Compare commits
146
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbd1be8952 | ||
|
|
0bf5d6065c | ||
|
|
67c3a4fd73 | ||
|
|
9beb24273b | ||
|
|
5cc6128086 | ||
|
|
569b6be2b4 | ||
|
|
752256f12e | ||
|
|
a3502b9755 | ||
|
|
0e1524c600 | ||
|
|
11ae7593f4 | ||
|
|
3eb75ea0b8 | ||
|
|
7365d8d8fb | ||
|
|
7755d77340 | ||
|
|
564aae21f0 | ||
|
|
0fdd2fc1e1 | ||
|
|
f15a7348db | ||
|
|
f3c846201c | ||
|
|
ee9804f1bb | ||
|
|
4ce6169d14 | ||
|
|
60a0970d73 | ||
|
|
fc29dcede7 | ||
|
|
5f774ce26e | ||
|
|
25b82997b6 | ||
|
|
b4a3c3a925 | ||
|
|
9c0ee7940e | ||
|
|
5750590b68 | ||
|
|
3c42c114f5 | ||
|
|
5aef419582 | ||
|
|
0c0dfa1df7 | ||
|
|
c162a41bc0 | ||
|
|
97e43b2335 | ||
|
|
db21e1217c | ||
|
|
5bb9066be8 | ||
|
|
b1fd259f10 | ||
|
|
3687bb8fa2 | ||
|
|
a80caf5676 | ||
|
|
d39e3454aa | ||
|
|
a8f2055c77 | ||
|
|
cdeb97d841 | ||
|
|
36c5305db7 | ||
|
|
5f2b4e2d40 | ||
|
|
cff26813b9 | ||
|
|
39c8ec4a02 | ||
|
|
c6c859a495 | ||
|
|
bf5cfff12c | ||
|
|
30a8ec4420 | ||
|
|
ec43591a99 | ||
|
|
0e5fb065e2 | ||
|
|
5188b2aff2 | ||
|
|
cd1fd2c9d3 | ||
|
|
77425cca32 | ||
|
|
29d245cde3 | ||
|
|
7a491a325d | ||
|
|
db64621122 | ||
|
|
6332d83a12 | ||
|
|
3834c40aa8 | ||
|
|
90c8c393e5 | ||
|
|
cb799da13a | ||
|
|
e0ddb17539 | ||
|
|
2820683cba | ||
|
|
37167f78e3 | ||
|
|
cf68b59b3f | ||
|
|
5e512407e5 | ||
|
|
13889005f8 | ||
|
|
f0dc5acfd3 | ||
|
|
63bed28251 | ||
|
|
95dcd3df58 | ||
|
|
1e9312acbd | ||
|
|
5884bd8873 | ||
|
|
fc161506e7 | ||
|
|
b3e50a1f5f | ||
|
|
fe32c9313a | ||
|
|
6123660346 | ||
|
|
b680f882cc | ||
|
|
5e0c16e808 | ||
|
|
66509b26bd | ||
|
|
15ad53af1f | ||
|
|
883842636b | ||
|
|
b9f430de16 | ||
|
|
25e040a5dd | ||
|
|
f2df990746 | ||
|
|
77bc174e43 | ||
|
|
0e9349e508 | ||
|
|
0208aa4d7c | ||
|
|
42a2e18047 | ||
|
|
9df3971f6c | ||
|
|
7ed269cf2c | ||
|
|
7f81c304ae | ||
|
|
5f29cfaf0f | ||
|
|
d2de8d3188 | ||
|
|
d3a2d38b37 | ||
|
|
a1a9124f3d | ||
|
|
009011a917 | ||
|
|
399871f4f9 | ||
|
|
158fc36294 | ||
|
|
7dc4836c71 | ||
|
|
efc3812e12 | ||
|
|
8d5fd84962 | ||
|
|
f69e65b014 | ||
|
|
49d14a00af | ||
|
|
9ab2418a5f | ||
|
|
63aa9d424b | ||
|
|
43ab17b253 | ||
|
|
4b8cf83ee9 | ||
|
|
cdaf3deb03 | ||
|
|
3ce4179f3c | ||
|
|
1a47905beb | ||
|
|
b51d979a1c | ||
|
|
bc01da1708 | ||
|
|
500a543045 | ||
|
|
60024facab | ||
|
|
53ea0e74a3 | ||
|
|
6c8ca17590 | ||
|
|
d62d212c86 | ||
|
|
bfceb07a23 | ||
|
|
e7a8ad0910 | ||
|
|
0435608ef9 | ||
|
|
c2a8a08618 | ||
|
|
35876fcd6f | ||
|
|
7207d1b749 | ||
|
|
4b92a6eb81 | ||
|
|
9ccc8459d7 | ||
|
|
f282585b52 | ||
|
|
40e2040fb0 | ||
|
|
bb07ba0f69 | ||
|
|
1fefed2aa0 | ||
|
|
4489dfb2d6 | ||
|
|
4b240d70d0 | ||
|
|
63252ae85e | ||
|
|
a359874a42 | ||
|
|
4ec247f2c0 | ||
|
|
2697a35c0a | ||
|
|
7295545cde | ||
|
|
a5b5a2a3d3 | ||
|
|
01596864dc | ||
|
|
02119a3acd | ||
|
|
c36994c36b | ||
|
|
dfbaa859ad | ||
|
|
b9a75b6831 | ||
|
|
11ad94f67d | ||
|
|
84ecd4f061 | ||
|
|
6d8379501c | ||
|
|
5f31bb47fb | ||
|
|
bdb9b72fac | ||
|
|
ce00c4c671 | ||
|
|
f3f831653f |
No files matched your search
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Adapting installed ReUI code (reuse-first, no AI slop)
|
||||||
|
|
||||||
|
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
|
||||||
|
|
||||||
|
## Preserve the design - don't over-customize
|
||||||
|
|
||||||
|
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
|
||||||
|
|
||||||
|
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
|
||||||
|
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
|
||||||
|
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
|
||||||
|
|
||||||
|
## Reuse the parts: examples and the block's own elements
|
||||||
|
|
||||||
|
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
|
||||||
|
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
|
||||||
|
|
||||||
|
## Don't invent (read, don't guess)
|
||||||
|
|
||||||
|
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
|
||||||
|
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
|
||||||
|
|
||||||
|
## What to change vs leave alone
|
||||||
|
|
||||||
|
- **Change:** the item's own data, copy, props, and layout to fit the app.
|
||||||
|
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
|
||||||
|
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
|
||||||
|
|
||||||
|
## Demo data -> real data
|
||||||
|
|
||||||
|
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
|
||||||
|
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
|
||||||
|
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
|
||||||
|
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
|
||||||
|
|
||||||
|
## Believable content (no AI tells)
|
||||||
|
|
||||||
|
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
|
||||||
|
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
|
||||||
|
|
||||||
|
## Operational surfaces (settings / profile / admin)
|
||||||
|
|
||||||
|
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CLI: registry setup, license, non-interactive install
|
||||||
|
|
||||||
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
|
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
|
||||||
|
|
||||||
|
1. Add the key to `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REUI_LICENSE_KEY=your-license-key
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Switch `components.json` to the authenticated object form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registries": {
|
||||||
|
"@reui": {
|
||||||
|
"url": "https://reui.io/r/{style}/{name}.json",
|
||||||
|
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
Use the project's package runner (check `packageManager`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes # npm
|
||||||
|
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
|
||||||
|
bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||||
|
```
|
||||||
|
|
||||||
|
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
|
||||||
|
|
||||||
|
## Handling prompts and conflicts
|
||||||
|
|
||||||
|
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
|
||||||
|
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
|
||||||
|
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
|
||||||
|
- Run from the **project root** so `components.json` and `.env.local` are found.
|
||||||
|
|
||||||
|
## Free vs premium boundary
|
||||||
|
|
||||||
|
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# ReUI components
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
})
|
||||||
|
|
||||||
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes:
|
||||||
|
|
||||||
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
|
## kanban
|
||||||
|
|
||||||
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
|
||||||
|
<KanbanBoard>
|
||||||
|
{Object.entries(cols).map(([id, items]) => (
|
||||||
|
<KanbanColumn key={id} value={id}>
|
||||||
|
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||||
|
<KanbanColumnContent value={id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<KanbanItem key={i.id} value={i.id}>
|
||||||
|
<KanbanItemHandle>{i.title}</KanbanItemHandle>
|
||||||
|
</KanbanItem>
|
||||||
|
))}
|
||||||
|
</KanbanColumnContent>
|
||||||
|
</KanbanColumn>
|
||||||
|
))}
|
||||||
|
</KanbanBoard>
|
||||||
|
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
|
||||||
|
</Kanban>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
|
||||||
|
|
||||||
|
## sortable
|
||||||
|
|
||||||
|
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<SortableItem key={i.id} value={i.id}>
|
||||||
|
<SortableItemHandle><GripVertical /></SortableItemHandle>
|
||||||
|
{i.label}
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</Sortable>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
|
||||||
|
|
||||||
|
## filters
|
||||||
|
|
||||||
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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 fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange` to capture the value.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||||
|
|
||||||
|
<DateSelector value={value} onChange={setValue} label="Due date" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
|
||||||
|
|
||||||
|
## tree
|
||||||
|
|
||||||
|
**Required:** `tree` (a `@headless-tree/core` instance you construct)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tree tree={tree}>
|
||||||
|
{tree.getItems().map((item) => (
|
||||||
|
<TreeItem key={item.getId()} item={item}>
|
||||||
|
<TreeItemLabel />
|
||||||
|
</TreeItem>
|
||||||
|
))}
|
||||||
|
</Tree>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
|
||||||
|
|
||||||
|
## stepper
|
||||||
|
|
||||||
|
**Required:** `StepperItem step` (number), `StepperContent value` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stepper defaultValue={1}>
|
||||||
|
<StepperNav>
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel>
|
||||||
|
<StepperContent value={1}>Step 1 content</StepperContent>
|
||||||
|
<StepperContent value={2}>Step 2 content</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
|
||||||
|
|
||||||
|
## timeline
|
||||||
|
|
||||||
|
**Required:** `TimelineItem step` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Timeline>
|
||||||
|
<TimelineItem step={1}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>March 2024</TimelineDate>
|
||||||
|
<TimelineTitle>Project initialized</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
<TimelineContent>Repo and architecture set up.</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
</Timeline>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
|
||||||
|
|
||||||
|
## autocomplete
|
||||||
|
|
||||||
|
**Required:** `items` (array; each item has at least `value`)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Autocomplete items={items}>
|
||||||
|
<AutocompleteInput placeholder="Search..." />
|
||||||
|
<AutocompleteContent>
|
||||||
|
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
|
||||||
|
<AutocompleteList>
|
||||||
|
{(item) => (
|
||||||
|
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
|
||||||
|
)}
|
||||||
|
</AutocompleteList>
|
||||||
|
</AutocompleteContent>
|
||||||
|
</Autocomplete>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
|
||||||
|
|
||||||
|
## phone-input
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
|
||||||
|
|
||||||
|
## number-field
|
||||||
|
|
||||||
|
**Required:** wrap the controls in `NumberFieldGroup`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NumberField defaultValue={0}>
|
||||||
|
<NumberFieldScrubArea label="Quantity" />
|
||||||
|
<NumberFieldGroup>
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
|
||||||
|
|
||||||
|
## rating
|
||||||
|
|
||||||
|
**Required:** `rating` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
|
||||||
|
|
||||||
|
## scrollspy
|
||||||
|
|
||||||
|
**Required:** `targetRef` (the scroll container ref)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Scrollspy targetRef={containerRef}>
|
||||||
|
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
|
||||||
|
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
|
||||||
|
</Scrollspy>
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<div id="s1">...</div>
|
||||||
|
<div id="s2">...</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
|
||||||
|
|
||||||
|
## frame
|
||||||
|
|
||||||
|
**Required:** `Frame` > `FramePanel`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Frame>
|
||||||
|
<FramePanel>
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Title</FrameTitle>
|
||||||
|
<FrameDescription>Description</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<div className="p-5">Content</div>
|
||||||
|
<FrameFooter>Footer</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||||
|
|
||||||
|
## icon-stack
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconStack aria-hidden="true">
|
||||||
|
<InboxIcon className="size-4" />
|
||||||
|
</IconStack>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
|
## alert
|
||||||
|
|
||||||
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Alert variant="success">
|
||||||
|
<ShieldCheckIcon />
|
||||||
|
<AlertTitle>Security update</AlertTitle>
|
||||||
|
<AlertDescription>Enable two-factor authentication.</AlertDescription>
|
||||||
|
<AlertAction><Button size="xs">Update</Button></AlertAction>
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
|
||||||
|
|
||||||
|
## badge
|
||||||
|
|
||||||
|
**Required:** none (text child).
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="success-light" size="sm">Success</Badge>
|
||||||
|
<Badge variant="outline" radius="full">Pill</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||||
|
|
||||||
|
## base vs radix - write for the project's base
|
||||||
|
|
||||||
|
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||||
|
|
||||||
|
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||||
|
|
||||||
|
- `"style": "base-nova"` -> **Base UI**
|
||||||
|
- `"style": "radix-nova"` -> **Radix UI**
|
||||||
|
|
||||||
|
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||||
|
|
||||||
|
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
|
||||||
|
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
|
||||||
|
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
|
||||||
|
|
||||||
|
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Craft: make ReUI UI exceptional, not generic
|
||||||
|
|
||||||
|
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||||
|
|
||||||
|
## Have a point of view
|
||||||
|
|
||||||
|
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
|
||||||
|
|
||||||
|
## Brutally clear hierarchy
|
||||||
|
|
||||||
|
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||||
|
|
||||||
|
## Spacing rhythm and deliberate density
|
||||||
|
|
||||||
|
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||||
|
|
||||||
|
## Cover the real states (the usual miss)
|
||||||
|
|
||||||
|
A surface isn't done at the happy path. Compose, and wire:
|
||||||
|
|
||||||
|
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||||
|
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||||
|
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||||
|
|
||||||
|
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||||
|
|
||||||
|
## Responsive by default
|
||||||
|
|
||||||
|
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||||
|
|
||||||
|
## Motion, subtly
|
||||||
|
|
||||||
|
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||||
|
|
||||||
|
## Real, activated content
|
||||||
|
|
||||||
|
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||||
|
|
||||||
|
## Avoid the AI tells
|
||||||
|
|
||||||
|
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||||
|
|
||||||
|
## The bar
|
||||||
|
|
||||||
|
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Icons (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||||
|
|
||||||
|
## Portable icons (library-agnostic)
|
||||||
|
|
||||||
|
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||||
|
|
||||||
|
- `lucide` -> `lucide-react`
|
||||||
|
- `tabler` -> `@tabler/icons-react`
|
||||||
|
- `phosphor` -> `@phosphor-icons/react`
|
||||||
|
- `remix` -> `@remixicon/react`
|
||||||
|
- `hugeicons` -> `@hugeicons/react`
|
||||||
|
|
||||||
|
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||||
|
|
||||||
|
## Keep icons purposeful
|
||||||
|
|
||||||
|
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
|
||||||
|
|
||||||
|
## Motion Icons (the `@reui/icons/...` set)
|
||||||
|
|
||||||
|
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||||
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
|
```
|
||||||
|
|
||||||
|
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
|
Finding icons:
|
||||||
|
|
||||||
|
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
|
||||||
|
- One icon: `search` with `type: "icon"`.
|
||||||
|
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||||
|
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||||
|
|
||||||
|
The `icon-stack` component composes multiple icons into a stacked display.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Quality gates (security, accessibility, scroll)
|
||||||
|
|
||||||
|
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Never `dangerouslySetInnerHTML`. Render data as text/components.
|
||||||
|
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
|
||||||
|
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
|
||||||
|
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
|
||||||
|
- Every non-submit button is `type="button"`.
|
||||||
|
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||||
|
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||||
|
|
||||||
|
## Scroll mechanics
|
||||||
|
|
||||||
|
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
|
||||||
|
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# ReUI registry structure
|
||||||
|
|
||||||
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
|
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
|
|
||||||
|
## The @reui registry
|
||||||
|
|
||||||
|
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
|
||||||
|
|
||||||
|
## Know your base: base or radix
|
||||||
|
|
||||||
|
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
|
||||||
|
|
||||||
|
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
|
||||||
|
|
||||||
|
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||||
|
|
||||||
|
## Free vs premium
|
||||||
|
|
||||||
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
|
## Component API index
|
||||||
|
|
||||||
|
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Styling (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||||
|
|
||||||
|
## ReUI extended semantic tokens
|
||||||
|
|
||||||
|
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||||
|
|
||||||
|
- `--success` / `--success-foreground`
|
||||||
|
- `--info` / `--info-foreground`
|
||||||
|
- `--warning` / `--warning-foreground`
|
||||||
|
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||||
|
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||||
|
|
||||||
|
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||||
|
|
||||||
|
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||||
|
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||||
|
|
||||||
|
## Blocks follow your theme
|
||||||
|
|
||||||
|
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||||
|
|
||||||
|
## Density and typography rhythm
|
||||||
|
|
||||||
|
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Workflow: find -> install -> read API -> adapt
|
||||||
|
|
||||||
|
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
|
||||||
|
|
||||||
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||||
|
|
||||||
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
|
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
|
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||||
|
|
||||||
|
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||||
|
|
||||||
|
## 2. Install (shadcn CLI)
|
||||||
|
|
||||||
|
Run the result's `install` command from the project root, non-interactively:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
|
||||||
|
|
||||||
|
## 3. Read the API (do not guess props)
|
||||||
|
|
||||||
|
Before writing code against any component an item uses:
|
||||||
|
|
||||||
|
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its 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.
|
||||||
|
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||||
|
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||||
|
|
||||||
|
## 4. Adapt (reuse-first) - do not skip
|
||||||
|
|
||||||
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
|
||||||
|
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
|
||||||
|
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
|
||||||
|
8. **Typecheck / lint**.
|
||||||
|
|
||||||
|
## If no single block fits
|
||||||
|
|
||||||
|
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# ReUI MCP: full reference
|
||||||
|
|
||||||
|
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
|
||||||
|
|
||||||
|
## Golden path (token-optimal - follow this order)
|
||||||
|
|
||||||
|
Most tasks need 2-4 MCP calls and ZERO web fetches:
|
||||||
|
|
||||||
|
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
|
||||||
|
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
|
||||||
|
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
|
||||||
|
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
|
||||||
|
5. **`get_audit_checklist()`** before declaring done.
|
||||||
|
|
||||||
|
If you already know the exact item name, skip `search`. Everything else is situational.
|
||||||
|
|
||||||
|
## The 5 task-specific tools (when to reach for each)
|
||||||
|
|
||||||
|
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||||
|
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||||
|
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||||
|
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||||
|
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||||
|
|
||||||
|
## All 19 tools
|
||||||
|
|
||||||
|
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
|
## Token + speed rules
|
||||||
|
|
||||||
|
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
|
||||||
|
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
|
||||||
|
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
|
||||||
|
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
|
||||||
|
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
|
||||||
|
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
|
||||||
|
|
||||||
|
## Result shapes (so you don't re-fetch)
|
||||||
|
|
||||||
|
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
|
||||||
|
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||||
|
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||||
|
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||||
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
|
||||||
|
## Error playbook
|
||||||
|
|
||||||
|
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||||
|
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||||
|
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||||
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||||
|
|
||||||
|
## Fallbacks
|
||||||
|
|
||||||
|
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||||
|
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||||
|
|
||||||
|
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||||
@@ -54,12 +54,6 @@ function formatSemver(parts) {
|
|||||||
|
|
||||||
function bumpVersion(version, bump) {
|
function bumpVersion(version, bump) {
|
||||||
const parts = parseSemver(version)
|
const parts = parseSemver(version)
|
||||||
if (bump === "major") {
|
|
||||||
parts.major += 1
|
|
||||||
parts.minor = 0
|
|
||||||
parts.patch = 0
|
|
||||||
return formatSemver(parts)
|
|
||||||
}
|
|
||||||
if (bump === "minor") {
|
if (bump === "minor") {
|
||||||
parts.minor += 1
|
parts.minor += 1
|
||||||
parts.patch = 0
|
parts.patch = 0
|
||||||
@@ -72,8 +66,8 @@ function bumpVersion(version, bump) {
|
|||||||
function parseCommit(subject, body = "") {
|
function parseCommit(subject, body = "") {
|
||||||
const match = CONVENTIONAL_RE.exec(subject.trim())
|
const match = CONVENTIONAL_RE.exec(subject.trim())
|
||||||
const breaking =
|
const breaking =
|
||||||
/BREAKING[ -]CHANGE:/i.test(body) ||
|
/BREAKING CHANGE/i.test(body) ||
|
||||||
(match !== null && /^[^:]+!:\s/.test(subject.trim()))
|
(match?.[1] && subject.includes(`${match[1]}!:`))
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return { type: "other", scope: null, description: subject.trim(), bump: "patch" }
|
return { type: "other", scope: null, description: subject.trim(), bump: "patch" }
|
||||||
@@ -84,9 +78,7 @@ function parseCommit(subject, body = "") {
|
|||||||
const description = match[3].trim()
|
const description = match[3].trim()
|
||||||
let bump = "patch"
|
let bump = "patch"
|
||||||
|
|
||||||
if (breaking) {
|
if (breaking || MINOR_TYPES.has(type)) {
|
||||||
bump = "major"
|
|
||||||
} else if (MINOR_TYPES.has(type)) {
|
|
||||||
bump = "minor"
|
bump = "minor"
|
||||||
} else if (PATCH_TYPES.has(type)) {
|
} else if (PATCH_TYPES.has(type)) {
|
||||||
bump = "patch"
|
bump = "patch"
|
||||||
@@ -259,9 +251,7 @@ function main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const bump = commits.some((commit) => commit.bump === "major")
|
const bump = commits.some((commit) => commit.bump === "minor") ? "minor" : "patch"
|
||||||
? "major"
|
|
||||||
: commits.some((commit) => commit.bump === "minor") ? "minor" : "patch"
|
|
||||||
const version = bumpVersion(baseFromTag, bump)
|
const version = bumpVersion(baseFromTag, bump)
|
||||||
const tag = `v${version}`
|
const tag = `v${version}`
|
||||||
const publishedAt = new Date().toISOString()
|
const publishedAt = new Date().toISOString()
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import assert from "node:assert/strict"
|
|
||||||
import { execFileSync } from "node:child_process"
|
|
||||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
||||||
import { tmpdir } from "node:os"
|
|
||||||
import { basename, dirname, join, resolve } from "node:path"
|
|
||||||
import { fileURLToPath } from "node:url"
|
|
||||||
import test from "node:test"
|
|
||||||
|
|
||||||
const source = fileURLToPath(new URL("./compute-release.mjs", import.meta.url))
|
|
||||||
|
|
||||||
const cases = [
|
|
||||||
{ name: "исправление повышает patch", commits: [["fix(ui): исправить отображение"]], version: "1.47.7", bump: "patch" },
|
|
||||||
{ name: "новая возможность повышает minor", commits: [["feat(ui): добавить раздел"]], version: "1.48.0", bump: "minor" },
|
|
||||||
{ name: "breaking со scope повышает major", commits: [["chore(release)!: подготовить вторую основную версию"]], version: "2.0.0", bump: "major" },
|
|
||||||
{ name: "breaking без scope повышает major", commits: [["feat!: заменить контракт"]], version: "2.0.0", bump: "major" },
|
|
||||||
{ name: "footer со пробелом повышает major", commits: [["fix(api): заменить контракт", "BREAKING CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
|
|
||||||
{ name: "footer с дефисом повышает major", commits: [["fix(api): заменить контракт", "BREAKING-CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
|
|
||||||
{ name: "major имеет приоритет в смешанной истории", commits: [["chore(release)!: подготовить выпуск"], ["feat(ui): добавить раздел"], ["fix(ui): исправить отображение"]], version: "2.0.0", bump: "major" },
|
|
||||||
{ name: "после второй версии сохраняется patch", base: "2.0.0", commits: [["fix(ui): исправить отображение"]], version: "2.0.1", bump: "patch" },
|
|
||||||
{ name: "после второй версии сохраняется minor", base: "2.0.0", commits: [["feat(ui): добавить раздел"]], version: "2.1.0", bump: "minor" },
|
|
||||||
{ name: "готовый тег не выпускается повторно", base: "2.0.0", commits: [], version: "2.0.0", bump: "none" },
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const scenario of cases) test(scenario.name, () => {
|
|
||||||
const parent = resolve(tmpdir())
|
|
||||||
const root = mkdtempSync(join(parent, "mm-release-"))
|
|
||||||
const git = (...args) => execFileSync("git", args, { cwd: root, stdio: "pipe" })
|
|
||||||
try {
|
|
||||||
mkdirSync(join(root, ".ci/scripts"), { recursive: true })
|
|
||||||
copyFileSync(source, join(root, ".ci/scripts/compute-release.mjs"))
|
|
||||||
git("init", "--initial-branch=main")
|
|
||||||
git("config", "user.name", "Release Test")
|
|
||||||
git("config", "user.email", "release@example.invalid")
|
|
||||||
git("config", "core.hooksPath", ".git/hooks")
|
|
||||||
writeFileSync(join(root, "fixture"), "baseline")
|
|
||||||
git("add", "fixture")
|
|
||||||
git("commit", "-m", "chore(test): создать исходную базу")
|
|
||||||
git("tag", `v${scenario.base ?? "1.47.6"}`)
|
|
||||||
for (const [index, [subject, body]] of scenario.commits.entries()) {
|
|
||||||
writeFileSync(join(root, "fixture"), `change ${index}`)
|
|
||||||
git("add", "fixture")
|
|
||||||
git("commit", "-m", subject, ...(body ? ["-m", body] : []))
|
|
||||||
}
|
|
||||||
execFileSync(process.execPath, [join(root, ".ci/scripts/compute-release.mjs")], {
|
|
||||||
cwd: root, stdio: "pipe",
|
|
||||||
env: { ...process.env, BASE_VERSION: "1.0.0", RELEASE_OUTPUT_DIR: ".ci/release", GITHUB_OUTPUT: join(root, "outputs"), GITEA_REPOSITORY: "test/repo", GITEA_SERVER_URL: "https://example.invalid" },
|
|
||||||
})
|
|
||||||
const manifest = JSON.parse(readFileSync(join(root, ".ci/release/release-manifest.json"), "utf8"))
|
|
||||||
const output = readFileSync(join(root, "outputs"), "utf8")
|
|
||||||
assert.equal(manifest.version, scenario.version)
|
|
||||||
assert.equal(manifest.tag, `v${scenario.version}`)
|
|
||||||
assert.equal(manifest.releaseUrl, `https://example.invalid/test/repo/releases/tag/v${scenario.version}`)
|
|
||||||
assert.match(output, new RegExp(`^bump=${scenario.bump}$`, "m"))
|
|
||||||
assert.match(output, new RegExp(`^should_release=${scenario.bump !== "none"}$`, "m"))
|
|
||||||
if (scenario.bump !== "none") assert.match(readFileSync(join(root, ".ci/release/release_notes.md"), "utf8"), new RegExp(`# v${scenario.version.replaceAll(".", "\\.")}`))
|
|
||||||
} finally {
|
|
||||||
assert.equal(dirname(resolve(root)), parent)
|
|
||||||
assert.ok(basename(root).startsWith("mm-release-"))
|
|
||||||
rmSync(root, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":"0.0.1","configurations":[{"name":"MikrotikManager","runtimeExecutable":"npm","runtimeArgs":["run","dev"],"port":3000,"autoPort":true}]}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm install *)",
|
||||||
|
"mcp__Claude_Preview__preview_start",
|
||||||
|
"Bash(npx tsc *)",
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/dashboard)",
|
||||||
|
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000__TRACKED_VAR__)",
|
||||||
|
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3001__TRACKED_VAR__)",
|
||||||
|
"PowerShell(netstat -ano | Select-String \":300\" | Where-Object { $_ -match \"LISTENING\" })",
|
||||||
|
"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.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]\\)\\)\")",
|
||||||
|
"mcp__Claude_in_Chrome__tabs_context_mcp",
|
||||||
|
"Bash(npx eslint *)",
|
||||||
|
"Bash(grep -E \"^C:\\\\\\\\\\\\\\\\| [0-9]+:[0-9]+ error\")",
|
||||||
|
"Bash(grep -E \"\\(\\\\\\\\\\\\\\\\.*\\\\.tsx$\\)|\\(error\\)\")",
|
||||||
|
"Bash(python -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(m['ruleId'], m['line'], m['message'][:60]\\) for f in data for m in f['messages'] if m['severity']==2]\")",
|
||||||
|
"Bash(python -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(m['ruleId'], m['line'], m['message'][:80]\\) for f in data for m in f['messages'] if m['severity']==2]\")",
|
||||||
|
"Bash(npx next *)",
|
||||||
|
"Bash(dir \"C:\\\\Users\\\\shats\\\\Dev\\\\MikrotikManager-3\\\\app\\\\\\(main\\)\")",
|
||||||
|
"Bash(Get-ChildItem \"C:\\\\Users\\\\shats\\\\Dev\\\\MikrotikManager-3\\\\app\\\\\\(main\\)\" -Directory)",
|
||||||
|
"Bash(Select-Object Name)",
|
||||||
|
"Bash(Sort-Object Name)",
|
||||||
|
"Bash(awk 'NR>=508 && NR<=730' \"app/\\(main\\)/servers/page.tsx\")",
|
||||||
|
"Bash(grep -n \"Fragment\\\\|<>\\\\s*$\" \"app/\\(main\\)/filters/page.tsx\")",
|
||||||
|
"Bash(grep -n \"\\\\.map\\\\|Fragment\\\\|<>\\\\s*$\" \"app/\\(main\\)/ospf/page.tsx\")",
|
||||||
|
"Bash(grep -v \"//.*console\\\\|ErrorRecord\\\\|console\\\\.error.*err\\\\|any>\\\\|: any\\\\|as any\\\\|any\\\\[\\\\]\\\\|Record<.*any\\\\|Promise<any\")",
|
||||||
|
"Bash(curl -s http://localhost:8000/api/servers)",
|
||||||
|
"Bash(curl -s -X POST http://localhost:8000/api/servers/1/poll)",
|
||||||
|
"Bash(curl -s http://localhost:8000/api/servers/1)",
|
||||||
|
"Bash(curl -s http://localhost:8000/api/servers/1/snapshots)",
|
||||||
|
"Bash(curl -s -X DELETE http://localhost:8000/api/servers/1 -w \"\\\\nHTTP %{http_code}\")",
|
||||||
|
"Bash(curl -s http://localhost:8000/api/servers/999)",
|
||||||
|
"Bash(curl -s -X POST http://localhost:8000/api/servers -H \"Content-Type: application/json\" -d \"{}\")",
|
||||||
|
"Bash(curl -s -X POST http://localhost:8000/api/servers/test-connection -H 'Content-Type: application/json' -d '{\"host\":\"192.168.0.254\",\"port\":80,\"useSsl\":false,\"verifySsl\":false,\"apiPath\":\"/rest\",\"username\":\"admin\",\"password\":\"\"}')",
|
||||||
|
"Bash(curl -s -X POST http://localhost:8000/api/servers -H 'Content-Type: application/json' -d '{\"host\":\"192.168.0.254\",\"port\":80,\"useSsl\":false,\"verifySsl\":false,\"username\":\"admin\",\"password\":\"test123\",\"name\":\"RB5009 Home\",\"type\":\"home-router\",\"site\":\"MSK\",\"country\":\"RU\",\"asn\":\"\",\"comment\":\"Тестовый роутер\",\"enabled\":true}')",
|
||||||
|
"Bash(python3 -m json.tool)",
|
||||||
|
"Bash(curl -s -X POST http://localhost:8000/api/servers -H 'Content-Type: application/json' -d '{\"host\":\"192.168.0.254\",\"port\":80,\"useSsl\":false,\"verifySsl\":false,\"username\":\"admin\",\"password\":\"test123\",\"name\":\"RB5009 Home\",\"type\":\"home-router\",\"site\":\"MSK\",\"country\":\"RU\"}')",
|
||||||
|
"Bash(node -e \"const d=require\\('fs'\\).readFileSync\\('/dev/stdin','utf8'\\); const a=JSON.parse\\(d\\); a.forEach\\(s=>console.log\\(s.id, s.host, s.name\\)\\)\")",
|
||||||
|
"Bash(curl -s http://localhost:8000/api/bgp/sessions)",
|
||||||
|
"Bash(curl -s \"http://localhost:8000/api/bgp/sessions/raw/3\")",
|
||||||
|
"Bash(node -e \"0\")",
|
||||||
|
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/neighbor\")",
|
||||||
|
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/instance\")",
|
||||||
|
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/area\")",
|
||||||
|
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/interface-template\")",
|
||||||
|
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/ip/route?.type=ospf\")",
|
||||||
|
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/ip/route\")",
|
||||||
|
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://192.168.0.254/rest/routing/ospf/neighbor\")",
|
||||||
|
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://192.168.0.254/rest/routing/ospf/instance\")",
|
||||||
|
"Bash(curl -s http://localhost:8000/api/ospf/all)",
|
||||||
|
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://192.168.0.254/rest/routing/bfd/session\")",
|
||||||
|
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/bfd/session\")",
|
||||||
|
"Bash(node -e \"const j=JSON.parse\\(require\\('fs'\\).readFileSync\\(0,'utf8'\\)\\); console.log\\('bfd:', JSON.stringify\\(j.bfdSessions, null, 2\\)\\)\")",
|
||||||
|
"Bash(xargs grep *)",
|
||||||
|
"Bash(npx shadcn@latest add popover --yes)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"REUI_LICENSE_KEY": "REUI-EAF4-EA40-6C5C-5D86"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Adapting installed ReUI code (reuse-first, no AI slop)
|
||||||
|
|
||||||
|
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
|
||||||
|
|
||||||
|
## Preserve the design - don't over-customize
|
||||||
|
|
||||||
|
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
|
||||||
|
|
||||||
|
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
|
||||||
|
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
|
||||||
|
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
|
||||||
|
|
||||||
|
## Reuse the parts: examples and the block's own elements
|
||||||
|
|
||||||
|
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
|
||||||
|
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
|
||||||
|
|
||||||
|
## Don't invent (read, don't guess)
|
||||||
|
|
||||||
|
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
|
||||||
|
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
|
||||||
|
|
||||||
|
## What to change vs leave alone
|
||||||
|
|
||||||
|
- **Change:** the item's own data, copy, props, and layout to fit the app.
|
||||||
|
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
|
||||||
|
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
|
||||||
|
|
||||||
|
## Demo data -> real data
|
||||||
|
|
||||||
|
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
|
||||||
|
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
|
||||||
|
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
|
||||||
|
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
|
||||||
|
|
||||||
|
## Believable content (no AI tells)
|
||||||
|
|
||||||
|
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
|
||||||
|
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
|
||||||
|
|
||||||
|
## Operational surfaces (settings / profile / admin)
|
||||||
|
|
||||||
|
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CLI: registry setup, license, non-interactive install
|
||||||
|
|
||||||
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
|
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
|
||||||
|
|
||||||
|
1. Add the key to `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REUI_LICENSE_KEY=your-license-key
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Switch `components.json` to the authenticated object form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registries": {
|
||||||
|
"@reui": {
|
||||||
|
"url": "https://reui.io/r/{style}/{name}.json",
|
||||||
|
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
Use the project's package runner (check `packageManager`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes # npm
|
||||||
|
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
|
||||||
|
bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||||
|
```
|
||||||
|
|
||||||
|
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
|
||||||
|
|
||||||
|
## Handling prompts and conflicts
|
||||||
|
|
||||||
|
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
|
||||||
|
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
|
||||||
|
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
|
||||||
|
- Run from the **project root** so `components.json` and `.env.local` are found.
|
||||||
|
|
||||||
|
## Free vs premium boundary
|
||||||
|
|
||||||
|
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# ReUI components
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
})
|
||||||
|
|
||||||
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes:
|
||||||
|
|
||||||
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
|
## kanban
|
||||||
|
|
||||||
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
|
||||||
|
<KanbanBoard>
|
||||||
|
{Object.entries(cols).map(([id, items]) => (
|
||||||
|
<KanbanColumn key={id} value={id}>
|
||||||
|
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||||
|
<KanbanColumnContent value={id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<KanbanItem key={i.id} value={i.id}>
|
||||||
|
<KanbanItemHandle>{i.title}</KanbanItemHandle>
|
||||||
|
</KanbanItem>
|
||||||
|
))}
|
||||||
|
</KanbanColumnContent>
|
||||||
|
</KanbanColumn>
|
||||||
|
))}
|
||||||
|
</KanbanBoard>
|
||||||
|
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
|
||||||
|
</Kanban>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
|
||||||
|
|
||||||
|
## sortable
|
||||||
|
|
||||||
|
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<SortableItem key={i.id} value={i.id}>
|
||||||
|
<SortableItemHandle><GripVertical /></SortableItemHandle>
|
||||||
|
{i.label}
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</Sortable>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
|
||||||
|
|
||||||
|
## filters
|
||||||
|
|
||||||
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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 fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange` to capture the value.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||||
|
|
||||||
|
<DateSelector value={value} onChange={setValue} label="Due date" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
|
||||||
|
|
||||||
|
## tree
|
||||||
|
|
||||||
|
**Required:** `tree` (a `@headless-tree/core` instance you construct)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tree tree={tree}>
|
||||||
|
{tree.getItems().map((item) => (
|
||||||
|
<TreeItem key={item.getId()} item={item}>
|
||||||
|
<TreeItemLabel />
|
||||||
|
</TreeItem>
|
||||||
|
))}
|
||||||
|
</Tree>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
|
||||||
|
|
||||||
|
## stepper
|
||||||
|
|
||||||
|
**Required:** `StepperItem step` (number), `StepperContent value` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stepper defaultValue={1}>
|
||||||
|
<StepperNav>
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel>
|
||||||
|
<StepperContent value={1}>Step 1 content</StepperContent>
|
||||||
|
<StepperContent value={2}>Step 2 content</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
|
||||||
|
|
||||||
|
## timeline
|
||||||
|
|
||||||
|
**Required:** `TimelineItem step` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Timeline>
|
||||||
|
<TimelineItem step={1}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>March 2024</TimelineDate>
|
||||||
|
<TimelineTitle>Project initialized</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
<TimelineContent>Repo and architecture set up.</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
</Timeline>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
|
||||||
|
|
||||||
|
## autocomplete
|
||||||
|
|
||||||
|
**Required:** `items` (array; each item has at least `value`)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Autocomplete items={items}>
|
||||||
|
<AutocompleteInput placeholder="Search..." />
|
||||||
|
<AutocompleteContent>
|
||||||
|
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
|
||||||
|
<AutocompleteList>
|
||||||
|
{(item) => (
|
||||||
|
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
|
||||||
|
)}
|
||||||
|
</AutocompleteList>
|
||||||
|
</AutocompleteContent>
|
||||||
|
</Autocomplete>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
|
||||||
|
|
||||||
|
## phone-input
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
|
||||||
|
|
||||||
|
## number-field
|
||||||
|
|
||||||
|
**Required:** wrap the controls in `NumberFieldGroup`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NumberField defaultValue={0}>
|
||||||
|
<NumberFieldScrubArea label="Quantity" />
|
||||||
|
<NumberFieldGroup>
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
|
||||||
|
|
||||||
|
## rating
|
||||||
|
|
||||||
|
**Required:** `rating` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
|
||||||
|
|
||||||
|
## scrollspy
|
||||||
|
|
||||||
|
**Required:** `targetRef` (the scroll container ref)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Scrollspy targetRef={containerRef}>
|
||||||
|
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
|
||||||
|
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
|
||||||
|
</Scrollspy>
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<div id="s1">...</div>
|
||||||
|
<div id="s2">...</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
|
||||||
|
|
||||||
|
## frame
|
||||||
|
|
||||||
|
**Required:** `Frame` > `FramePanel`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Frame>
|
||||||
|
<FramePanel>
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Title</FrameTitle>
|
||||||
|
<FrameDescription>Description</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<div className="p-5">Content</div>
|
||||||
|
<FrameFooter>Footer</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||||
|
|
||||||
|
## icon-stack
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconStack aria-hidden="true">
|
||||||
|
<InboxIcon className="size-4" />
|
||||||
|
</IconStack>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
|
## alert
|
||||||
|
|
||||||
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Alert variant="success">
|
||||||
|
<ShieldCheckIcon />
|
||||||
|
<AlertTitle>Security update</AlertTitle>
|
||||||
|
<AlertDescription>Enable two-factor authentication.</AlertDescription>
|
||||||
|
<AlertAction><Button size="xs">Update</Button></AlertAction>
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
|
||||||
|
|
||||||
|
## badge
|
||||||
|
|
||||||
|
**Required:** none (text child).
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="success-light" size="sm">Success</Badge>
|
||||||
|
<Badge variant="outline" radius="full">Pill</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||||
|
|
||||||
|
## base vs radix - write for the project's base
|
||||||
|
|
||||||
|
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||||
|
|
||||||
|
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||||
|
|
||||||
|
- `"style": "base-nova"` -> **Base UI**
|
||||||
|
- `"style": "radix-nova"` -> **Radix UI**
|
||||||
|
|
||||||
|
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||||
|
|
||||||
|
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
|
||||||
|
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
|
||||||
|
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
|
||||||
|
|
||||||
|
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Craft: make ReUI UI exceptional, not generic
|
||||||
|
|
||||||
|
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||||
|
|
||||||
|
## Have a point of view
|
||||||
|
|
||||||
|
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
|
||||||
|
|
||||||
|
## Brutally clear hierarchy
|
||||||
|
|
||||||
|
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||||
|
|
||||||
|
## Spacing rhythm and deliberate density
|
||||||
|
|
||||||
|
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||||
|
|
||||||
|
## Cover the real states (the usual miss)
|
||||||
|
|
||||||
|
A surface isn't done at the happy path. Compose, and wire:
|
||||||
|
|
||||||
|
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||||
|
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||||
|
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||||
|
|
||||||
|
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||||
|
|
||||||
|
## Responsive by default
|
||||||
|
|
||||||
|
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||||
|
|
||||||
|
## Motion, subtly
|
||||||
|
|
||||||
|
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||||
|
|
||||||
|
## Real, activated content
|
||||||
|
|
||||||
|
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||||
|
|
||||||
|
## Avoid the AI tells
|
||||||
|
|
||||||
|
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||||
|
|
||||||
|
## The bar
|
||||||
|
|
||||||
|
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Icons (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||||
|
|
||||||
|
## Portable icons (library-agnostic)
|
||||||
|
|
||||||
|
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||||
|
|
||||||
|
- `lucide` -> `lucide-react`
|
||||||
|
- `tabler` -> `@tabler/icons-react`
|
||||||
|
- `phosphor` -> `@phosphor-icons/react`
|
||||||
|
- `remix` -> `@remixicon/react`
|
||||||
|
- `hugeicons` -> `@hugeicons/react`
|
||||||
|
|
||||||
|
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||||
|
|
||||||
|
## Keep icons purposeful
|
||||||
|
|
||||||
|
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
|
||||||
|
|
||||||
|
## Motion Icons (the `@reui/icons/...` set)
|
||||||
|
|
||||||
|
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||||
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
|
```
|
||||||
|
|
||||||
|
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
|
Finding icons:
|
||||||
|
|
||||||
|
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
|
||||||
|
- One icon: `search` with `type: "icon"`.
|
||||||
|
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||||
|
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||||
|
|
||||||
|
The `icon-stack` component composes multiple icons into a stacked display.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Quality gates (security, accessibility, scroll)
|
||||||
|
|
||||||
|
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Never `dangerouslySetInnerHTML`. Render data as text/components.
|
||||||
|
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
|
||||||
|
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
|
||||||
|
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
|
||||||
|
- Every non-submit button is `type="button"`.
|
||||||
|
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||||
|
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||||
|
|
||||||
|
## Scroll mechanics
|
||||||
|
|
||||||
|
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
|
||||||
|
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# ReUI registry structure
|
||||||
|
|
||||||
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
|
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
|
|
||||||
|
## The @reui registry
|
||||||
|
|
||||||
|
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
|
||||||
|
|
||||||
|
## Know your base: base or radix
|
||||||
|
|
||||||
|
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
|
||||||
|
|
||||||
|
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
|
||||||
|
|
||||||
|
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||||
|
|
||||||
|
## Free vs premium
|
||||||
|
|
||||||
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
|
## Component API index
|
||||||
|
|
||||||
|
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Styling (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||||
|
|
||||||
|
## ReUI extended semantic tokens
|
||||||
|
|
||||||
|
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||||
|
|
||||||
|
- `--success` / `--success-foreground`
|
||||||
|
- `--info` / `--info-foreground`
|
||||||
|
- `--warning` / `--warning-foreground`
|
||||||
|
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||||
|
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||||
|
|
||||||
|
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||||
|
|
||||||
|
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||||
|
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||||
|
|
||||||
|
## Blocks follow your theme
|
||||||
|
|
||||||
|
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||||
|
|
||||||
|
## Density and typography rhythm
|
||||||
|
|
||||||
|
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Workflow: find -> install -> read API -> adapt
|
||||||
|
|
||||||
|
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
|
||||||
|
|
||||||
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||||
|
|
||||||
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
|
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
|
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||||
|
|
||||||
|
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||||
|
|
||||||
|
## 2. Install (shadcn CLI)
|
||||||
|
|
||||||
|
Run the result's `install` command from the project root, non-interactively:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
|
||||||
|
|
||||||
|
## 3. Read the API (do not guess props)
|
||||||
|
|
||||||
|
Before writing code against any component an item uses:
|
||||||
|
|
||||||
|
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its 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.
|
||||||
|
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||||
|
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||||
|
|
||||||
|
## 4. Adapt (reuse-first) - do not skip
|
||||||
|
|
||||||
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
|
||||||
|
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
|
||||||
|
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
|
||||||
|
8. **Typecheck / lint**.
|
||||||
|
|
||||||
|
## If no single block fits
|
||||||
|
|
||||||
|
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# ReUI MCP: full reference
|
||||||
|
|
||||||
|
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
|
||||||
|
|
||||||
|
## Golden path (token-optimal - follow this order)
|
||||||
|
|
||||||
|
Most tasks need 2-4 MCP calls and ZERO web fetches:
|
||||||
|
|
||||||
|
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
|
||||||
|
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
|
||||||
|
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
|
||||||
|
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
|
||||||
|
5. **`get_audit_checklist()`** before declaring done.
|
||||||
|
|
||||||
|
If you already know the exact item name, skip `search`. Everything else is situational.
|
||||||
|
|
||||||
|
## The 5 task-specific tools (when to reach for each)
|
||||||
|
|
||||||
|
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||||
|
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||||
|
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||||
|
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||||
|
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||||
|
|
||||||
|
## All 19 tools
|
||||||
|
|
||||||
|
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
|
## Token + speed rules
|
||||||
|
|
||||||
|
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
|
||||||
|
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
|
||||||
|
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
|
||||||
|
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
|
||||||
|
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
|
||||||
|
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
|
||||||
|
|
||||||
|
## Result shapes (so you don't re-fetch)
|
||||||
|
|
||||||
|
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
|
||||||
|
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||||
|
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||||
|
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||||
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
|
||||||
|
## Error playbook
|
||||||
|
|
||||||
|
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||||
|
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||||
|
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||||
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||||
|
|
||||||
|
## Fallbacks
|
||||||
|
|
||||||
|
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||||
|
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||||
|
|
||||||
|
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# CodeGraph data files
|
||||||
|
# These are local to each machine and should not be committed
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
cache/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Hook markers
|
||||||
|
.dirty
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H6","location":"components/debug-runtime-probe.tsx","message":"cursor-ref-count-on-mount","data":{"pathname":"/bgp","count":80},"timestamp":1777738439411}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H5","location":"components/debug-runtime-probe.tsx","message":"route-change","data":{"pathname":"/bgp","search":""},"timestamp":1777738439411}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738439416}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738439428}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"live","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738439603}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-start","data":{"url":"http://localhost:8000"},"timestamp":1777738439605}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-result","data":{"url":"http://localhost:8000","ok":true,"status":200},"timestamp":1777738439621}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508126}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508134}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508145}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508145}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"live","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508204}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-start","data":{"url":"http://localhost:8000"},"timestamp":1777738508205}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-result","data":{"url":"http://localhost:8000","ok":true,"status":200},"timestamp":1777738508228}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508553}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508560}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508571}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508572}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"live","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508662}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-start","data":{"url":"http://localhost:8000"},"timestamp":1777738508663}
|
||||||
|
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-result","data":{"url":"http://localhost:8000","ok":true,"status":200},"timestamp":1777738508676}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
description: "Generate Commit Message: русский subject, fix vs feat, Conventional Commits"
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Git commit message (Generate Commit Message)
|
||||||
|
|
||||||
|
При **Generate Commit Message**, автогенерации subject и предложении `git commit`:
|
||||||
|
|
||||||
|
## Обязательно
|
||||||
|
|
||||||
|
- Subject и body — **только на русском** (после `:` в conventional commit).
|
||||||
|
- Локальный hook `.githooks/commit-msg` **отклоняет** коммиты без кириллицы в subject.
|
||||||
|
- Префикс и scope — английский: `fix(ui):`, `chore(rules):`.
|
||||||
|
|
||||||
|
## Тип: сначала не `feat`
|
||||||
|
|
||||||
|
- **`fix`** — баг, регрессия, правка вёрстки/модалки, некорректное поведение, CI/CD, мелкий дефект.
|
||||||
|
- **`chore` / `docs` / `ci` / `build`** — правила Cursor, README, workflow, зависимости, рефакторинг без новой UX-фичи.
|
||||||
|
- **`feat`** — только **новая** пользовательская возможность (страница, API, сценарий), которой не было.
|
||||||
|
|
||||||
|
Если сомневаешься между `feat` и `fix` — выбирай **`fix`** или **`chore`**, не `feat`.
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
- `fix(ui): выровнять модалку сводки релиза`
|
||||||
|
- `fix(ci): исправить сборку release manifest`
|
||||||
|
- `chore(rules): усилить правила русских commit message`
|
||||||
|
- `feat(releases): добавить страницу релизов`
|
||||||
|
|
||||||
|
## Запрещено
|
||||||
|
|
||||||
|
- `feat: update layout`, `fix: add modal`, `chore: update rules` — английский subject.
|
||||||
|
- `feat` для правки UI, правил, документации и багфиксов.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
description: "Быстрый запуск локального frontend и backend"
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Локальный запуск проекта
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- **Node.js 22**, npm с workspaces.
|
||||||
|
- Первый раз (или после смены зависимостей): `npm install` из корня репозитория.
|
||||||
|
- Backend: скопировать `backend/.env.example` → `backend/.env` (по умолчанию `PORT=8000`, `CORS_ORIGIN=http://localhost:3000`).
|
||||||
|
|
||||||
|
## Запуск (два процесса)
|
||||||
|
|
||||||
|
Из корня репозитория поднять **два** long-running процесса в **отдельных** терминалах:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev
|
||||||
|
npm --prefix backend run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
| Сервис | URL | Проверка |
|
||||||
|
|--------|-----|----------|
|
||||||
|
| Frontend (Next.js 16, Turbopack) | http://localhost:3000 | открыть в браузере |
|
||||||
|
| Backend (Fastify) | http://localhost:8000 | `GET /health` |
|
||||||
|
|
||||||
|
Проверка backend в PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:8000/health -UseBasicParsing | Select-Object -ExpandProperty Content
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый ответ: `{"status":"ok",...}`.
|
||||||
|
|
||||||
|
## Поведение агента
|
||||||
|
|
||||||
|
Если пользователь просит «запусти проект», «запусти фронт и бэк» или похожее:
|
||||||
|
|
||||||
|
1. Сначала проверить уже запущенные терминалы — не дублировать процессы.
|
||||||
|
2. Запустить обе команды выше как фоновые long-running процессы.
|
||||||
|
3. Дождаться готовности: frontend — `Ready`, backend — `Server listening` / успешный `/health`.
|
||||||
|
|
||||||
|
Подробности архитектуры и env — `README.md`, раздел «Запуск».
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
description: "Fastify 5 + Zod + Drizzle + SQLite — модульная архитектура, слои, валидация, ошибки (ответы на русском)"
|
||||||
|
globs: "**/{features,shared,lib,plugins}/**/*.ts,**/app.ts,**/server.ts"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backend: Fastify 5 + TypeScript (ESM) + Zod + Drizzle + SQLite
|
||||||
|
|
||||||
|
**Язык:** все ответы пользователю — **только на русском**.
|
||||||
|
|
||||||
|
**Стек:** TypeScript (`"type": "module"`), **Fastify 5.x**, **Zod** + `@fastify/type-provider-zod`, **SQLite** (`better-sqlite3`) + **Drizzle ORM**, `@fastify/cors`, `pino-pretty`, `undici`, `dotenv`.
|
||||||
|
|
||||||
|
**Цель:** масштабируемый модульный backend, предсказуемый DX, строгое разделение ответственности.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Архитектура (критично)
|
||||||
|
|
||||||
|
- **Feature-based модули**, не монолитные файлы. Каждая фича — изолированная папка.
|
||||||
|
|
||||||
|
Структура фичи:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/features/<feature-name>/
|
||||||
|
route.ts — регистрация маршрутов Fastify
|
||||||
|
controller.ts — разбор запроса/ответа, вызов сервиса
|
||||||
|
service.ts — бизнес-логика (переиспользуемая)
|
||||||
|
schema.ts — Zod-схемы (params, query, body, response)
|
||||||
|
repository.ts — только Drizzle-запросы к БД
|
||||||
|
types.ts — опционально
|
||||||
|
```
|
||||||
|
|
||||||
|
**Запреты:** не смешивать слои; **нет** бизнес-логики в `route`; **нет** SQL/Drizzle в `controller`; сервисы не должны дублировать обязанности репозитория.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fastify
|
||||||
|
|
||||||
|
- Регистрация фич через **плагины** и инкапсулированные модули.
|
||||||
|
- Везде **async/await**; декорации инстанса Fastify — только при реальной необходимости.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Валидация (Zod обязательна)
|
||||||
|
|
||||||
|
- **Все входы** — через Zod; `@fastify/type-provider-zod` для типобезопасности.
|
||||||
|
- Явно задавать схемы: **params**, **query**, **body**, **response** (где уместно).
|
||||||
|
- Нет «сырого» входа; схемы **переиспользовать** между слоями (импорт из `schema.ts`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. База данных (Drizzle)
|
||||||
|
|
||||||
|
- Любой доступ к БД — **только** из `repository.ts` / слоя репозитория.
|
||||||
|
- Типизированные запросы Drizzle; **без** raw SQL, если нет веской причины.
|
||||||
|
- Запросы — простые, композируемые; логику БД не выносить наружу репозитория.
|
||||||
|
- Учитывать возможную смену БД: избегать sqlite-специфичных костылей вне репозитория/миграций.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ошибки
|
||||||
|
|
||||||
|
- Единый формат ошибок API (код/сообщение для клиента).
|
||||||
|
- Обрабатывать: ошибки валидации, БД, внешних HTTP (`undici`).
|
||||||
|
- **Не** отдавать клиенту внутренние детали (стейки, SQL, секреты).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Логирование
|
||||||
|
|
||||||
|
- **Pino**; в dev — **pino-pretty**.
|
||||||
|
- Логировать ошибки и значимые действия; не засорять лог шумом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Внешние HTTP (undici)
|
||||||
|
|
||||||
|
- Вызовы только из **service** (или выделенного клиента, вызываемого из сервиса).
|
||||||
|
- Таймауты, обработка ошибок сети/статусов, без утечки сырого ответа наружу.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Конфигурация окружения
|
||||||
|
|
||||||
|
- `dotenv` для локальной разработки; **валидация env** (предпочтительно через Zod-схему).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Организация кода
|
||||||
|
|
||||||
|
- Небольшие файлы, одна ответственность на файл.
|
||||||
|
- Композиция вместо копипаста; общее — в `/shared/` и `/lib/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Масштабируемость
|
||||||
|
|
||||||
|
- Расширяемые границы модулей, слабая связанность, явные контракты между слоями.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Процесс изменений (обязательно)
|
||||||
|
|
||||||
|
Для каждого изменения в ответе указать: что сделано; зачем (архитектура + практики); затронутые файлы; минимальный полный дифф без лишнего рефакторинга.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Формат ответа агента
|
||||||
|
|
||||||
|
1. Что изменено
|
||||||
|
2. Почему (архитектура + практики)
|
||||||
|
3. Структура файлов (если новая фича)
|
||||||
|
4. Код (несколько файлов при необходимости)
|
||||||
|
5. Замечания / улучшения
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Антипаттерны (строго запрещено)
|
||||||
|
|
||||||
|
- Толстые route handlers
|
||||||
|
- Смешение БД и бизнес-логики в одном месте
|
||||||
|
- Пропуск валидации входа
|
||||||
|
- Глобальное мутабельное состояние
|
||||||
|
- Дублирование запросов к БД
|
||||||
|
- Хардкод секретов и конфигурации (всё через env/конфиг)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Роль агента
|
||||||
|
|
||||||
|
Вести себя как **senior backend engineer**: чистый модульный код, строгое разделение слоёв, долгосрочная поддерживаемость и предсказуемый процесс разработки.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
description: "[Multi-agent] Роль ANALYZER — анализ кодовой базы перед реализацией, без кода"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Агент: ANALYZER (анализатор)
|
||||||
|
|
||||||
|
Ты выполняешь **только фазу анализа** перед любыми изменениями кода.
|
||||||
|
|
||||||
|
## Язык
|
||||||
|
|
||||||
|
- Все ответы пользователю — **на русском**.
|
||||||
|
|
||||||
|
## Обязанности
|
||||||
|
|
||||||
|
- Искать по **всему** репозиторию релевантные места.
|
||||||
|
- Зафиксировать:
|
||||||
|
- похожие компоненты и дубликаты по смыслу;
|
||||||
|
- существующие UI-паттерны, **особенно в зоне `/servers`** (`app/(main)/servers` и связанные компоненты);
|
||||||
|
- общие хуки, утилиты, UI-обёртки;
|
||||||
|
- стратегии загрузки данных (RSC, route handlers, server actions, клиентский fetch — что уже принято в проекте).
|
||||||
|
- Явно отметить **риски дублирования** и конфликты с текущими абстракциями.
|
||||||
|
|
||||||
|
## Запреты
|
||||||
|
|
||||||
|
- **Нельзя** писать реализацию (патчи, готовый production-код, большие вставки «как сделать»). Допустимы короткие псевдокод-намёки уровня «расширить X», без полной реализации.
|
||||||
|
- **Нельзя** предлагать «с нуля», если в кодовой базе уже есть переиспользуемое решение — приоритет **reuse**.
|
||||||
|
|
||||||
|
## Формат вывода (строго)
|
||||||
|
|
||||||
|
1. **Релевантные файлы** (пути).
|
||||||
|
2. **Существующие паттерны** (кратко, с привязкой к файлам).
|
||||||
|
3. **Что переиспользовать** (компоненты, хуки, утилиты).
|
||||||
|
4. **Риски и конфликты** (дубли, расхождение с `/servers`, ломкие зависимости).
|
||||||
|
|
||||||
|
## Цель фазы
|
||||||
|
|
||||||
|
Дать Implementer’у однозначный вход: **что трогать, что не плодить, откуда копировать поведение**.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
description: "[Multi-agent] Глобальные правила пайплайна (Next 16.2.4, Turbopack, shadcn) — подключать с ролями"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Глобальные правила (все агенты пайплайна)
|
||||||
|
|
||||||
|
**Язык:** отвечать пользователю **только на русском**.
|
||||||
|
|
||||||
|
## Стек и документация
|
||||||
|
|
||||||
|
- Next.js **16.2.4** (App Router, **Turbopack**), React, **shadcn/ui**.
|
||||||
|
- Сверять решения с **официальной** документацией Next.js и shadcn для версий проекта. Не придумывать API и недокументированные паттерны.
|
||||||
|
|
||||||
|
## Встроенные возможности Next.js (предпочтительно)
|
||||||
|
|
||||||
|
- App Router, Server Components, Server Actions, Route Handlers — вместо обходных кастомных путей, если задача покрывается фреймворком.
|
||||||
|
|
||||||
|
## Эталон UI
|
||||||
|
|
||||||
|
- **`app/(main)/servers`** — эталон **поведения** CRUD. Оболочка ops: **Frame + DataGrid**, не Card-shell. Не копировать `DataPageCard` на новые экраны.
|
||||||
|
|
||||||
|
## Качество и совместимость
|
||||||
|
|
||||||
|
- Устранять: ошибки сборки, TypeScript, гидратации, предупреждения о плохих практиках; учитывать **Turbopack** в dev.
|
||||||
|
|
||||||
|
## Связь с проектом
|
||||||
|
|
||||||
|
- Полные стандарты проекта (формат ответа, чеклисты) — в правиле **`next-shadcn-production.mdc`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют.
|
||||||
|
- Сообщения коммитов: **`commit-messages-ru.mdc`** (язык и `fix` vs `feat`), semver bump — **`release-versioning.mdc`**.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
description: "[Multi-agent] Роль IMPLEMENTER — реализация по результатам анализа, /servers, shadcn"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Агент: IMPLEMENTER (реализатор)
|
||||||
|
|
||||||
|
Ты пишешь **production-ready** изменения **на основе** отчёта Analyzer (и уточнённой задачи пользователя).
|
||||||
|
|
||||||
|
## Язык
|
||||||
|
|
||||||
|
- Все ответы пользователю — **на русском**.
|
||||||
|
|
||||||
|
## Обязанности
|
||||||
|
|
||||||
|
- Внедрять фичу **минимально**, но **полностью**: все затронутые файлы (типы, UI, API, actions/handlers) согласованы.
|
||||||
|
- **Переиспользовать** существующие абстракции из отчёта анализа и кодовой базы.
|
||||||
|
- Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция).
|
||||||
|
- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики).
|
||||||
|
- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент.
|
||||||
|
- При завершении задачи предлагать subject по **`commit-messages-ru.mdc`**: правки UI/багов → `fix`, инфраструктура/правила → `chore`, новая фича → `feat`; описание **на русском**.
|
||||||
|
|
||||||
|
## Формат вывода (строго)
|
||||||
|
|
||||||
|
1. **Что изменено** (файлы и суть).
|
||||||
|
2. **Почему так** — связка Next.js + shadcn/ui + эталон **`/servers`** + принятые в репозитории паттерны.
|
||||||
|
3. **Код** — диффы или полные фрагменты по всем затронутым файлам.
|
||||||
|
4. По возможности: **Server vs Client** для новых/изменённых границ компонентов.
|
||||||
|
|
||||||
|
## Запреты
|
||||||
|
|
||||||
|
- Не оставлять «полфичи» (забытые типы, сломанные импорты, несогласованный API).
|
||||||
|
- Не копипастить большие куски без привязки к существующим компонентам, если анализ уже указал переиспользование.
|
||||||
|
|
||||||
|
## Согласование с проектом
|
||||||
|
|
||||||
|
- Если активно правило **`next-shadcn-production.mdc`**, выдерживай его структуру ответа (шаги 0–6) и требования к проверкам.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
description: "[Multi-agent] Оркестратор — последовательность Analyzer→Implementer→Reviewer→UI Guardian"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Оркестратор пайплайна (multi-agent)
|
||||||
|
|
||||||
|
Используй это правило, когда нужно прогнать задачу через **все роли по очереди**. Подключай вместе с **`ma-global.mdc`** и при необходимости **`next-shadcn-production.mdc`**.
|
||||||
|
|
||||||
|
## Язык
|
||||||
|
|
||||||
|
- На каждой фазе ответы пользователю — **на русском**.
|
||||||
|
|
||||||
|
## Порядок фаз (не нарушать)
|
||||||
|
|
||||||
|
1. **Analyzer** — правило **`ma-analyzer.mdc`**: только анализ, без реализации.
|
||||||
|
2. **Implementer** — правило **`ma-implementer.mdc`**: код по результатам фазы 1.
|
||||||
|
3. **Reviewer** — правило **`ma-reviewer.mdc`**: строгое ревью результата фазы 2.
|
||||||
|
4. **UI Guardian** — правило **`ma-ui-guardian.mdc`**: только сверка с **`/servers`**.
|
||||||
|
|
||||||
|
## Как исполнять в одном чате
|
||||||
|
|
||||||
|
- Выполни **фазу 1** целиком и остановись на выходном формате Analyzer.
|
||||||
|
- Затем **фаза 2** с явной ссылкой: «реализуй согласно отчёту Analyzer выше».
|
||||||
|
- Затем **фаза 3** по артефакту фазы 2.
|
||||||
|
- Затем **фаза 4** по UI-части изменений.
|
||||||
|
|
||||||
|
Если пользователь просит «только одну фазу» — выполни только её и напомни номер следующей.
|
||||||
|
|
||||||
|
## Промпт-шаблон для пользователя (скопировать в чат)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Запусти multi-agent пайплайн для задачи ниже.
|
||||||
|
Фазы строго по порядку: Analyzer → Implementer → Reviewer → UI Guardian.
|
||||||
|
Язык ответов — русский. Стек: Next.js 16.2.4 App Router + Turbopack, shadcn/ui.
|
||||||
|
Эталон UI: app/(main)/servers.
|
||||||
|
Следуй правилам ma-global и соответствующим ma-* ролям.
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
<опишите фичу или баг>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Файлы ролей в `.cursor/rules/`
|
||||||
|
|
||||||
|
| Роль | Файл |
|
||||||
|
|------------|-------------------------|
|
||||||
|
| Глобальные | `ma-global.mdc` |
|
||||||
|
| Analyzer | `ma-analyzer.mdc` |
|
||||||
|
| Implementer| `ma-implementer.mdc` |
|
||||||
|
| Reviewer | `ma-reviewer.mdc` |
|
||||||
|
| UI Guardian| `ma-ui-guardian.mdc` |
|
||||||
|
| Оркестратор| `ma-orchestrator.mdc` |
|
||||||
|
|
||||||
|
## Ожидаемый результат пайплайна
|
||||||
|
|
||||||
|
- Согласованность с кодовой базой и **максимальный reuse**.
|
||||||
|
- UI, выровненный под **`/servers`**.
|
||||||
|
- Отсутствие известных блокеров по Next.js, гидратации, TS и заметных anti-patterns после фазы Reviewer.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
description: "[Multi-agent] Роль REVIEWER — строгая проверка Next/shadcn/a11y/гидратации/Turbopack"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Агент: REVIEWER (ревьюер)
|
||||||
|
|
||||||
|
Ты **валидируешь** предложенные или уже внесённые изменения. Режим: **строгий**.
|
||||||
|
|
||||||
|
## Язык
|
||||||
|
|
||||||
|
- Все ответы пользователю — **на русском**.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
|
||||||
|
- **Next.js:** App Router, границы RSC/Client, данные ближе к серверу, кэширование/revalidate где уместно, корректные route handlers и server actions.
|
||||||
|
- **shadcn/ui:** композиция, токены/классы проекта, отсутствие «левого» самописного UI там, где хватает shadcn.
|
||||||
|
- **Производительность:** лишние клиентские деревья, waterfall fetch, чрезмерные `'use client'` границы.
|
||||||
|
- **a11y:** семантика, фокус, aria, клавиатура (в разумных пределах контекста правки).
|
||||||
|
- **Гидратация:** несовпадение разметки/случайные значения на сервере и клиенте, неправильное использование браузерных API в RSC.
|
||||||
|
- **Turbopack:** не предлагать паттерны, ломающие dev-сборку Next 16 + Turbopack; учитывать типичные предупреждения toolchain.
|
||||||
|
|
||||||
|
## Обязательные находки
|
||||||
|
|
||||||
|
- Ошибочное **смешение Server/Client** (хуки в RSC, серверные модули в клиенте без обёртки и т.д.).
|
||||||
|
- **Over-fetching** и дублирующая логика запросов.
|
||||||
|
- **Дублирование** логики/UI там, где уже есть общий модуль или паттерн `/servers`.
|
||||||
|
|
||||||
|
## Формат вывода (строго)
|
||||||
|
|
||||||
|
Таблица или списки:
|
||||||
|
|
||||||
|
1. **Проблема** (кратко).
|
||||||
|
2. **Серьёзность:** блокер / высокая / средняя / низкая.
|
||||||
|
3. **Рекомендация по исправлению** (конкретные шаги или патч-уровень).
|
||||||
|
|
||||||
|
Если дефектов нет — явно напиши **«Блокирующих замечаний нет»** и перечисли **что именно** было проверено.
|
||||||
|
|
||||||
|
## Запреты
|
||||||
|
|
||||||
|
- Нельзя «мягко пропускать» очевидные проблемы ради скорости.
|
||||||
|
- Нельзя ограничиваться общими фразами — каждая **рекомендация должна быть actionable**.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
description: "[Multi-agent] Роль UI GUARDIAN — сверка UI с CRUD /servers и оболочкой Frame+DataGrid"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Агент: UI GUARDIAN (хранитель UI)
|
||||||
|
|
||||||
|
Ты отвечаешь **только** за **визуальную и UX-согласованность**.
|
||||||
|
|
||||||
|
## Язык
|
||||||
|
|
||||||
|
- Все ответы пользователю — **на русском**.
|
||||||
|
|
||||||
|
## Эталон
|
||||||
|
|
||||||
|
Два слоя (не смешивать):
|
||||||
|
|
||||||
|
1. **Поведение CRUD** — каталог **`app/(main)/servers`**: фильтры, таблица, диалоги, формы, плотность, паттерны кнопок.
|
||||||
|
2. **Оболочка ops** — **ReUI Frame + DataGrid**, не shadcn `Card` как page shell. KPI — `IconTile` `variant="elevated"` `className="size-10.5"`. Preview: [frame](https://reui.io/docs/components/base/frame) · [data-grid](https://reui.io/docs/components/base/data-grid) · [stats-12](https://reui.io/preview/base/stats-12) · [icon-tile](https://reui.io/docs/components/base/icon-tile).
|
||||||
|
|
||||||
|
`DataPageCard` — ops list shell на ReUI Frame; новые экраны не копируют Card-shell.
|
||||||
|
|
||||||
|
## Обязанности
|
||||||
|
|
||||||
|
- Сравнить **каждое** спорное UI-решение: CRUD — с `/servers`; оболочка — с Frame+DataGrid.
|
||||||
|
- Требовать **переиспользование** существующих компонентов/обёрток, если они покрывают задачу.
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
- **Нельзя** принимать новый Card page-shell без явной технической необходимости (назвать её).
|
||||||
|
- **Нельзя** предлагать «новый стиль» ради вариации — только выравнивание с эталоном или расширение существующих примитивов.
|
||||||
|
|
||||||
|
## Формат вывода (строго)
|
||||||
|
|
||||||
|
1. **Несоответствия** эталону (конкретно: CRUD vs Frame-shell).
|
||||||
|
2. **Обязательные правки** (что поменять).
|
||||||
|
3. **Что переиспользовать**: файлы/компоненты и паттерн.
|
||||||
|
|
||||||
|
Если расхождений нет — явно: **«Расхождений с эталоном CRUD /servers и Frame+DataGrid не выявлено»** и кратко перечисли проверенные аспекты.
|
||||||
|
|
||||||
|
## Граница ответственности
|
||||||
|
|
||||||
|
- Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности** и **reuse** UI.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
description: Next.js App Router + shadcn/ui — эталон /servers, поиск по репозиторию, формат ответа и проверки
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Next.js 16 (App Router, Turbopack) + shadcn/ui — правила проекта
|
||||||
|
|
||||||
|
Стек ориентира: **Next.js 16.2.4** (App Router, Turbopack), **React**, **shadcn/ui**. При расхождении с документацией — верифицировать по официальным источникам для вашей версии.
|
||||||
|
|
||||||
|
**Язык:** все ответы пользователю — **только на русском**.
|
||||||
|
|
||||||
|
**Git / Generate Commit Message:** subject и body — **на русском**; `feat` только для новой UX-фичи, иначе `fix`/`chore`. См. **`commit-messages-ru.mdc`** и **`release-versioning.mdc`**.
|
||||||
|
|
||||||
|
## 1. Документация в первую очередь
|
||||||
|
|
||||||
|
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
|
||||||
|
- Не выдумывать API и «недокументированные» паттерны; предпочитать стабильные, описанные в доках решения.
|
||||||
|
|
||||||
|
## 2. Эталон UI/UX: `/servers` (CRUD) + Frame+DataGrid (оболочка)
|
||||||
|
|
||||||
|
- **`app/(main)/servers`** — эталон **поведения** CRUD: фильтры, сортировка, пагинация, диалоги, формы, плотность.
|
||||||
|
- **Оболочка ops-списков / dashboard / KPI:** **ReUI Frame + DataGrid**, не shadcn `Card` как page shell. Preview: [frame](https://reui.io/docs/components/base/frame) · [data-grid](https://reui.io/docs/components/base/data-grid) · [stats-12](https://reui.io/preview/base/stats-12) · [icon-tile](https://reui.io/docs/components/base/icon-tile).
|
||||||
|
- `DataPageCard` — ops list shell на ReUI Frame (`components/data-page-card.tsx`). Внутренние панели — `OpsPanel` / Frame, не shadcn Card как page shell.
|
||||||
|
- Переиспользовать паттерны `/servers` для CRUD; **не** вводить Card-grid как новый SoT.
|
||||||
|
|
||||||
|
## 3. Большой репозиторий (критично)
|
||||||
|
|
||||||
|
- **Перед изменениями** искать по проекту: похожие компоненты, существующие паттерны, общие утилиты, хуки, UI-абстракции.
|
||||||
|
- **Не дублировать:** если похожее решение уже есть — расширять или переиспользовать, а не писать с нуля.
|
||||||
|
|
||||||
|
## 4. Согласованность между файлами
|
||||||
|
|
||||||
|
При правке фичи проверять и при необходимости обновлять **все** связанное: компоненты, хуки, route handlers / server actions, типы, стили/обёртки UI — не только открытый файл.
|
||||||
|
|
||||||
|
## 5. Зависимости и влияние
|
||||||
|
|
||||||
|
Перед реализацией: от чего зависит целевой файл, где используется, какие побочные эффекты; избегать ломающих изменений без явной необходимости.
|
||||||
|
|
||||||
|
## 6. Нативные возможности фреймворка
|
||||||
|
|
||||||
|
- Next.js: App Router, Server Components, Server Actions, Route Handlers — по умолчанию предпочтительнее кастомных обходных путей.
|
||||||
|
- UI: компоненты **shadcn/ui**; не плодить самописное, если стандартное решение покрывает задачу.
|
||||||
|
|
||||||
|
## 7. Практики качества
|
||||||
|
|
||||||
|
- Рекомендации Next.js: загрузка данных, рендеринг, производительность.
|
||||||
|
- Минимизировать `'use client'` там, где достаточно серверных компонентов.
|
||||||
|
- Композиция shadcn/ui и доступность (a11y).
|
||||||
|
|
||||||
|
## 8. Валидация каждого изменения (обязательно)
|
||||||
|
|
||||||
|
В ответе явно указать:
|
||||||
|
|
||||||
|
- **Почему** это согласуется с Next.js, shadcn/ui, CRUD `/servers` и оболочкой Frame+DataGrid.
|
||||||
|
- **Server vs Client Component** и обоснование.
|
||||||
|
- Что **переиспользовано** из проекта (паттерны/компоненты).
|
||||||
|
- Какие **файлы проанализированы**.
|
||||||
|
|
||||||
|
## 9. Turbopack и консоль разработки
|
||||||
|
|
||||||
|
После правок: проверить сборку/типы (по возможности запустить проверки проекта), ошибки гидратации, предупреждения; критичное исправить; не игнорировать предупреждения о плохих практиках.
|
||||||
|
|
||||||
|
## 10. Правки кода
|
||||||
|
|
||||||
|
- Минимальные, но **полные** (все зависимые места обновлены).
|
||||||
|
- Не рефакторить несвязанную логику.
|
||||||
|
|
||||||
|
## 11. Формат ответа (обязательная структура)
|
||||||
|
|
||||||
|
1. **Шаг 0:** список проанализированных файлов.
|
||||||
|
2. **Шаг 1:** что изменено.
|
||||||
|
3. **Шаг 2:** почему (Next.js + shadcn/ui + CRUD `/servers` + Frame+DataGrid + паттерны кодовой базы).
|
||||||
|
4. **Шаг 3:** анализ влияния (что ещё затронуто).
|
||||||
|
5. **Шаг 4:** код (диффы или несколько файлов).
|
||||||
|
6. **Шаг 5:** проверка dev/консоли (ошибки, предупреждения, исправления).
|
||||||
|
7. **Шаг 6:** опциональные улучшения.
|
||||||
|
|
||||||
|
## 12. Дублирование и антипаттерны
|
||||||
|
|
||||||
|
Явно указывать, если найдено дублирование или антипаттерн; ссылаться на существующую реализацию в проекте; предлагать переиспользование или рефакторинг вместо копипаста.
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, CRUD как на `/servers`, оболочка Frame+DataGrid (не Card-shell), безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
description: "Conventional Commits, semver релизов MikrotikManager и русские git commit message (Generate Commit Message)"
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Версионирование релизов
|
||||||
|
|
||||||
|
## Generate Commit Message (критично)
|
||||||
|
|
||||||
|
Для **Generate Commit Message** в Cursor, предложения subject в чате, `git commit` и любой автогенерации сообщения коммита:
|
||||||
|
|
||||||
|
- Формат: `тип(scope): описание на русском` — префикс Conventional Commits и scope **на английском**, текст после `:` **только на русском**.
|
||||||
|
- Body и footer (если нужны) — **только на русском**.
|
||||||
|
- В описании после `:` не использовать английские слова: `add`, `update`, `implement`, `rename` и т.п.
|
||||||
|
- **`feat` не по умолчанию.** Сначала проверь `fix` / `chore` / `ci` / `docs`; `feat` — только новая пользовательская возможность.
|
||||||
|
- Примеры: `fix(ui): выровнять модалку релиза`; `chore(rules): усилить правила commit message`; `feat(releases): добавить страницу релизов`.
|
||||||
|
- Детальный чеклист типов — **`commit-messages-ru.mdc`**.
|
||||||
|
|
||||||
|
- Версию **не** править вручную в UI, `package.json` и git-тегах; bump делает CI через [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs).
|
||||||
|
- Базовая линия: `v1.0.0`. Формат отображения: `v1.2.3`.
|
||||||
|
- **Язык коммитов:** subject и body — **только на русском**; префикс Conventional Commits (`feat`, `fix`, `chore` и т.д.) и scope — на английском.
|
||||||
|
|
||||||
|
## Patch (`1.0.x`)
|
||||||
|
|
||||||
|
`fix:`, `chore:`, `docs:`, `refactor:`, `style:`, `test:`, `build:`, `ci:`
|
||||||
|
|
||||||
|
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов, **на русском**.
|
||||||
|
- Scope по желанию: `fix(servers):`, `chore(ci):`.
|
||||||
|
|
||||||
|
## Minor (`1.x.0`)
|
||||||
|
|
||||||
|
`feat:`, `feat!`, footer `BREAKING CHANGE` — только для заметного core-функционала.
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
- `fix(servers): исправить опрос API после таймаута` — patch
|
||||||
|
- `feat(filters): добавить синхронизацию BGP in` — minor
|
||||||
|
- `chore(ci): добавить job prepare-release` — хорошо
|
||||||
|
- `feat: add release modal` — плохо (subject не на русском)
|
||||||
|
|
||||||
|
## Антипаттерны
|
||||||
|
|
||||||
|
WIP, «update», subject на английском, смешение несвязанных изменений, маскировка фич под `fix`/`chore`, ручной bump в sidebar.
|
||||||
|
|
||||||
|
Подробности — раздел CI/CD в `README.md`.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Adapting installed ReUI code (reuse-first, no AI slop)
|
||||||
|
|
||||||
|
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
|
||||||
|
|
||||||
|
## Preserve the design - don't over-customize
|
||||||
|
|
||||||
|
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
|
||||||
|
|
||||||
|
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
|
||||||
|
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
|
||||||
|
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
|
||||||
|
|
||||||
|
## Reuse the parts: examples and the block's own elements
|
||||||
|
|
||||||
|
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
|
||||||
|
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
|
||||||
|
|
||||||
|
## Don't invent (read, don't guess)
|
||||||
|
|
||||||
|
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
|
||||||
|
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
|
||||||
|
|
||||||
|
## What to change vs leave alone
|
||||||
|
|
||||||
|
- **Change:** the item's own data, copy, props, and layout to fit the app.
|
||||||
|
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
|
||||||
|
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
|
||||||
|
|
||||||
|
## Demo data -> real data
|
||||||
|
|
||||||
|
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
|
||||||
|
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
|
||||||
|
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
|
||||||
|
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
|
||||||
|
|
||||||
|
## Believable content (no AI tells)
|
||||||
|
|
||||||
|
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
|
||||||
|
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
|
||||||
|
|
||||||
|
## Operational surfaces (settings / profile / admin)
|
||||||
|
|
||||||
|
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CLI: registry setup, license, non-interactive install
|
||||||
|
|
||||||
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
|
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
|
||||||
|
|
||||||
|
1. Add the key to `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REUI_LICENSE_KEY=your-license-key
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Switch `components.json` to the authenticated object form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registries": {
|
||||||
|
"@reui": {
|
||||||
|
"url": "https://reui.io/r/{style}/{name}.json",
|
||||||
|
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
Use the project's package runner (check `packageManager`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes # npm
|
||||||
|
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
|
||||||
|
bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||||
|
```
|
||||||
|
|
||||||
|
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
|
||||||
|
|
||||||
|
## Handling prompts and conflicts
|
||||||
|
|
||||||
|
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
|
||||||
|
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
|
||||||
|
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
|
||||||
|
- Run from the **project root** so `components.json` and `.env.local` are found.
|
||||||
|
|
||||||
|
## Free vs premium boundary
|
||||||
|
|
||||||
|
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# ReUI components
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
})
|
||||||
|
|
||||||
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes:
|
||||||
|
|
||||||
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
|
## kanban
|
||||||
|
|
||||||
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
|
||||||
|
<KanbanBoard>
|
||||||
|
{Object.entries(cols).map(([id, items]) => (
|
||||||
|
<KanbanColumn key={id} value={id}>
|
||||||
|
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||||
|
<KanbanColumnContent value={id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<KanbanItem key={i.id} value={i.id}>
|
||||||
|
<KanbanItemHandle>{i.title}</KanbanItemHandle>
|
||||||
|
</KanbanItem>
|
||||||
|
))}
|
||||||
|
</KanbanColumnContent>
|
||||||
|
</KanbanColumn>
|
||||||
|
))}
|
||||||
|
</KanbanBoard>
|
||||||
|
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
|
||||||
|
</Kanban>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
|
||||||
|
|
||||||
|
## sortable
|
||||||
|
|
||||||
|
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<SortableItem key={i.id} value={i.id}>
|
||||||
|
<SortableItemHandle><GripVertical /></SortableItemHandle>
|
||||||
|
{i.label}
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</Sortable>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
|
||||||
|
|
||||||
|
## filters
|
||||||
|
|
||||||
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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 fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange` to capture the value.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||||
|
|
||||||
|
<DateSelector value={value} onChange={setValue} label="Due date" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
|
||||||
|
|
||||||
|
## tree
|
||||||
|
|
||||||
|
**Required:** `tree` (a `@headless-tree/core` instance you construct)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tree tree={tree}>
|
||||||
|
{tree.getItems().map((item) => (
|
||||||
|
<TreeItem key={item.getId()} item={item}>
|
||||||
|
<TreeItemLabel />
|
||||||
|
</TreeItem>
|
||||||
|
))}
|
||||||
|
</Tree>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
|
||||||
|
|
||||||
|
## stepper
|
||||||
|
|
||||||
|
**Required:** `StepperItem step` (number), `StepperContent value` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stepper defaultValue={1}>
|
||||||
|
<StepperNav>
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel>
|
||||||
|
<StepperContent value={1}>Step 1 content</StepperContent>
|
||||||
|
<StepperContent value={2}>Step 2 content</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
|
||||||
|
|
||||||
|
## timeline
|
||||||
|
|
||||||
|
**Required:** `TimelineItem step` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Timeline>
|
||||||
|
<TimelineItem step={1}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>March 2024</TimelineDate>
|
||||||
|
<TimelineTitle>Project initialized</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
<TimelineContent>Repo and architecture set up.</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
</Timeline>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
|
||||||
|
|
||||||
|
## autocomplete
|
||||||
|
|
||||||
|
**Required:** `items` (array; each item has at least `value`)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Autocomplete items={items}>
|
||||||
|
<AutocompleteInput placeholder="Search..." />
|
||||||
|
<AutocompleteContent>
|
||||||
|
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
|
||||||
|
<AutocompleteList>
|
||||||
|
{(item) => (
|
||||||
|
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
|
||||||
|
)}
|
||||||
|
</AutocompleteList>
|
||||||
|
</AutocompleteContent>
|
||||||
|
</Autocomplete>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
|
||||||
|
|
||||||
|
## phone-input
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
|
||||||
|
|
||||||
|
## number-field
|
||||||
|
|
||||||
|
**Required:** wrap the controls in `NumberFieldGroup`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NumberField defaultValue={0}>
|
||||||
|
<NumberFieldScrubArea label="Quantity" />
|
||||||
|
<NumberFieldGroup>
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
|
||||||
|
|
||||||
|
## rating
|
||||||
|
|
||||||
|
**Required:** `rating` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
|
||||||
|
|
||||||
|
## scrollspy
|
||||||
|
|
||||||
|
**Required:** `targetRef` (the scroll container ref)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Scrollspy targetRef={containerRef}>
|
||||||
|
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
|
||||||
|
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
|
||||||
|
</Scrollspy>
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<div id="s1">...</div>
|
||||||
|
<div id="s2">...</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
|
||||||
|
|
||||||
|
## frame
|
||||||
|
|
||||||
|
**Required:** `Frame` > `FramePanel`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Frame>
|
||||||
|
<FramePanel>
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Title</FrameTitle>
|
||||||
|
<FrameDescription>Description</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<div className="p-5">Content</div>
|
||||||
|
<FrameFooter>Footer</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||||
|
|
||||||
|
## icon-stack
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconStack aria-hidden="true">
|
||||||
|
<InboxIcon className="size-4" />
|
||||||
|
</IconStack>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
|
## alert
|
||||||
|
|
||||||
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Alert variant="success">
|
||||||
|
<ShieldCheckIcon />
|
||||||
|
<AlertTitle>Security update</AlertTitle>
|
||||||
|
<AlertDescription>Enable two-factor authentication.</AlertDescription>
|
||||||
|
<AlertAction><Button size="xs">Update</Button></AlertAction>
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
|
||||||
|
|
||||||
|
## badge
|
||||||
|
|
||||||
|
**Required:** none (text child).
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="success-light" size="sm">Success</Badge>
|
||||||
|
<Badge variant="outline" radius="full">Pill</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||||
|
|
||||||
|
## base vs radix - write for the project's base
|
||||||
|
|
||||||
|
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||||
|
|
||||||
|
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||||
|
|
||||||
|
- `"style": "base-nova"` -> **Base UI**
|
||||||
|
- `"style": "radix-nova"` -> **Radix UI**
|
||||||
|
|
||||||
|
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||||
|
|
||||||
|
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
|
||||||
|
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
|
||||||
|
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
|
||||||
|
|
||||||
|
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Craft: make ReUI UI exceptional, not generic
|
||||||
|
|
||||||
|
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||||
|
|
||||||
|
## Have a point of view
|
||||||
|
|
||||||
|
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
|
||||||
|
|
||||||
|
## Brutally clear hierarchy
|
||||||
|
|
||||||
|
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||||
|
|
||||||
|
## Spacing rhythm and deliberate density
|
||||||
|
|
||||||
|
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||||
|
|
||||||
|
## Cover the real states (the usual miss)
|
||||||
|
|
||||||
|
A surface isn't done at the happy path. Compose, and wire:
|
||||||
|
|
||||||
|
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||||
|
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||||
|
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||||
|
|
||||||
|
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||||
|
|
||||||
|
## Responsive by default
|
||||||
|
|
||||||
|
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||||
|
|
||||||
|
## Motion, subtly
|
||||||
|
|
||||||
|
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||||
|
|
||||||
|
## Real, activated content
|
||||||
|
|
||||||
|
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||||
|
|
||||||
|
## Avoid the AI tells
|
||||||
|
|
||||||
|
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||||
|
|
||||||
|
## The bar
|
||||||
|
|
||||||
|
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Icons (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||||
|
|
||||||
|
## Portable icons (library-agnostic)
|
||||||
|
|
||||||
|
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||||
|
|
||||||
|
- `lucide` -> `lucide-react`
|
||||||
|
- `tabler` -> `@tabler/icons-react`
|
||||||
|
- `phosphor` -> `@phosphor-icons/react`
|
||||||
|
- `remix` -> `@remixicon/react`
|
||||||
|
- `hugeicons` -> `@hugeicons/react`
|
||||||
|
|
||||||
|
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||||
|
|
||||||
|
## Keep icons purposeful
|
||||||
|
|
||||||
|
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
|
||||||
|
|
||||||
|
## Motion Icons (the `@reui/icons/...` set)
|
||||||
|
|
||||||
|
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||||
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
|
```
|
||||||
|
|
||||||
|
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
|
Finding icons:
|
||||||
|
|
||||||
|
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
|
||||||
|
- One icon: `search` with `type: "icon"`.
|
||||||
|
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||||
|
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||||
|
|
||||||
|
The `icon-stack` component composes multiple icons into a stacked display.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Quality gates (security, accessibility, scroll)
|
||||||
|
|
||||||
|
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Never `dangerouslySetInnerHTML`. Render data as text/components.
|
||||||
|
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
|
||||||
|
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
|
||||||
|
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
|
||||||
|
- Every non-submit button is `type="button"`.
|
||||||
|
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||||
|
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||||
|
|
||||||
|
## Scroll mechanics
|
||||||
|
|
||||||
|
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
|
||||||
|
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# ReUI registry structure
|
||||||
|
|
||||||
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
|
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
|
|
||||||
|
## The @reui registry
|
||||||
|
|
||||||
|
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
|
||||||
|
|
||||||
|
## Know your base: base or radix
|
||||||
|
|
||||||
|
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
|
||||||
|
|
||||||
|
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
|
||||||
|
|
||||||
|
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||||
|
|
||||||
|
## Free vs premium
|
||||||
|
|
||||||
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
|
## Component API index
|
||||||
|
|
||||||
|
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Styling (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||||
|
|
||||||
|
## ReUI extended semantic tokens
|
||||||
|
|
||||||
|
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||||
|
|
||||||
|
- `--success` / `--success-foreground`
|
||||||
|
- `--info` / `--info-foreground`
|
||||||
|
- `--warning` / `--warning-foreground`
|
||||||
|
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||||
|
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||||
|
|
||||||
|
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||||
|
|
||||||
|
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||||
|
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||||
|
|
||||||
|
## Blocks follow your theme
|
||||||
|
|
||||||
|
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||||
|
|
||||||
|
## Density and typography rhythm
|
||||||
|
|
||||||
|
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Workflow: find -> install -> read API -> adapt
|
||||||
|
|
||||||
|
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
|
||||||
|
|
||||||
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||||
|
|
||||||
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
|
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
|
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||||
|
|
||||||
|
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||||
|
|
||||||
|
## 2. Install (shadcn CLI)
|
||||||
|
|
||||||
|
Run the result's `install` command from the project root, non-interactively:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
|
||||||
|
|
||||||
|
## 3. Read the API (do not guess props)
|
||||||
|
|
||||||
|
Before writing code against any component an item uses:
|
||||||
|
|
||||||
|
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its 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.
|
||||||
|
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||||
|
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||||
|
|
||||||
|
## 4. Adapt (reuse-first) - do not skip
|
||||||
|
|
||||||
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
|
||||||
|
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
|
||||||
|
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
|
||||||
|
8. **Typecheck / lint**.
|
||||||
|
|
||||||
|
## If no single block fits
|
||||||
|
|
||||||
|
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# ReUI MCP: full reference
|
||||||
|
|
||||||
|
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
|
||||||
|
|
||||||
|
## Golden path (token-optimal - follow this order)
|
||||||
|
|
||||||
|
Most tasks need 2-4 MCP calls and ZERO web fetches:
|
||||||
|
|
||||||
|
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
|
||||||
|
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
|
||||||
|
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
|
||||||
|
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
|
||||||
|
5. **`get_audit_checklist()`** before declaring done.
|
||||||
|
|
||||||
|
If you already know the exact item name, skip `search`. Everything else is situational.
|
||||||
|
|
||||||
|
## The 5 task-specific tools (when to reach for each)
|
||||||
|
|
||||||
|
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||||
|
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||||
|
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||||
|
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||||
|
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||||
|
|
||||||
|
## All 19 tools
|
||||||
|
|
||||||
|
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
|
## Token + speed rules
|
||||||
|
|
||||||
|
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
|
||||||
|
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
|
||||||
|
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
|
||||||
|
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
|
||||||
|
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
|
||||||
|
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
|
||||||
|
|
||||||
|
## Result shapes (so you don't re-fetch)
|
||||||
|
|
||||||
|
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
|
||||||
|
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||||
|
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||||
|
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||||
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
|
||||||
|
## Error playbook
|
||||||
|
|
||||||
|
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||||
|
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||||
|
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||||
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||||
|
|
||||||
|
## Fallbacks
|
||||||
|
|
||||||
|
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||||
|
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||||
|
|
||||||
|
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
deploy/netflow-agent/*.sh text eol=lf
|
|
||||||
deploy/netflow-agent/*.py text eol=lf
|
|
||||||
# Migration hashes authenticate exact reviewed bytes, including existing CRLF files.
|
|
||||||
# Preserve those bytes on every checkout instead of applying core.autocrlf.
|
|
||||||
backend/drizzle/*.sql -text
|
|
||||||
+9
-102
@@ -25,9 +25,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Test release version calculation
|
|
||||||
run: node --test .ci/scripts/compute-release.test.mjs
|
|
||||||
|
|
||||||
- name: Compute release metadata
|
- name: Compute release metadata
|
||||||
id: release
|
id: release
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -57,31 +54,16 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||||
docker rm -f "$NAME" 2>/dev/null || true
|
docker rm -f "$NAME" mm-pg 2>/dev/null || true
|
||||||
# Docker-backed Gitea runners execute this step in a separate container.
|
HOST_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
|
||||||
# Join its network; a published host loopback port is not reachable here.
|
|
||||||
NETWORKS=$(docker inspect --format '{{range $name, $network := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$(cat /etc/hostname)" 2>/dev/null || true)
|
|
||||||
if [ -n "$NETWORKS" ]; then
|
|
||||||
test "$(printf '%s\n' "$NETWORKS" | wc -l)" -eq 1
|
|
||||||
NETWORK_ARGS=(--network "$NETWORKS" --network-alias "$NAME")
|
|
||||||
PG_HOST="$NAME"
|
|
||||||
PG_PORT=5432
|
|
||||||
else
|
|
||||||
# Host-based runners can use a Docker-assigned ephemeral host port.
|
|
||||||
NETWORK_ARGS=(-p 127.0.0.1::5432)
|
|
||||||
PG_HOST=127.0.0.1
|
|
||||||
fi
|
|
||||||
docker run -d --name "$NAME" --rm \
|
docker run -d --name "$NAME" --rm \
|
||||||
-e POSTGRES_USER=mmapp \
|
-e POSTGRES_USER=mmapp \
|
||||||
-e POSTGRES_PASSWORD=mmapp \
|
-e POSTGRES_PASSWORD=mmapp \
|
||||||
-e POSTGRES_DB=mmapp \
|
-e POSTGRES_DB=mmapp \
|
||||||
"${NETWORK_ARGS[@]}" \
|
-p "127.0.0.1:${HOST_PORT}:5432" \
|
||||||
postgres:18-alpine
|
postgres:18-alpine
|
||||||
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
|
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
|
||||||
if [ "$PG_HOST" = 127.0.0.1 ]; then
|
echo "DATABASE_URL=postgres://mmapp:mmapp@127.0.0.1:${HOST_PORT}/mmapp" >> "${GITHUB_ENV}"
|
||||||
PG_PORT=$(docker port "$NAME" 5432/tcp | awk -F: '{print $NF}')
|
|
||||||
fi
|
|
||||||
echo "DATABASE_URL=postgres://mmapp:mmapp@${PG_HOST}:${PG_PORT}/mmapp" >> "${GITHUB_ENV}"
|
|
||||||
for i in $(seq 1 40); do
|
for i in $(seq 1 40); do
|
||||||
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
|
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
|
||||||
exit 0
|
exit 0
|
||||||
@@ -94,27 +76,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Install and test backend
|
- name: Install and test backend
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
REQUIRE_TEST_POSTGRES: "1"
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
npm ci
|
npm ci
|
||||||
node --input-type=module <<'NODE'
|
|
||||||
import pg from 'pg';
|
|
||||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 8000 });
|
|
||||||
try {
|
|
||||||
await pool.query('SELECT 1');
|
|
||||||
console.log('PostgreSQL reachable from the test process');
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
NODE
|
|
||||||
npm run test --prefix backend
|
npm run test --prefix backend
|
||||||
|
|
||||||
- name: Stop PostgreSQL
|
- name: Stop PostgreSQL
|
||||||
if: always()
|
if: always()
|
||||||
shell: bash
|
shell: bash
|
||||||
run: docker rm -f "${PG_CONTAINER:-}" 2>/dev/null || true
|
run: docker rm -f "${PG_CONTAINER:-}" mm-pg 2>/dev/null || true
|
||||||
|
|
||||||
backend-image:
|
backend-image:
|
||||||
needs:
|
needs:
|
||||||
@@ -153,13 +123,8 @@ jobs:
|
|||||||
NODE
|
NODE
|
||||||
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
||||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||||
cp backend/package.json backend/tsconfig.json backend/mcp-coverage.json "$STAGING/backend/"
|
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
||||||
cp -R backend/src "$STAGING/backend/"
|
cp -R backend/src "$STAGING/backend/"
|
||||||
mkdir -p "$STAGING/backend/scripts/writer-profile"
|
|
||||||
cp backend/scripts/build-database-operator.mjs backend/scripts/database-writer-bootstrap-operator.mjs backend/scripts/database-writer-profile-operator.mts "$STAGING/backend/scripts/"
|
|
||||||
cp backend/scripts/writer-profile/operator-files.mjs backend/scripts/writer-profile/operator-contract.mjs backend/scripts/writer-profile/publisher.mjs backend/scripts/writer-profile/context.mjs backend/scripts/writer-profile/catalogue.mts backend/scripts/writer-profile/catalogue-value-bridge.ts backend/scripts/writer-profile/catalogue-projection.ts backend/scripts/writer-profile/catalogue-leaves.ts backend/scripts/writer-profile/application-writer-profile-catalogue.ts backend/scripts/writer-profile/application-writer-profile-catalogue-values.ts backend/scripts/writer-profile/application-writer-profile-catalogue-parent-policy.ts backend/scripts/writer-profile/application-writer-profile-canonical.ts backend/scripts/writer-profile/fixed44-build-sql.ts backend/scripts/writer-profile/fixed44-build-definition.ts backend/scripts/writer-profile/expanded44-data-policy.ts "${STAGING}/backend/scripts/writer-profile/"
|
|
||||||
mkdir -p "$STAGING/deploy/netflow-agent"
|
|
||||||
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
|
|
||||||
if [ -d backend/drizzle ]; then
|
if [ -d backend/drizzle ]; then
|
||||||
cp -R backend/drizzle "$STAGING/backend/"
|
cp -R backend/drizzle "$STAGING/backend/"
|
||||||
fi
|
fi
|
||||||
@@ -334,69 +299,12 @@ jobs:
|
|||||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||||
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
||||||
|
|
||||||
netflow-agent-image:
|
|
||||||
needs: prepare-release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Configure netflow-agent image name
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
|
|
||||||
name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
|
|
||||||
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
|
|
||||||
echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-netflow-agent" >> "${GITHUB_ENV}"
|
|
||||||
|
|
||||||
- name: Test controller and installer
|
|
||||||
run: |
|
|
||||||
python3 -m unittest discover -s deploy/netflow-agent -p "test_*.py" -v
|
|
||||||
bash -n deploy/netflow-agent/install.sh
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to Gitea Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.shx.one
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.ACTIONS_PAT }}
|
|
||||||
|
|
||||||
- name: Verify image with real WireGuard
|
|
||||||
run: |
|
|
||||||
docker build --target test -t mm-netflow-agent-test deploy/netflow-agent
|
|
||||||
docker run --rm --cap-drop ALL --cap-add NET_ADMIN --read-only --tmpfs /tmp -e FLOW_AGENT_KERNEL_TEST=1 --entrypoint python3 mm-netflow-agent-test -m unittest discover -s /app -p "test_agent.py" -v
|
|
||||||
|
|
||||||
- name: Build and push netflow-agent image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: deploy/netflow-agent
|
|
||||||
file: deploy/netflow-agent/Dockerfile
|
|
||||||
platforms: linux/amd64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.IMAGE_NAME }}:latest
|
|
||||||
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
|
||||||
${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
|
|
||||||
cache-from: type=gha,scope=netflow-agent
|
|
||||||
cache-to: type=gha,mode=max,scope=netflow-agent
|
|
||||||
labels: |
|
|
||||||
org.opencontainers.image.title=mmapp-netflow-agent
|
|
||||||
org.opencontainers.image.description=MikroTik Manager local WireGuard controller
|
|
||||||
org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
|
|
||||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
|
||||||
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
needs:
|
needs:
|
||||||
- prepare-release
|
- prepare-release
|
||||||
- backend-image
|
- backend-image
|
||||||
- frontend-image
|
- frontend-image
|
||||||
- updater-image
|
- updater-image
|
||||||
- netflow-agent-image
|
|
||||||
if: needs.prepare-release.outputs.should_release == 'true'
|
if: needs.prepare-release.outputs.should_release == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -478,18 +386,17 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
notify-webhook:
|
notify-webhook:
|
||||||
|
if: ${{ secrets.DEPLOY_WEBHOOK_URL != '' }}
|
||||||
needs:
|
needs:
|
||||||
- prepare-release
|
- prepare-release
|
||||||
- backend-image
|
- backend-image
|
||||||
- frontend-image
|
- frontend-image
|
||||||
- netflow-agent-image
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Notify deploy webhook
|
- name: Notify deploy webhook
|
||||||
if: env.WEBHOOK_URL != ''
|
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
curl --fail --silent --show-error --request POST \
|
curl --fail --silent --show-error --request POST \
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
name: MCP isolated acceptance preview images
|
|
||||||
'on':
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
expected_sha:
|
|
||||||
description: Exact reviewed full commit SHA on the selected ref; includes all accepted source changes
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
auth_portal_origin:
|
|
||||||
description: Canonical isolated browser-facing Auth Portal HTTP(S) origin; no path, credentials or query
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
default: http://127.0.0.1:55175
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
jobs:
|
|
||||||
backend-preview:
|
|
||||||
if: ${{ gitea.event_name == 'workflow_dispatch' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 40
|
|
||||||
env:
|
|
||||||
COMPONENT: backend
|
|
||||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
|
||||||
DISPATCH_SHA: ${{ gitea.sha }}
|
|
||||||
DISPATCH_EVENT: ${{ gitea.event_name }}
|
|
||||||
REPOSITORY: ${{ gitea.repository }}
|
|
||||||
RUN_ID: ${{ gitea.run_id }}
|
|
||||||
RUN_ATTEMPT: ${{ gitea.run_attempt }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout exact dispatch commit
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ gitea.sha }}
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Use existing Linux Node22 build toolchain
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
- name: Verify source and isolated preview destination
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
test "$DISPATCH_EVENT" = workflow_dispatch
|
|
||||||
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
|
|
||||||
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
|
|
||||||
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
|
||||||
git diff --exit-code
|
|
||||||
git diff --cached --exit-code
|
|
||||||
[[ "$RUN_ID" =~ ^[0-9]+$ && "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]
|
|
||||||
owner=$(printf '%s' "$REPOSITORY" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
|
|
||||||
name=$(printf '%s' "$REPOSITORY" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
|
|
||||||
[[ "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ && "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]
|
|
||||||
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
|
|
||||||
echo "IMAGE_NAME=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance" >> "$GITHUB_ENV"
|
|
||||||
echo "PREVIEW_TAG=mcp-acceptance-$EXPECTED_SHA-$RUN_ID-$RUN_ATTEMPT" >> "$GITHUB_ENV"
|
|
||||||
echo "PREVIEW_CACHE=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance-buildcache:node-buildcache" >> "$GITHUB_ENV"
|
|
||||||
mkdir -p .ci/acceptance-evidence
|
|
||||||
node <<'NODE'
|
|
||||||
const fs=require('node:fs'),cp=require('node:child_process'),crypto=require('node:crypto');
|
|
||||||
const files=cp.execFileSync('git',['ls-files','-z'],{encoding:'utf8'}).split('\0').filter(Boolean);
|
|
||||||
const inputs=files.map(path=>{const info=fs.lstatSync(path);if(!info.isFile()||info.isSymbolicLink())throw Error('Regular tracked source inputs required');const data=fs.readFileSync(path);return {path,sha256:crypto.createHash('sha256').update(data).digest('hex'),bytes:data.length}});
|
|
||||||
fs.writeFileSync('.ci/acceptance-evidence/source.json',JSON.stringify({format:'mcp-preview-source-v1',repository:process.env.REPOSITORY,sourceSha:process.env.EXPECTED_SHA,sourceTree:cp.execFileSync('git',['rev-parse','HEAD^{tree}'],{encoding:'utf8'}).trim(),platform:'linux/amd64',component:process.env.COMPONENT,runId:process.env.RUN_ID,runAttempt:process.env.RUN_ATTEMPT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,inputs},null,2)+'\n');
|
|
||||||
NODE
|
|
||||||
- name: Prepare existing scoped Docker context
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
STAGING=".ci/docker/backend"
|
|
||||||
rm -rf "$STAGING"
|
|
||||||
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
|
|
||||||
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 backend/mcp-coverage.json "$STAGING/backend/"
|
|
||||||
cp -R backend/src "$STAGING/backend/"
|
|
||||||
mkdir -p "$STAGING/deploy/netflow-agent"
|
|
||||||
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
|
|
||||||
if [ -d backend/drizzle ]; then
|
|
||||||
cp -R backend/drizzle "$STAGING/backend/"
|
|
||||||
fi
|
|
||||||
mkdir -p "$STAGING/backend/scripts/writer-profile"
|
|
||||||
cp backend/scripts/build-database-operator.mjs backend/scripts/database-writer-bootstrap-operator.mjs backend/scripts/database-writer-profile-operator.mts "$STAGING/backend/scripts/"
|
|
||||||
cp backend/scripts/writer-profile/operator-files.mjs backend/scripts/writer-profile/operator-contract.mjs backend/scripts/writer-profile/publisher.mjs backend/scripts/writer-profile/context.mjs backend/scripts/writer-profile/catalogue.mts backend/scripts/writer-profile/catalogue-value-bridge.ts backend/scripts/writer-profile/catalogue-projection.ts backend/scripts/writer-profile/catalogue-leaves.ts backend/scripts/writer-profile/application-writer-profile-catalogue.ts backend/scripts/writer-profile/application-writer-profile-catalogue-values.ts backend/scripts/writer-profile/application-writer-profile-catalogue-parent-policy.ts backend/scripts/writer-profile/application-writer-profile-canonical.ts backend/scripts/writer-profile/fixed44-build-sql.ts backend/scripts/writer-profile/fixed44-build-definition.ts backend/scripts/writer-profile/expanded44-data-policy.ts "${STAGING}/backend/scripts/writer-profile/"
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Log in to existing Gitea registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.shx.one
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.ACTIONS_PAT }}
|
|
||||||
- name: Build and push isolated acceptance image
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .ci/docker/backend
|
|
||||||
file: backend/Dockerfile
|
|
||||||
platforms: linux/amd64
|
|
||||||
push: true
|
|
||||||
tags: ${{ env.IMAGE_NAME }}:${{ env.PREVIEW_TAG }}
|
|
||||||
build-args: APP_VERSION=${{ env.PREVIEW_TAG }}
|
|
||||||
cache-from: type=gha,scope=mcp-acceptance-backend
|
|
||||||
cache-to: type=gha,scope=mcp-acceptance-backend,mode=max
|
|
||||||
provenance: mode=min
|
|
||||||
sbom: true
|
|
||||||
labels: |-
|
|
||||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
|
||||||
org.opencontainers.image.version=${{ env.PREVIEW_TAG }}
|
|
||||||
io.mmapp.acceptance-only=true
|
|
||||||
- name: Record exact digest and build metadata
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
|
|
||||||
BUILD_METADATA: ${{ steps.build.outputs.metadata }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
node <<'NODE'
|
|
||||||
const fs=require('node:fs'),crypto=require('node:crypto');
|
|
||||||
const digest=process.env.IMAGE_DIGEST;if(!/^sha256:[a-f0-9]{64}$/.test(digest))throw Error('Exact registry digest required');
|
|
||||||
const metadata=JSON.parse(process.env.BUILD_METADATA);const source=fs.readFileSync('.ci/acceptance-evidence/source.json');
|
|
||||||
fs.writeFileSync('.ci/acceptance-evidence/image.json',JSON.stringify({format:'mcp-preview-image-v1',sourceSha:process.env.EXPECTED_SHA,sourceProofSha256:crypto.createHash('sha256').update(source).digest('hex'),image:process.env.IMAGE_NAME,tag:process.env.PREVIEW_TAG,digest,pinnedReference:process.env.IMAGE_NAME+'@'+digest,platform:'linux/amd64',component:process.env.COMPONENT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,buildMetadata:metadata,productionDeployed:false,wholeInstallation2GbAccepted:false},null,2)+'\n');
|
|
||||||
console.log('Acceptance image digest: '+process.env.IMAGE_NAME+'@'+digest);
|
|
||||||
NODE
|
|
||||||
- name: Retain source and image binding artifacts
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: mcp-acceptance-${{ env.COMPONENT }}-${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
|
||||||
path: .ci/acceptance-evidence/*.json
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
frontend-preview:
|
|
||||||
if: ${{ gitea.event_name == 'workflow_dispatch' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 40
|
|
||||||
env:
|
|
||||||
COMPONENT: frontend
|
|
||||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
|
||||||
DISPATCH_SHA: ${{ gitea.sha }}
|
|
||||||
DISPATCH_EVENT: ${{ gitea.event_name }}
|
|
||||||
REPOSITORY: ${{ gitea.repository }}
|
|
||||||
RUN_ID: ${{ gitea.run_id }}
|
|
||||||
RUN_ATTEMPT: ${{ gitea.run_attempt }}
|
|
||||||
AUTH_PORTAL_ORIGIN_REQUEST: ${{ inputs.auth_portal_origin }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout exact dispatch commit
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ gitea.sha }}
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Use existing Linux Node22 build toolchain
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
- name: Validate explicit isolated portal origin
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
node <<'NODE'
|
|
||||||
const fs=require('node:fs'),assert=require('node:assert/strict');
|
|
||||||
const raw=process.env.AUTH_PORTAL_ORIGIN_REQUEST;
|
|
||||||
assert(typeof raw==='string'&&raw.length>0&&raw.length<=2048&&!/[\x00-\x20\x7f]/.test(raw),'Bounded canonical HTTP(S) origin required');
|
|
||||||
const origin=new URL(raw);
|
|
||||||
assert(['http:','https:'].includes(origin.protocol)&&!origin.username&&!origin.password&&origin.pathname==='/'&&!origin.search&&!origin.hash&&origin.origin!=='null'&&origin.origin===raw,'Canonical origin only; credentials/path/query/hash refused');
|
|
||||||
fs.appendFileSync(process.env.GITHUB_ENV,'AUTH_PORTAL_ORIGIN='+origin.origin+'\n');
|
|
||||||
NODE
|
|
||||||
- name: Verify source and isolated preview destination
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
test "$DISPATCH_EVENT" = workflow_dispatch
|
|
||||||
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
|
|
||||||
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
|
|
||||||
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
|
||||||
git diff --exit-code
|
|
||||||
git diff --cached --exit-code
|
|
||||||
[[ "$RUN_ID" =~ ^[0-9]+$ && "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]
|
|
||||||
owner=$(printf '%s' "$REPOSITORY" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
|
|
||||||
name=$(printf '%s' "$REPOSITORY" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
|
|
||||||
[[ "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ && "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]
|
|
||||||
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
|
|
||||||
echo "IMAGE_NAME=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance" >> "$GITHUB_ENV"
|
|
||||||
echo "PREVIEW_TAG=mcp-acceptance-$EXPECTED_SHA-$RUN_ID-$RUN_ATTEMPT" >> "$GITHUB_ENV"
|
|
||||||
echo "PREVIEW_CACHE=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance-buildcache:node-buildcache" >> "$GITHUB_ENV"
|
|
||||||
mkdir -p .ci/acceptance-evidence
|
|
||||||
node <<'NODE'
|
|
||||||
const fs=require('node:fs'),cp=require('node:child_process'),crypto=require('node:crypto');
|
|
||||||
const files=cp.execFileSync('git',['ls-files','-z'],{encoding:'utf8'}).split('\0').filter(Boolean);
|
|
||||||
const inputs=files.map(path=>{const info=fs.lstatSync(path);if(!info.isFile()||info.isSymbolicLink())throw Error('Regular tracked source inputs required');const data=fs.readFileSync(path);return {path,sha256:crypto.createHash('sha256').update(data).digest('hex'),bytes:data.length}});
|
|
||||||
fs.writeFileSync('.ci/acceptance-evidence/source.json',JSON.stringify({format:'mcp-preview-source-v1',repository:process.env.REPOSITORY,sourceSha:process.env.EXPECTED_SHA,sourceTree:cp.execFileSync('git',['rev-parse','HEAD^{tree}'],{encoding:'utf8'}).trim(),platform:'linux/amd64',component:process.env.COMPONENT,runId:process.env.RUN_ID,runAttempt:process.env.RUN_ATTEMPT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,inputs},null,2)+'\n');
|
|
||||||
NODE
|
|
||||||
- name: Prepare existing scoped Docker context
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
STAGING=".ci/docker/frontend"
|
|
||||||
rm -rf "$STAGING"
|
|
||||||
mkdir -p "$STAGING/packages/contracts" "$STAGING/public"
|
|
||||||
cp package-lock.json next.config.ts tsconfig.json postcss.config.mjs eslint.config.mjs components.json "$STAGING/"
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require("node:fs")
|
|
||||||
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
|
||||||
pkg.workspaces = ["packages/*"]
|
|
||||||
fs.writeFileSync(".ci/docker/frontend/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/"
|
|
||||||
for dir in app components lib shared entities public hooks; do
|
|
||||||
if [ -d "$dir" ]; then
|
|
||||||
cp -R "$dir" "$STAGING/"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
test -f "$STAGING/public/release-manifest.json"
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Log in to existing Gitea registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.shx.one
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.ACTIONS_PAT }}
|
|
||||||
- name: Build and push isolated acceptance image
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .ci/docker/frontend
|
|
||||||
file: Dockerfile.frontend
|
|
||||||
platforms: linux/amd64
|
|
||||||
push: true
|
|
||||||
tags: ${{ env.IMAGE_NAME }}:${{ env.PREVIEW_TAG }}
|
|
||||||
build-args: |-
|
|
||||||
BACKEND_INTERNAL_URL=http://backend:8000
|
|
||||||
NEXT_PUBLIC_BACKEND_URL=same-origin
|
|
||||||
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
|
||||||
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
|
||||||
NEXT_PUBLIC_AUTH_PORTAL_URL=${{ env.AUTH_PORTAL_ORIGIN }}
|
|
||||||
NEXT_PUBLIC_APP_VERSION=${{ env.PREVIEW_TAG }}
|
|
||||||
NEXT_PUBLIC_RELEASE_URL=
|
|
||||||
cache-from: type=gha,scope=mcp-acceptance-frontend
|
|
||||||
cache-to: type=gha,scope=mcp-acceptance-frontend,mode=max
|
|
||||||
provenance: mode=min
|
|
||||||
sbom: true
|
|
||||||
labels: |-
|
|
||||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
|
||||||
org.opencontainers.image.version=${{ env.PREVIEW_TAG }}
|
|
||||||
io.mmapp.acceptance-only=true
|
|
||||||
- name: Record exact digest and build metadata
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
|
|
||||||
BUILD_METADATA: ${{ steps.build.outputs.metadata }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
node <<'NODE'
|
|
||||||
const fs=require('node:fs'),crypto=require('node:crypto');
|
|
||||||
const digest=process.env.IMAGE_DIGEST;if(!/^sha256:[a-f0-9]{64}$/.test(digest))throw Error('Exact registry digest required');
|
|
||||||
const metadata=JSON.parse(process.env.BUILD_METADATA);const source=fs.readFileSync('.ci/acceptance-evidence/source.json');
|
|
||||||
fs.writeFileSync('.ci/acceptance-evidence/image.json',JSON.stringify({format:'mcp-preview-image-v1',sourceSha:process.env.EXPECTED_SHA,sourceProofSha256:crypto.createHash('sha256').update(source).digest('hex'),image:process.env.IMAGE_NAME,tag:process.env.PREVIEW_TAG,digest,pinnedReference:process.env.IMAGE_NAME+'@'+digest,platform:'linux/amd64',component:process.env.COMPONENT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,buildMetadata:metadata,productionDeployed:false,wholeInstallation2GbAccepted:false},null,2)+'\n');
|
|
||||||
console.log('Acceptance image digest: '+process.env.IMAGE_NAME+'@'+digest);
|
|
||||||
NODE
|
|
||||||
- name: Retain source and image binding artifacts
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: mcp-acceptance-${{ env.COMPONENT }}-${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
|
||||||
path: .ci/acceptance-evidence/*.json
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
-23
@@ -31,10 +31,6 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# coverage
|
# coverage
|
||||||
coverage/
|
coverage/
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
@@ -52,8 +48,6 @@ Thumbs.db
|
|||||||
.vercel/
|
.vercel/
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
.mcp-state/
|
|
||||||
backend/.mcp-state/
|
|
||||||
.ci/docker/
|
.ci/docker/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
@@ -61,20 +55,3 @@ backend/.mcp-state/
|
|||||||
.cursor/mcp.json
|
.cursor/mcp.json
|
||||||
.mcp.json
|
.mcp.json
|
||||||
opencode.json
|
opencode.json
|
||||||
|
|
||||||
# Local codegraph process
|
|
||||||
.codegraph/daemon.pid
|
|
||||||
|
|
||||||
# Python controller tests
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
||||||
# NetFlow agent host state (when Compose is installed in a checkout)
|
|
||||||
/data/netflow-wireguard/
|
|
||||||
/data/netflow-agent-socket/
|
|
||||||
# Compiled disposable memory-acceptance entry; not a production image artifact.
|
|
||||||
backend/dist-mcp-memory/
|
|
||||||
backend/dist-operator/
|
|
||||||
|
|
||||||
# Agent-local permissions can contain commands with deployment credentials.
|
|
||||||
/.claude/settings.local.json
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Adapting installed ReUI code (reuse-first, no AI slop)
|
||||||
|
|
||||||
|
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
|
||||||
|
|
||||||
|
## Preserve the design - don't over-customize
|
||||||
|
|
||||||
|
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
|
||||||
|
|
||||||
|
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
|
||||||
|
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
|
||||||
|
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
|
||||||
|
|
||||||
|
## Reuse the parts: examples and the block's own elements
|
||||||
|
|
||||||
|
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
|
||||||
|
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
|
||||||
|
|
||||||
|
## Don't invent (read, don't guess)
|
||||||
|
|
||||||
|
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
|
||||||
|
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
|
||||||
|
|
||||||
|
## What to change vs leave alone
|
||||||
|
|
||||||
|
- **Change:** the item's own data, copy, props, and layout to fit the app.
|
||||||
|
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
|
||||||
|
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
|
||||||
|
|
||||||
|
## Demo data -> real data
|
||||||
|
|
||||||
|
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
|
||||||
|
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
|
||||||
|
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
|
||||||
|
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
|
||||||
|
|
||||||
|
## Believable content (no AI tells)
|
||||||
|
|
||||||
|
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
|
||||||
|
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
|
||||||
|
|
||||||
|
## Operational surfaces (settings / profile / admin)
|
||||||
|
|
||||||
|
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CLI: registry setup, license, non-interactive install
|
||||||
|
|
||||||
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
|
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
|
||||||
|
|
||||||
|
1. Add the key to `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REUI_LICENSE_KEY=your-license-key
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Switch `components.json` to the authenticated object form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registries": {
|
||||||
|
"@reui": {
|
||||||
|
"url": "https://reui.io/r/{style}/{name}.json",
|
||||||
|
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
Use the project's package runner (check `packageManager`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes # npm
|
||||||
|
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
|
||||||
|
bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||||
|
```
|
||||||
|
|
||||||
|
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
|
||||||
|
|
||||||
|
## Handling prompts and conflicts
|
||||||
|
|
||||||
|
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
|
||||||
|
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
|
||||||
|
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
|
||||||
|
- Run from the **project root** so `components.json` and `.env.local` are found.
|
||||||
|
|
||||||
|
## Free vs premium boundary
|
||||||
|
|
||||||
|
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# ReUI components
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
})
|
||||||
|
|
||||||
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes:
|
||||||
|
|
||||||
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
|
## kanban
|
||||||
|
|
||||||
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
|
||||||
|
<KanbanBoard>
|
||||||
|
{Object.entries(cols).map(([id, items]) => (
|
||||||
|
<KanbanColumn key={id} value={id}>
|
||||||
|
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||||
|
<KanbanColumnContent value={id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<KanbanItem key={i.id} value={i.id}>
|
||||||
|
<KanbanItemHandle>{i.title}</KanbanItemHandle>
|
||||||
|
</KanbanItem>
|
||||||
|
))}
|
||||||
|
</KanbanColumnContent>
|
||||||
|
</KanbanColumn>
|
||||||
|
))}
|
||||||
|
</KanbanBoard>
|
||||||
|
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
|
||||||
|
</Kanban>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
|
||||||
|
|
||||||
|
## sortable
|
||||||
|
|
||||||
|
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<SortableItem key={i.id} value={i.id}>
|
||||||
|
<SortableItemHandle><GripVertical /></SortableItemHandle>
|
||||||
|
{i.label}
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</Sortable>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
|
||||||
|
|
||||||
|
## filters
|
||||||
|
|
||||||
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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 fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange` to capture the value.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||||
|
|
||||||
|
<DateSelector value={value} onChange={setValue} label="Due date" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
|
||||||
|
|
||||||
|
## tree
|
||||||
|
|
||||||
|
**Required:** `tree` (a `@headless-tree/core` instance you construct)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tree tree={tree}>
|
||||||
|
{tree.getItems().map((item) => (
|
||||||
|
<TreeItem key={item.getId()} item={item}>
|
||||||
|
<TreeItemLabel />
|
||||||
|
</TreeItem>
|
||||||
|
))}
|
||||||
|
</Tree>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
|
||||||
|
|
||||||
|
## stepper
|
||||||
|
|
||||||
|
**Required:** `StepperItem step` (number), `StepperContent value` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stepper defaultValue={1}>
|
||||||
|
<StepperNav>
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel>
|
||||||
|
<StepperContent value={1}>Step 1 content</StepperContent>
|
||||||
|
<StepperContent value={2}>Step 2 content</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
|
||||||
|
|
||||||
|
## timeline
|
||||||
|
|
||||||
|
**Required:** `TimelineItem step` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Timeline>
|
||||||
|
<TimelineItem step={1}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>March 2024</TimelineDate>
|
||||||
|
<TimelineTitle>Project initialized</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
<TimelineContent>Repo and architecture set up.</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
</Timeline>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
|
||||||
|
|
||||||
|
## autocomplete
|
||||||
|
|
||||||
|
**Required:** `items` (array; each item has at least `value`)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Autocomplete items={items}>
|
||||||
|
<AutocompleteInput placeholder="Search..." />
|
||||||
|
<AutocompleteContent>
|
||||||
|
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
|
||||||
|
<AutocompleteList>
|
||||||
|
{(item) => (
|
||||||
|
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
|
||||||
|
)}
|
||||||
|
</AutocompleteList>
|
||||||
|
</AutocompleteContent>
|
||||||
|
</Autocomplete>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
|
||||||
|
|
||||||
|
## phone-input
|
||||||
|
|
||||||
|
**Required:** none, but wire `onChange`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
|
||||||
|
|
||||||
|
## number-field
|
||||||
|
|
||||||
|
**Required:** wrap the controls in `NumberFieldGroup`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NumberField defaultValue={0}>
|
||||||
|
<NumberFieldScrubArea label="Quantity" />
|
||||||
|
<NumberFieldGroup>
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
|
||||||
|
|
||||||
|
## rating
|
||||||
|
|
||||||
|
**Required:** `rating` (number)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
|
||||||
|
|
||||||
|
## scrollspy
|
||||||
|
|
||||||
|
**Required:** `targetRef` (the scroll container ref)
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Scrollspy targetRef={containerRef}>
|
||||||
|
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
|
||||||
|
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
|
||||||
|
</Scrollspy>
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<div id="s1">...</div>
|
||||||
|
<div id="s2">...</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
|
||||||
|
|
||||||
|
## frame
|
||||||
|
|
||||||
|
**Required:** `Frame` > `FramePanel`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Frame>
|
||||||
|
<FramePanel>
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Title</FrameTitle>
|
||||||
|
<FrameDescription>Description</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<div className="p-5">Content</div>
|
||||||
|
<FrameFooter>Footer</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||||
|
|
||||||
|
## icon-stack
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconStack aria-hidden="true">
|
||||||
|
<InboxIcon className="size-4" />
|
||||||
|
</IconStack>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
|
## alert
|
||||||
|
|
||||||
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Alert variant="success">
|
||||||
|
<ShieldCheckIcon />
|
||||||
|
<AlertTitle>Security update</AlertTitle>
|
||||||
|
<AlertDescription>Enable two-factor authentication.</AlertDescription>
|
||||||
|
<AlertAction><Button size="xs">Update</Button></AlertAction>
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
|
||||||
|
|
||||||
|
## badge
|
||||||
|
|
||||||
|
**Required:** none (text child).
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="success-light" size="sm">Success</Badge>
|
||||||
|
<Badge variant="outline" radius="full">Pill</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||||
|
|
||||||
|
## base vs radix - write for the project's base
|
||||||
|
|
||||||
|
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||||
|
|
||||||
|
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||||
|
|
||||||
|
- `"style": "base-nova"` -> **Base UI**
|
||||||
|
- `"style": "radix-nova"` -> **Radix UI**
|
||||||
|
|
||||||
|
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||||
|
|
||||||
|
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
|
||||||
|
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
|
||||||
|
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
|
||||||
|
|
||||||
|
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Craft: make ReUI UI exceptional, not generic
|
||||||
|
|
||||||
|
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||||
|
|
||||||
|
## Have a point of view
|
||||||
|
|
||||||
|
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
|
||||||
|
|
||||||
|
## Brutally clear hierarchy
|
||||||
|
|
||||||
|
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||||
|
|
||||||
|
## Spacing rhythm and deliberate density
|
||||||
|
|
||||||
|
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||||
|
|
||||||
|
## Cover the real states (the usual miss)
|
||||||
|
|
||||||
|
A surface isn't done at the happy path. Compose, and wire:
|
||||||
|
|
||||||
|
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||||
|
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||||
|
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||||
|
|
||||||
|
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||||
|
|
||||||
|
## Responsive by default
|
||||||
|
|
||||||
|
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||||
|
|
||||||
|
## Motion, subtly
|
||||||
|
|
||||||
|
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||||
|
|
||||||
|
## Real, activated content
|
||||||
|
|
||||||
|
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||||
|
|
||||||
|
## Avoid the AI tells
|
||||||
|
|
||||||
|
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||||
|
|
||||||
|
## The bar
|
||||||
|
|
||||||
|
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Icons (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||||
|
|
||||||
|
## Portable icons (library-agnostic)
|
||||||
|
|
||||||
|
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||||
|
|
||||||
|
- `lucide` -> `lucide-react`
|
||||||
|
- `tabler` -> `@tabler/icons-react`
|
||||||
|
- `phosphor` -> `@phosphor-icons/react`
|
||||||
|
- `remix` -> `@remixicon/react`
|
||||||
|
- `hugeicons` -> `@hugeicons/react`
|
||||||
|
|
||||||
|
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||||
|
|
||||||
|
## Keep icons purposeful
|
||||||
|
|
||||||
|
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
|
||||||
|
|
||||||
|
## Motion Icons (the `@reui/icons/...` set)
|
||||||
|
|
||||||
|
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||||
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
|
```
|
||||||
|
|
||||||
|
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
|
Finding icons:
|
||||||
|
|
||||||
|
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
|
||||||
|
- One icon: `search` with `type: "icon"`.
|
||||||
|
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||||
|
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||||
|
|
||||||
|
The `icon-stack` component composes multiple icons into a stacked display.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Quality gates (security, accessibility, scroll)
|
||||||
|
|
||||||
|
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Never `dangerouslySetInnerHTML`. Render data as text/components.
|
||||||
|
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
|
||||||
|
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
|
||||||
|
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
|
||||||
|
- Every non-submit button is `type="button"`.
|
||||||
|
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||||
|
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||||
|
|
||||||
|
## Scroll mechanics
|
||||||
|
|
||||||
|
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
|
||||||
|
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# ReUI registry structure
|
||||||
|
|
||||||
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
|
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
|
|
||||||
|
## The @reui registry
|
||||||
|
|
||||||
|
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
|
||||||
|
|
||||||
|
## Know your base: base or radix
|
||||||
|
|
||||||
|
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
|
||||||
|
|
||||||
|
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
|
||||||
|
|
||||||
|
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||||
|
|
||||||
|
## Free vs premium
|
||||||
|
|
||||||
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
|
## Component API index
|
||||||
|
|
||||||
|
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Styling (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||||
|
|
||||||
|
## ReUI extended semantic tokens
|
||||||
|
|
||||||
|
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||||
|
|
||||||
|
- `--success` / `--success-foreground`
|
||||||
|
- `--info` / `--info-foreground`
|
||||||
|
- `--warning` / `--warning-foreground`
|
||||||
|
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||||
|
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||||
|
|
||||||
|
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||||
|
|
||||||
|
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||||
|
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||||
|
|
||||||
|
## Blocks follow your theme
|
||||||
|
|
||||||
|
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||||
|
|
||||||
|
## Density and typography rhythm
|
||||||
|
|
||||||
|
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Workflow: find -> install -> read API -> adapt
|
||||||
|
|
||||||
|
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
|
||||||
|
|
||||||
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||||
|
|
||||||
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
|
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
|
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||||
|
|
||||||
|
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||||
|
|
||||||
|
## 2. Install (shadcn CLI)
|
||||||
|
|
||||||
|
Run the result's `install` command from the project root, non-interactively:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/<name> --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
|
||||||
|
|
||||||
|
## 3. Read the API (do not guess props)
|
||||||
|
|
||||||
|
Before writing code against any component an item uses:
|
||||||
|
|
||||||
|
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its 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.
|
||||||
|
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||||
|
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||||
|
|
||||||
|
## 4. Adapt (reuse-first) - do not skip
|
||||||
|
|
||||||
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
|
||||||
|
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
|
||||||
|
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
|
||||||
|
8. **Typecheck / lint**.
|
||||||
|
|
||||||
|
## If no single block fits
|
||||||
|
|
||||||
|
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# ReUI MCP: full reference
|
||||||
|
|
||||||
|
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
|
||||||
|
|
||||||
|
## Golden path (token-optimal - follow this order)
|
||||||
|
|
||||||
|
Most tasks need 2-4 MCP calls and ZERO web fetches:
|
||||||
|
|
||||||
|
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
|
||||||
|
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
|
||||||
|
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
|
||||||
|
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
|
||||||
|
5. **`get_audit_checklist()`** before declaring done.
|
||||||
|
|
||||||
|
If you already know the exact item name, skip `search`. Everything else is situational.
|
||||||
|
|
||||||
|
## The 5 task-specific tools (when to reach for each)
|
||||||
|
|
||||||
|
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||||
|
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||||
|
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||||
|
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||||
|
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||||
|
|
||||||
|
## All 19 tools
|
||||||
|
|
||||||
|
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
|
## Token + speed rules
|
||||||
|
|
||||||
|
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
|
||||||
|
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
|
||||||
|
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
|
||||||
|
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
|
||||||
|
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
|
||||||
|
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
|
||||||
|
|
||||||
|
## Result shapes (so you don't re-fetch)
|
||||||
|
|
||||||
|
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
|
||||||
|
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||||
|
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||||
|
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||||
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
|
||||||
|
## Error playbook
|
||||||
|
|
||||||
|
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||||
|
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||||
|
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||||
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||||
|
|
||||||
|
## Fallbacks
|
||||||
|
|
||||||
|
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||||
|
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||||
|
|
||||||
|
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Локальные GeoLite2-базы (Country + ASN) для потоков по странам и ASN
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Сейчас страна и ASN для netflow-потоков резолвятся через внешний RIPEstat API (`backend/src/services/traffic-flow-ripe.ts`): лимит 30 новых префиксов/мин, очередь на 90, кэш в PG `flow_ip_meta`. Новые IP «дозревают» с задержкой, IPv6 не покрывается (кэш индексируется только по IPv4). Локальные mmdb-базы дают мгновенный синхронный lookup всех IP без внешних вызовов.
|
||||||
|
|
||||||
|
Решения (подтверждены):
|
||||||
|
- Источник — **MaxMind GeoLite2 через P3TERX-зеркало**: `https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb` и `.../GeoLite2-ASN.mmdb`. Без регистрации, ключей и tar-распаковки. Точность по стране у GeoLite2 и IPinfo паритетная (<1% ошибок у обоих, arXiv 2026); выбран P3TERX за надёжность зеркала (5.2k звёзд) и преемственность: текущий RIPE-путь и так читает GeoLite (`maxmind-geo-lite`), история в кэше остаётся консистентной.
|
||||||
|
- **RIPEstat остаётся fallback** (до первой загрузки баз / если lookup не дал результата).
|
||||||
|
- City-базу не качаем (lat/lng фронтенд не использует).
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
### 1. Зависимость
|
||||||
|
- `npm install -w mikrotik-manager-backend maxmind` — sync-чтение mmdb, встроенные TS-типы, без транзитивных зависимостей, Node 22 ок.
|
||||||
|
|
||||||
|
### 2. Новый сервис `backend/src/services/traffic-flow-geoip.ts`
|
||||||
|
(по конвенциям окружения traffic-flow-*: контракт → маршрут → сервис, без БД-логики в маршрутах)
|
||||||
|
- Каталог: `backend/storage/geoip/` (конвенция `storage/backups`), файлы `GeoLite2-Country.mmdb`, `GeoLite2-ASN.mmdb`.
|
||||||
|
- `initGeoip()` — открыть ридеры best-effort при старте (из `index.ts` рядом с `startTrafficFlowListener`), независимо от настроек автообновления: файлы есть — работают.
|
||||||
|
- `lookupGeoip(ip): FlowIpMeta | null` — синхронно: `country.iso_code` (fallback `registered_country.iso_code`) с валидацией `isIsoCountry`, ASN = `autonomous_system_number`, holder = `autonomous_system_organization`; приватные IP → negative-запись как в RIPE (`isNonPublicIp`); в PG не пишем (lookup и так быстрый). IPv6 поддержан ридером.
|
||||||
|
- `resolveFlowIp(ip)` — фасад: `lookupGeoip(ip) ?? lookupRipeCached(ip)`; главный экспорт для потребителей.
|
||||||
|
- `geoipStatus()` — loaded, даты сборки баз (метаданные mmdb). Тест-хук `setGeoipReadersForTests`. Смена ридеров после обновления — атомарная замена ссылок.
|
||||||
|
|
||||||
|
### 3. Коллектор `backend/src/services/geoip-update-collector.ts`
|
||||||
|
`collectGeoipUpdateOnce()` по образцу `certificate-renew-collector.ts`:
|
||||||
|
1. Conditional GET с ETag/If-None-Match из настроек → 304 = skip (фолбэк-сравнение: размер/содержимое).
|
||||||
|
2. Скачивание в `*.tmp` через глобальный `fetch` + AbortController с таймаутом (внешний HTTP из service-слоя — по правилу fastify-backend-drizzle).
|
||||||
|
3. Валидация: открыть ридер из tmp-файла, пробой 8.8.8.8 (страна US, ASN 15169).
|
||||||
|
4. `fs.rename` атомарная подмена, старый файл → `*.prev` (откат, если новый ридер не открылся).
|
||||||
|
5. Перезагрузка ридеров, статус в настройках; snapshot для `scheduler_runs` (checked/downloaded/skipped/bytes/error).
|
||||||
|
|
||||||
|
### 4. Планировщик (`backend/src/services/scheduler.ts`)
|
||||||
|
- `JOB_KEYS` += `geoip_update`; case в `runSchedulerJobBody`; блок в `refreshScheduler()` по образцу `certificates_renew`: интервал `Math.max(6ч, updateIntervalSec*1000)`, по умолчанию 7 дней (upstream обновляется еженедельно) + немедленный первый запуск при включённой настройке.
|
||||||
|
|
||||||
|
### 5. Схема и миграция
|
||||||
|
- `backend/src/db/schema.ts`: singleton `geoip_settings` — `enabled` (default true), `updateIntervalSec` (default 604800), `lastCheckAt`, `lastSuccessAt`, `lastError`, `countryBuildAt`, `asnBuildAt`, `etagsJson` (jsonb), `createdAt/updatedAt`.
|
||||||
|
- Миграция: `npm run db:generate` → файл в `backend/drizzle/`.
|
||||||
|
|
||||||
|
### 6. API + контракты
|
||||||
|
- `packages/contracts/src/geoip.ts`: zod-схемы настроек/статуса (все входы — Zod, по правилам проекта).
|
||||||
|
- Новый `backend/src/routes/geoip.ts`, регистрация в `index.ts` с prefix `/api`:
|
||||||
|
- `GET /api/geoip` — настройки + статус (ready, даты сборки, последняя проверка/ошибка);
|
||||||
|
- `PUT /api/geoip` — сохранить настройки, затем `refreshScheduler()`;
|
||||||
|
- `POST /api/geoip/update` — запустить загрузку сейчас (409, если уже идёт; флаг-гард как в коллекторах).
|
||||||
|
|
||||||
|
### 7. Интеграция в пайплайн (geoip-first, RIPE-fallback)
|
||||||
|
- `traffic-flow-engine.ts` (`queueParsedFlows`, ~строка 336): `lookupRipeCached` → `resolveFlowIp`. Логика misses не меняется: при готовом mmdb публичные IP (v4+v6) резолвятся сразу, очередь RIPE пустеет; до скачивания баз — прежнее поведение.
|
||||||
|
- Остальные вызовы `lookupRipeCached` → `resolveFlowIp` (grep: как минимум `traffic-flow-analytics.ts` ~258–271).
|
||||||
|
- `classifyFlowDst`/бренды не трогаем: holder из mmdb (org name) встаёт в существующие `HOLDER_BRANDS`-регулярки как есть.
|
||||||
|
|
||||||
|
### 8. Frontend (по next-shadcn-production / ui-guardian: только переиспользование)
|
||||||
|
- Секция «GeoIP-базы (GeoLite2)» внутри существующей `components/traffic/netflow-settings-panel.tsx`: статус (готово/не скачано, даты сборки Country/ASN, последняя проверка, ошибка), тумблер автообновления, интервал, кнопка «Обновить сейчас» с индикатором. Только уже используемые в панели примитивы (Switch/Button/поля) — никаких новых визуальных паттернов и Card-shell. API-клиент через существующие http-хелперы.
|
||||||
|
|
||||||
|
### 9. Хаускипинг, тесты, проверки
|
||||||
|
- `backend/.gitignore`: `storage/geoip/`.
|
||||||
|
- Тесты `backend/src/services/traffic-flow-geoip.test.ts` + скрипт `test:geoip` (по образцу `test:traffic-flow`): приоритет фасада (geoip hit → RIPE не зовётся; miss → fallback), negative на приватных IP, фильтрация EU/ZZ через `isIsoCountry`, коллектор с мокнутым fetch (304-skip, битый файл → подмены нет, `.prev` сохранён), dims по стране/ASN с засеянным ридером.
|
||||||
|
- Проверки после реализации (обязательно по правилам): типы/сборка бэка (`npm run build -w mikrotik-manager-backend`), типы фронта при правке UI (`npx tsc --noEmit`), `npm run test:geoip` и `test:traffic-flow`; предупреждения не игнорировать.
|
||||||
|
- Коммит: `feat(netflow): <subject по-русски>` — новая пользовательская фича (мгновенные страна/ASN в потоках), по commit-messages-ru.
|
||||||
|
- README: короткий раздел о GeoIP; примечание, что в Docker `storage/geoip` ephemeral без тома — базы перекачаются после пересоздания контейнера (~17 МБ); при желании смонтировать volume.
|
||||||
|
|
||||||
|
## Что это даёт
|
||||||
|
Страна и ASN появляются у потока мгновенно при ingest (включая IPv6), без ограничения скорости RIPE; dims `country`/`asn` в `flow_daily_dims`, аналитика (карта, топы, monthly) становятся полными сразу. Внешняя зависимость от stat.ripe.net остаётся только как fallback до первой загрузки баз.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# IKEv2/IPsec VPN: клиенты, IP-адреса и сертификаты — по образцу WireGuard
|
||||||
|
|
||||||
|
## Архитектура (как у WireGuard)
|
||||||
|
|
||||||
|
Роутер — источник истины: live-чтение через существующий REST-клиент (`mikrotik.ts`), мутации прямыми REST-вызовами, история через `config_revisions` (новая секция `"ipsec"`; `section` — TEXT без CHECK → **SQL-миграция не нужна**). Новых таблиц нет. Объекты, созданные мастером/при создании клиентов, помечаются managed-комментарием `mm-ipsec` (по образцу managed-markers).
|
||||||
|
|
||||||
|
## Модель RouterOS (что создаём/читаем)
|
||||||
|
|
||||||
|
Мастер инициализации сервера (per router): CA-сертификат (`/certificate add` + `sign`, key-usage=key-cert-sign,crl-sign), серверный сертификат (CN=адрес/домен, SAN, tls-server), `/ip/ipsec/peer` (passive, exchange-mode=ike2, certificate=серверный cert), `/ip/ipsec/profile`+`proposal` (aes-256/sha256/modp2048, pfs), `/ip pool` + `/ip/ipsec/mode-config` «VPN» (address-pool, dns), policy-template, и опциональное (default on, чекбокс) managed srcnat masquerade-правило «интернет клиентам».
|
||||||
|
|
||||||
|
Клиент = `/ip/ipsec/identity`: **Сертификат** (`/certificate add` CN=<имя> → `sign` ca=<CA> → `export-certificate type=pkcs12` → файл `.p12` скачивается с роутера → identity match-by=certificate) или **PSK** (auth-method=pre-shared-key, remote-id+secret). IP: «из пула» (общий mode-config) или статический (персональный `mc-<user>` mode-config с `address=x.x.x.x/32`). Онлайн — `/ip/ipsec/active-peers`.
|
||||||
|
|
||||||
|
Клиентские загрузки: `.p12` (cert+key+CA, passphrase) + strongSwan `.sswan` (генерация на бэкенде) + текстовая инструкция. Перекачка `.p12` — повторный export с новой passphrase (ключ остаётся на роутере).
|
||||||
|
|
||||||
|
## Бэкенд
|
||||||
|
|
||||||
|
1. **`mikrotik.ts`**: `downloadFile(name): Promise<Buffer>` — бинарно-безопасный GET `/rest/file/<name>` (с fallback `flash/<name>`, как у `uploadTextFile`; текущий конвейер парсит JSON как utf8 — нужен Buffer-режим); хелперы `signCertificate` (с поллингом готовности, sign небыстрый), `exportCertificatePkcs12(name, passphrase)`.
|
||||||
|
2. **`services/ipsec-config.ts`** (чистые, тестируемые): генератор `.sswan` и инструкции, поиск свободного IP в пуле, naming-конвенции (`ipsec-ca`, `ipsec-server`, `mc-<user>`, `ipsec-user-<name>`).
|
||||||
|
3. **`services/ipsec-ca.ts`**: `ensureCa/ensureServerCert/issueClientCert/exportClientP12` — add → sign (poll) → export → download.
|
||||||
|
4. **`services/ipsec-ros.ts`**: put/patch/delete для `/ip/ipsec/{peer,identity,mode-config,profile,proposal,policy}`, `/ip/pool`, NAT managed-правило.
|
||||||
|
5. **`services/ipsec-live.ts`**: `fetchIpsecState(server)` — параллельные GET peer/identity/mode-config/pool/active-peers/certificate (+какие из них наши по маркеру/имени) → DTO; `listIpsec()` fan-out с failures; `countIpsecClients()` для сайдбара.
|
||||||
|
6. **`entity-snapshots.ts`**: `canonicalIpsecSnapshot/parseIpsecSnapshot/planIpsecRestore` (секреты через `isHiddenSecret`); `CONFIG_SECTIONS` += `"ipsec"`; enum в `schema.ts` (миграции нет).
|
||||||
|
7. **`routes/ipsec.ts`**: `GET /ipsec` (+observed revision), `POST /ipsec/server/init`, `DELETE /ipsec/server/:serverId` (только managed-объекты), `POST /ipsec/users` (ответ включает одноразовый p12 base64), `PATCH /ipsec/users/:serverId/:rosId` (имя/статический IP/psk), `DELETE /ipsec/users/...` (identity + mc + опционально клиентский cert), `POST /ipsec/users/:serverId/:rosId/cert` (перекачка p12), `GET /ipsec/revisions` + `POST /ipsec/revisions/:id/restore` — всё по образцу `routes/wireguard.ts` (`captureAndAppendRevision`). Регистрация в `index.ts`; `/api/ipsec` в обе группы network-правил `permissions.ts`.
|
||||||
|
8. **Модуль Пользователи**: `InterfaceType` += `"ipsec"` (iface-type.ts, schema.ts enum — TEXT, без миграции); каталог интерфейсов дополняется IPsec-клиентами (identity name + CN в peerPublicKey/peerName) — привязка app-пользователя к VPN-клиенту.
|
||||||
|
|
||||||
|
## Контракт
|
||||||
|
|
||||||
|
`packages/contracts/src/ipsec.ts` (+`package.json` exports, `index.ts`): `ipsecPeerDto/identityDto/modeConfigDto/poolDto/activePeerDto/certInfoDto/serverSummaryDto`, list-response с failures, `initRequest` (CN/SAN, пул, DNS, NAT-чекбокс), `userCreateRequest` (имя, auth: certificate|psk, psk?, ip: static|pool, passphrase, daysValid), `certDownloadResponse {filename, contentB64, mime}`.
|
||||||
|
|
||||||
|
## Фронт
|
||||||
|
|
||||||
|
`app/(main)/ipsec/page.tsx` — «одно окно» по образцу WG-страницы: ServerRail + KPI (серверы IKEv2 / клиенты / онлайн / CA) + Tabs **«Клиенты»** (grid: имя, сервер, аутентификация, IP, онлайн, действия — скачать .p12/.sswan, изменить, удалить; Sheet создания клиента), **«Сервер»** (peer/pool/mode-config/certs + мастер инициализации Stepper + статус NAT-правила), **«CLI»** (шпаргалка). История/restore — `ConfigHistorySheet`. Компоненты `components/ipsec/*` на существующих DataGridShell/form-kit/Sheet; скачивание бинарного p12 — Blob из base64 (по образцу `downloadText`), `.sswan`/инструкция — через `CodeExportSheet`. Сайдбар «IPsec / IKEv2» в «Управление» + бейдж (`sidebar-counts.ts` + mock в `sidebar-badges.ts`) + command-palette. Mock-данные в `lib/data.ts` для демо-режима.
|
||||||
|
|
||||||
|
## Тесты и проверка
|
||||||
|
|
||||||
|
- `ipsec-config.test.ts` (чистые функции: sswan, свободный IP, naming); `entity-snapshots.test.ts` — `planIpsecRestore` + `opsTouchOnly(["/ip/ipsec", ...])`.
|
||||||
|
- `npm run build -w @mmapp/contracts`; `tsc` backend/root; `npm --prefix backend run test:config-sync && test:wireguard` + новые; eslint без новых ошибок.
|
||||||
|
- Демо-режим: визуальная проверка страницы в браузере (как в прошлый раз).
|
||||||
|
|
||||||
|
## Риски (проверить на живом роутере при внедрении)
|
||||||
|
|
||||||
|
- REST-скачивание файла `GET /rest/file/<name>` — еслиRouterOS отдаёт только метаданные, fallback: чтение `contents` маленьких PEM-файлов или сборка p12 из PEM-частей.
|
||||||
|
- Точные имена полей mode-config (`address` vs `address-pool`) сверить с живым GET и адаптировать.
|
||||||
|
- `/certificate/sign` — поллинг готовности с таймаутом ~30–60 с.
|
||||||
|
|
||||||
|
Объём большой; коммит/пуш — по готовности, отдельным `feat(ipsec): …`.
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
|
|
||||||
## ReUI
|
## ReUI
|
||||||
|
|
||||||
Skill: `.agents/skills/reui` (Codex), `.claude/skills/reui`, `.cursor/skills/reui`, `.opencode/skills/reui` — версия `fe6742e29c` (22 free components).
|
Skill: `.claude/skills/reui` / `.cursor/skills/reui` — версия `668fb463eb` (20 free components).
|
||||||
Codex MCP: `.codex/config.toml`, стиль `base-nova`, авторизация через переменную окружения `REUI_LICENSE_KEY`. Для PRO-реестра shadcn тот же ключ хранится в `.env.local` (исключён из Git); реестр настроен в `components.json`. После изменения переменной окружения перезапустить Codex.
|
|
||||||
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills) · [Cursor](https://reui.io/docs/cursor).
|
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills) · [Cursor](https://reui.io/docs/cursor).
|
||||||
Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||||
@@ -219,9 +219,9 @@ Backend держит локальные mmdb-базы MaxMind GeoLite2 (Country
|
|||||||
|
|
||||||
### Версионирование и релизы
|
### Версионирование и релизы
|
||||||
|
|
||||||
- Базовая версия: **`v1.0.0`**. Поддерживаются patch, minor и major; выпуск `2.0.0` инициируется breaking-коммитом, версия вычисляется CI.
|
- Базовая версия: **`v1.0.0`**. Линия semver: `1.x.y` (major `2.x` вне scope).
|
||||||
- Job **`prepare-release`** запускает [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs): коммиты с последнего тега `v*.*.*`, bump по **Conventional Commits**.
|
- Job **`prepare-release`** запускает [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs): коммиты с последнего тега `v*.*.*`, bump по **Conventional Commits**.
|
||||||
- **`!` после типа/scope или footer `BREAKING CHANGE:` / `BREAKING-CHANGE:`** → major (`2.0.0` после `1.x`); **`feat`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
|
- **`feat` / `feat!` / `BREAKING CHANGE`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
|
||||||
- Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:<sha>`.
|
- Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:<sha>`.
|
||||||
- Первый релиз без тега `v1.0.0` возможен автоматически: CI берёт историю `HEAD`, считает bump от `1.0.0` и создаёт тег (например `v1.1.0` при `feat:`).
|
- Первый релиз без тега `v1.0.0` возможен автоматически: CI берёт историю `HEAD`, считает bump от `1.0.0` и создаёт тег (например `v1.1.0` при `feat:`).
|
||||||
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
|
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
|
||||||
@@ -568,7 +568,3 @@ docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
|
|||||||
10. Проверить `curl` на `:8000/health` и `:3000/`.
|
10. Проверить `curl` на `:8000/health` и `:3000/`.
|
||||||
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
|
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
|
||||||
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
|
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
|
||||||
|
|
||||||
### Автоматизация NetFlow через Docker-агент
|
|
||||||
|
|
||||||
[Установка, схема взаимодействия и production-состав](docs/netflow-agent.md).
|
|
||||||
+683
-318
File diff suppressed because it is too large.
Load diff
+28
-13
@@ -8,22 +8,25 @@ import { AsnsDataGrid } from "@/components/data-grids/asns-data-grid"
|
|||||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { asns as mockAsns } from "@/lib/data"
|
import { asns as mockAsns } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
||||||
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function AsnsPage() {
|
export default function AsnsPage() {
|
||||||
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
const { mode } = useDataSource()
|
||||||
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live"
|
const useEvoCatalog = mode === "live" && enabled
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (mode === "mock") return mockAsns
|
if (!useEvoCatalog) return mockAsns
|
||||||
if (loading && !snapshot) return []
|
if (loading && !snapshot) return []
|
||||||
return snapshot?.asns ?? []
|
return snapshot?.asns ?? []
|
||||||
}, [mode, loading, snapshot])
|
}, [useEvoCatalog, loading, snapshot])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search) return rows
|
if (!search) return rows
|
||||||
@@ -32,7 +35,7 @@ export default function AsnsPage() {
|
|||||||
(r) =>
|
(r) =>
|
||||||
r.asn.toLowerCase().includes(q) ||
|
r.asn.toLowerCase().includes(q) ||
|
||||||
r.org.toLowerCase().includes(q) ||
|
r.org.toLowerCase().includes(q) ||
|
||||||
(r.prefixes === null ? "" : String(r.prefixes)).includes(q),
|
String(r.prefixes).includes(q),
|
||||||
)
|
)
|
||||||
}, [rows, search])
|
}, [rows, search])
|
||||||
|
|
||||||
@@ -40,7 +43,8 @@ export default function AsnsPage() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
||||||
actions={mode === "live" ? null : <>
|
actions={
|
||||||
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
<UploadIcon className="size-4" />Импорт
|
<UploadIcon className="size-4" />Импорт
|
||||||
</Button>
|
</Button>
|
||||||
@@ -56,21 +60,32 @@ export default function AsnsPage() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Автономные системы — источники маршрутов для BGP-фильтров
|
Автономные системы — источники маршрутов для BGP-фильтров
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
{useEvoCatalog && (
|
||||||
|
<p className={cn(
|
||||||
|
"text-xs mt-2 flex items-center gap-1.5",
|
||||||
|
error ? "text-destructive" : "text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||||
|
{error
|
||||||
|
? `EvoBGP: ${error}`
|
||||||
|
: snapshot?.fetchedAt
|
||||||
|
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||||
|
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||||
countLabel={known ? `${filtered.length} ASN` : "Количество: —"}
|
countLabel={`${filtered.length} ASN`}
|
||||||
/>
|
/>
|
||||||
{known || loading ? <AsnsDataGrid
|
<AsnsDataGrid
|
||||||
asns={filtered}
|
asns={filtered}
|
||||||
isLoading={useEvoCatalog && loading && !snapshot}
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
pagination={useEvoCatalog}
|
pagination={useEvoCatalog}
|
||||||
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
/>
|
||||||
/> : <EvoBgpCatalogUnavailable />}
|
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+247
-328
@@ -1,11 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data"
|
import type { Backup, Server } from "@/lib/data"
|
||||||
import { useBackupsUrlState } from "@/hooks/use-backups-url-state"
|
import type { Filter } from "@/components/reui/filters"
|
||||||
import { backupStorageSettingsDtoSchema, type BackupScheduleSettingsDto, type BackupStorageSettingsDto } from "@mmapp/contracts/backups"
|
import type { BackupStorageSettingsDto } from "@mmapp/contracts/backups"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -18,13 +17,12 @@ import {
|
|||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { serverListSchema } from "@mmapp/contracts/servers"
|
import { listServers } from "@/shared/api/servers"
|
||||||
import { can } from "@/lib/auth"
|
|
||||||
import { userApiError } from "@/shared/api/users"
|
|
||||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||||
import {
|
import {
|
||||||
|
createBackupsAsync,
|
||||||
deleteBackup,
|
deleteBackup,
|
||||||
downloadBackup,
|
getBackupJob,
|
||||||
getBackupScheduleSettings,
|
getBackupScheduleSettings,
|
||||||
getBackupStorageSettings,
|
getBackupStorageSettings,
|
||||||
listBackups,
|
listBackups,
|
||||||
@@ -35,16 +33,9 @@ import {
|
|||||||
testBackupStorage,
|
testBackupStorage,
|
||||||
type BackupItem,
|
type BackupItem,
|
||||||
} from "@/shared/api/backups"
|
} from "@/shared/api/backups"
|
||||||
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
import { requestBlob } from "@/shared/api/http-client"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useBackupRestoreGuard } from "@/lib/use-backup-restore-guard"
|
|
||||||
import { restoreWasNotApplied } from "@/lib/backup-restore-checkpoint"
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import { useBackupJob } from "@/lib/use-backup-job"
|
|
||||||
import { backupJobOutcome, backupJobResultError } from "@/lib/backup-job-poll"
|
|
||||||
import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
|
|
||||||
import { BackupsHistory } from "@/components/backups/backups-history"
|
import { BackupsHistory } from "@/components/backups/backups-history"
|
||||||
import { backupFileMissing } from "@/lib/backup-file-state"
|
|
||||||
import {
|
import {
|
||||||
BackupsSettings,
|
BackupsSettings,
|
||||||
WEEK_DAYS,
|
WEEK_DAYS,
|
||||||
@@ -57,73 +48,27 @@ import { BackupCreateSheet } from "@/components/backups/backup-create-sheet"
|
|||||||
import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/backup-confirm-dialogs"
|
import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/backup-confirm-dialogs"
|
||||||
|
|
||||||
type PageTab = "history" | "settings"
|
type PageTab = "history" | "settings"
|
||||||
|
type KindFilter = "all" | "auto" | "manual"
|
||||||
|
|
||||||
type Resource = "backups" | "servers" | "schedule" | "storage"
|
export default function BackupsPage() {
|
||||||
type ResourceState = { known: boolean; error: string | null; receivedAt: string | null }
|
const { backendUrl } = useDataSource()
|
||||||
const unknownResource = (): ResourceState => ({ known: false, error: null, receivedAt: null })
|
const [tab, setTab] = useState<PageTab>("history")
|
||||||
|
const [backupList, setBackupList] = useState<Backup[]>([])
|
||||||
export default function BackupsPage() { return <Suspense fallback={<p role="status" className="p-5">Загрузка контекста резервных копий…</p>}><BackupsSource /></Suspense> }
|
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
||||||
function BackupsSource() {
|
const [search, setSearch] = useState("")
|
||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||||
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
return <BackupsContent key={mode + ":" + backendUrl} />
|
|
||||||
}
|
|
||||||
function BackupsContent() {
|
|
||||||
const { mode, backendUrl } = useDataSource()
|
|
||||||
const isLive = mode === "live"
|
|
||||||
const canRead = can("mm:backups:read")
|
|
||||||
const canWrite = isLive && can("mm:backups:write")
|
|
||||||
const canReadServers = can("mm:servers:read")
|
|
||||||
const [resources, setResources] = useState<Record<Resource, ResourceState>>(() => ({ backups: unknownResource(), servers: unknownResource(), schedule: unknownResource(), storage: unknownResource() }))
|
|
||||||
const dialogTrigger = useRef<{ element: HTMLElement; rowId: string | null; label: string | null } | null>(null)
|
|
||||||
const refreshTrigger = useRef<HTMLButtonElement | null>(null)
|
|
||||||
const captureDialogFocus = () => {
|
|
||||||
const element = document.activeElement
|
|
||||||
dialogTrigger.current = element instanceof HTMLElement
|
|
||||||
? { element, rowId: element.closest("[data-row-id]")?.getAttribute("data-row-id") ?? null, label: element.getAttribute("aria-label") }
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
const returnDialogFocus = () => {
|
|
||||||
const trigger = dialogTrigger.current
|
|
||||||
if (trigger?.element.isConnected) return trigger.element
|
|
||||||
// Cell renderers can remount when operation state changes; resolve the same row/action.
|
|
||||||
if (trigger?.rowId && trigger.label) {
|
|
||||||
const replacement = document.querySelector<HTMLElement>(`[data-row-id="${CSS.escape(trigger.rowId)}"] button[aria-label="${CSS.escape(trigger.label)}"]`)
|
|
||||||
if (replacement) return replacement
|
|
||||||
}
|
|
||||||
return refreshTrigger.current
|
|
||||||
}
|
|
||||||
const reader = useRef<AbortController | null>(null)
|
|
||||||
const revision = useRef(0)
|
|
||||||
const alive = useRef(true)
|
|
||||||
const scheduleDirty = useRef(false)
|
|
||||||
const storageDirty = useRef(false)
|
|
||||||
const operationLock = useRef(false)
|
|
||||||
const operationController = useRef<AbortController | null>(null)
|
|
||||||
const downloadControllers = useRef(new Map<string, AbortController>())
|
|
||||||
const settingsLock = useRef(false)
|
|
||||||
const settingsController = useRef<AbortController | null>(null)
|
|
||||||
const known = (resource: Resource) => !isLive || resources[resource].known
|
|
||||||
const current = (resource: Resource) => known(resource) && !resources[resource].error
|
|
||||||
const { tab, setTab, kindFilter, setKindFilter, search, setSearch, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, reset: resetContext } = useBackupsUrlState()
|
|
||||||
const [backupList, setBackupList] = useState<Backup[]>(isLive ? [] : mockBackups)
|
|
||||||
const [liveServers, setLiveServers] = useState<Server[]>(isLive ? [] : mockServers)
|
|
||||||
const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage")
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const restoreGuard = useBackupRestoreGuard(backendUrl, isLive)
|
|
||||||
const [restoreReviewed, setRestoreReviewed] = useState(false)
|
|
||||||
const [opBusy, setOpBusy] = useState(false)
|
const [opBusy, setOpBusy] = useState(false)
|
||||||
const [opError, setOpError] = useState<string | null>(null)
|
const [opError, setOpError] = useState<string | null>(null)
|
||||||
|
const [backupJobId, setBackupJobId] = useState<string | null>(null)
|
||||||
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
||||||
const [testBusy, setTestBusy] = useState(false)
|
const [testBusy, setTestBusy] = useState(false)
|
||||||
const [syncBusy, setSyncBusy] = useState(false)
|
const [syncBusy, setSyncBusy] = useState(false)
|
||||||
const [schedule, setSchedule] = useState<BackupScheduleForm>(defaultSchedule)
|
const [schedule, setSchedule] = useState<BackupScheduleForm>(defaultSchedule)
|
||||||
const [storage, setStorage] = useState<BackupStorageForm>(defaultStorageForm)
|
const [storage, setStorage] = useState<BackupStorageForm>(defaultStorageForm)
|
||||||
const [storageIsDirty, setStorageIsDirty] = useState(false)
|
|
||||||
const [savedSchedule, setSavedSchedule] = useState<BackupScheduleSettingsDto | null>(null)
|
|
||||||
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
|
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
|
||||||
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
|
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
|
||||||
const [allEnabled, setAllEnabled] = useState(true)
|
|
||||||
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set())
|
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set())
|
||||||
const [manualOpen, setManualOpen] = useState(false)
|
const [manualOpen, setManualOpen] = useState(false)
|
||||||
const [manualStep, setManualStep] = useState(1)
|
const [manualStep, setManualStep] = useState(1)
|
||||||
@@ -132,224 +77,256 @@ function BackupsContent() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null)
|
||||||
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
|
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
|
||||||
|
|
||||||
const setSched = <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => { scheduleDirty.current = true; setSchedule(s => ({ ...s, [k]: v })) }
|
const setSched = <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) =>
|
||||||
const setStore = <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) => { if (k !== "showPassword") { storageDirty.current = true; setStorageIsDirty(true) } setStorage(s => ({ ...s, [k]: v })) }
|
setSchedule((s) => ({ ...s, [k]: v }))
|
||||||
|
const setStore = <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) =>
|
||||||
|
setStorage((s) => ({ ...s, [k]: v }))
|
||||||
|
|
||||||
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
|
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
|
||||||
const kb = Math.round(b.sizeBytes / 1024)
|
const kb = Math.max(1, Math.round(b.sizeBytes / 1024))
|
||||||
return {
|
return {
|
||||||
id: b.id,
|
id: b.id,
|
||||||
server: b.serverName,
|
server: b.serverName,
|
||||||
serverId: b.serverId == null ? null : String(b.serverId),
|
serverId: b.serverId == null ? null : String(b.serverId),
|
||||||
filename: b.filename,
|
filename: b.filename,
|
||||||
size: b.sizeBytes < 1024 ? `${b.sizeBytes} Б` : `${kb} КБ`,
|
size: `${kb} КБ`,
|
||||||
sizeBytes: b.sizeBytes,
|
|
||||||
created: new Date(b.createdAt).toLocaleString("ru-RU"),
|
created: new Date(b.createdAt).toLocaleString("ru-RU"),
|
||||||
kind: b.kind,
|
kind: b.kind,
|
||||||
notes: b.notes ?? "",
|
notes: b.notes ?? "",
|
||||||
storage: b.storage,
|
storage: b.storage ?? "local",
|
||||||
uploadError: b.uploadError ?? undefined,
|
uploadError: b.uploadError ?? undefined,
|
||||||
localFileStatus: b.localFileStatus,
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadLive = useCallback(async () => {
|
const loadLive = useCallback(async () => {
|
||||||
if (!isLive || !canRead || !alive.current) return
|
|
||||||
reader.current?.abort()
|
|
||||||
const abort = new AbortController(), version = ++revision.current
|
|
||||||
reader.current = abort
|
|
||||||
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)])
|
|
||||||
const active = () => alive.current && !abort.signal.aborted && revision.current === version
|
|
||||||
const success = (resource: Resource) => { if (active()) setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } })) }
|
|
||||||
const failure = (resource: Resource, error: unknown) => { if (active()) setResources(prev => ({ ...prev, [resource]: { ...prev[resource], error: userApiError(error) } })) }
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
await Promise.allSettled([
|
setOpError(null)
|
||||||
listBackups(backendUrl, { signal }).then(rows => { if (!active()) return; setBackupList(rows.map(mapApiBackupToUi)); success("backups") }).catch(error => failure("backups", error)),
|
try {
|
||||||
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
|
const [serversRows, backupsRows, scheduleRow, storageRow] = await Promise.all([
|
||||||
const rows = serverListSchema.parse(payload)
|
listServers(backendUrl),
|
||||||
if (!active()) return
|
listBackups(backendUrl),
|
||||||
setLiveServers(rows.map(row => ({ ...toFrontendServer(row), status: row.status ?? "unknown" }))); success("servers")
|
getBackupScheduleSettings(backendUrl),
|
||||||
}).catch(error => failure("servers", error)) : Promise.resolve(),
|
getBackupStorageSettings(backendUrl),
|
||||||
getBackupScheduleSettings(backendUrl, { signal }).then(row => {
|
])
|
||||||
if (!active()) return
|
const mappedServers = serversRows.map((s) => toFrontendServer(s))
|
||||||
if (!scheduleDirty.current) { setSchedule(row); setSelectedServers(new Set(row.serverIds)); setAllEnabled(row.serverIds.length === 0) }
|
setLiveServers(mappedServers)
|
||||||
setSavedSchedule(row); setScheduleLastError(row.lastError ?? null); success("schedule")
|
setBackupList(backupsRows.map(mapApiBackupToUi))
|
||||||
}).catch(error => failure("schedule", error)),
|
setSchedule({
|
||||||
getBackupStorageSettings(backendUrl, { signal }).then(row => {
|
enabled: scheduleRow.enabled,
|
||||||
if (!active()) return
|
frequency: scheduleRow.frequency,
|
||||||
setSavedStorage(row)
|
hour: scheduleRow.hour,
|
||||||
if (!storageDirty.current) setStorage(prev => ({ ...prev, ...row, s3SecretAccessKey: "" }))
|
minute: scheduleRow.minute,
|
||||||
success("storage")
|
weekDay: scheduleRow.weekDay,
|
||||||
}).catch(error => failure("storage", error)),
|
monthDay: scheduleRow.monthDay,
|
||||||
])
|
keepCount: scheduleRow.keepCount,
|
||||||
if (active()) setLoading(false)
|
format: scheduleRow.format,
|
||||||
}, [backendUrl, isLive, canRead, canReadServers, mapApiBackupToUi])
|
})
|
||||||
|
setSavedStorage(storageRow)
|
||||||
|
setScheduleLastError(scheduleRow.lastError ?? null)
|
||||||
|
setStorage((prev) => ({
|
||||||
|
...prev,
|
||||||
|
provider: storageRow.provider,
|
||||||
|
s3Endpoint: storageRow.s3Endpoint,
|
||||||
|
s3Region: storageRow.s3Region,
|
||||||
|
s3Bucket: storageRow.s3Bucket,
|
||||||
|
s3Prefix: storageRow.s3Prefix,
|
||||||
|
s3AccessKeyId: storageRow.s3AccessKeyId,
|
||||||
|
s3SecretAccessKey: "",
|
||||||
|
s3ForcePathStyle: storageRow.s3ForcePathStyle,
|
||||||
|
keepLocalCopy: storageRow.keepLocalCopy,
|
||||||
|
}))
|
||||||
|
const serverIds = scheduleRow.serverIds.length > 0
|
||||||
|
? scheduleRow.serverIds
|
||||||
|
: mappedServers.map((s) => s.id)
|
||||||
|
setSelectedServers(new Set(serverIds.filter((id) => mappedServers.some((s) => s.id === id))))
|
||||||
|
} catch (e) {
|
||||||
|
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [backendUrl, mapApiBackupToUi])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
alive.current = true
|
void loadLive()
|
||||||
const downloads = downloadControllers.current
|
|
||||||
queueMicrotask(() => { if (alive.current) void loadLive() })
|
|
||||||
return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort(); operationController.current?.abort(); downloads.forEach(controller => controller.abort()) }
|
|
||||||
}, [loadLive])
|
}, [loadLive])
|
||||||
|
|
||||||
const job = useBackupJob(backendUrl, isLive, canRead, baseCanCreate && !opBusy && !scheduleSaveBusy, loadLive)
|
|
||||||
const checkpoint = job.checkpoint
|
|
||||||
const jobTerminal = !!checkpoint?.job && ["done", "failed"].includes(checkpoint.job.status)
|
|
||||||
const backupJobId = checkpoint && !jobTerminal ? checkpoint.request.requestId : null
|
|
||||||
const persistedJobError = checkpoint?.job && jobTerminal ? backupJobResultError(checkpoint.job) : null
|
|
||||||
const canCreate = baseCanCreate && job.hydrated && !checkpoint && !job.storageError
|
|
||||||
|
|
||||||
function startSettingsMutation() {
|
|
||||||
if (!alive.current || settingsLock.current || operationLock.current || backupJobId) return null
|
|
||||||
settingsLock.current = true
|
|
||||||
const abort = new AbortController()
|
|
||||||
settingsController.current = abort
|
|
||||||
reader.current?.abort(); revision.current += 1; setLoading(false)
|
|
||||||
setScheduleSaveBusy(true); setOpError(null)
|
|
||||||
return abort
|
|
||||||
}
|
|
||||||
function finishSettingsMutation(abort: AbortController) {
|
|
||||||
if (settingsController.current !== abort) return
|
|
||||||
settingsLock.current = false; settingsController.current = null
|
|
||||||
if (alive.current) { setScheduleSaveBusy(false); setTestBusy(false); setSyncBusy(false) }
|
|
||||||
}
|
|
||||||
function settingsConfirmed(resource: Resource) {
|
|
||||||
setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
|
|
||||||
}
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!canWrite || !current("schedule") || !current("servers") || !canReadServers) return
|
if (scheduleSaveBusy) return
|
||||||
const abort = startSettingsMutation(); if (!abort) return
|
setScheduleSaveBusy(true)
|
||||||
|
setOpError(null)
|
||||||
try {
|
try {
|
||||||
const payload = backupSchedulePayload(schedule, [...selectedServers], allEnabled)
|
await putBackupScheduleSettings(backendUrl, {
|
||||||
if (payload.serverIds?.some(id => !liveServers.some(server => server.id === id))) throw new Error("Выбранный сервер отсутствует; обновите или измените выбор")
|
enabled: schedule.enabled,
|
||||||
const result = await putBackupScheduleSettings(backendUrl, payload, { signal: abort.signal })
|
frequency: schedule.frequency,
|
||||||
if (!alive.current || abort.signal.aborted) return
|
hour: schedule.hour,
|
||||||
setSavedSchedule(result); setSchedule(result); setScheduleLastError(result.lastError ?? null); scheduleDirty.current = false
|
minute: schedule.minute,
|
||||||
setSelectedServers(new Set(result.serverIds)); setAllEnabled(result.serverIds.length === 0); settingsConfirmed("schedule")
|
weekDay: schedule.weekDay,
|
||||||
toast.success("Расписание сохранено")
|
monthDay: schedule.monthDay,
|
||||||
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
keepCount: schedule.keepCount,
|
||||||
finally { finishSettingsMutation(abort) }
|
format: "rsc",
|
||||||
}
|
serverIds: [...selectedServers],
|
||||||
async function handleSaveStorage() {
|
})
|
||||||
if (!canWrite || !current("storage")) return
|
const saved = await putBackupStorageSettings(backendUrl, {
|
||||||
const abort = startSettingsMutation(); if (!abort) return
|
provider: storage.provider,
|
||||||
try {
|
s3Endpoint: storage.s3Endpoint,
|
||||||
const result = await putBackupStorageSettings(backendUrl, backupStoragePayload(storage, savedStorage?.secretConfigured), { signal: abort.signal })
|
s3Region: storage.s3Region,
|
||||||
if (!alive.current || abort.signal.aborted) return
|
s3Bucket: storage.s3Bucket,
|
||||||
setSavedStorage(result); setStorage(prev => ({ ...prev, ...result, s3SecretAccessKey: "" })); storageDirty.current = false; setStorageIsDirty(false); settingsConfirmed("storage")
|
s3Prefix: storage.s3Prefix,
|
||||||
toast.success("Хранилище сохранено")
|
s3AccessKeyId: storage.s3AccessKeyId,
|
||||||
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
s3SecretAccessKey: storage.s3SecretAccessKey || undefined,
|
||||||
finally { finishSettingsMutation(abort) }
|
s3ForcePathStyle: storage.s3ForcePathStyle,
|
||||||
|
keepLocalCopy: storage.keepLocalCopy,
|
||||||
|
})
|
||||||
|
setSavedStorage(saved)
|
||||||
|
setStorage((prev) => ({ ...prev, s3SecretAccessKey: "" }))
|
||||||
|
toast.success("Настройки сохранены")
|
||||||
|
} catch (e) {
|
||||||
|
setOpError(e instanceof Error ? e.message : "Ошибка сохранения настроек")
|
||||||
|
} finally {
|
||||||
|
setScheduleSaveBusy(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTestStorage() {
|
async function handleTestStorage() {
|
||||||
if (!canWrite || !current("storage")) return
|
|
||||||
if (storageDirty.current) { setOpError("Сначала сохраните хранилище; проверка использует подтверждённые настройки"); return }
|
|
||||||
if (savedStorage?.provider !== "s3") { setOpError("Проверка доступна для сохранённого хранилища S3"); return }
|
|
||||||
const abort = startSettingsMutation(); if (!abort) return
|
|
||||||
setTestBusy(true)
|
setTestBusy(true)
|
||||||
try {
|
try {
|
||||||
const result = await testBackupStorage(backendUrl, { signal: abort.signal })
|
await putBackupStorageSettings(backendUrl, {
|
||||||
if (!alive.current || abort.signal.aborted) return
|
provider: storage.provider,
|
||||||
setSavedStorage(result); settingsConfirmed("storage")
|
s3Endpoint: storage.s3Endpoint,
|
||||||
if (result.lastTestError || !result.lastTestAt || result.provider !== "s3") throw new Error(result.lastTestError || "Проверка S3 не подтверждена")
|
s3Region: storage.s3Region,
|
||||||
toast.success("Доступ к S3 подтверждён")
|
s3Bucket: storage.s3Bucket,
|
||||||
} catch (error) {
|
s3Prefix: storage.s3Prefix,
|
||||||
if (alive.current && !abort.signal.aborted) {
|
s3AccessKeyId: storage.s3AccessKeyId,
|
||||||
if (error instanceof ApiClientError && error.payload && typeof error.payload === "object" && "settings" in error.payload) {
|
s3SecretAccessKey: storage.s3SecretAccessKey || undefined,
|
||||||
const parsed = backupStorageSettingsDtoSchema.safeParse(error.payload.settings)
|
s3ForcePathStyle: storage.s3ForcePathStyle,
|
||||||
if (parsed.success) setSavedStorage(parsed.data)
|
keepLocalCopy: storage.keepLocalCopy,
|
||||||
}
|
})
|
||||||
setOpError(userApiError(error))
|
const result = await testBackupStorage(backendUrl)
|
||||||
|
setSavedStorage(result)
|
||||||
|
toast.success("S3 доступен")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Ошибка проверки S3")
|
||||||
|
try {
|
||||||
|
setSavedStorage(await getBackupStorageSettings(backendUrl))
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
setTestBusy(false)
|
||||||
}
|
}
|
||||||
finally { finishSettingsMutation(abort) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSyncStorage() {
|
async function handleSyncStorage() {
|
||||||
if (!canWrite || !current("storage")) return
|
|
||||||
if (storageDirty.current) { setOpError("Сначала сохраните хранилище; синхронизация использует подтверждённые настройки"); return }
|
|
||||||
if (savedStorage?.provider !== "s3") return
|
|
||||||
const abort = startSettingsMutation(); if (!abort) return
|
|
||||||
setSyncBusy(true)
|
setSyncBusy(true)
|
||||||
try {
|
try {
|
||||||
const result = await syncBackupsFromStorage(backendUrl, { signal: abort.signal })
|
const result = await syncBackupsFromStorage(backendUrl)
|
||||||
if (!alive.current || abort.signal.aborted) return
|
toast.success(`Импортировано: ${result.imported}, пропущено: ${result.skipped}`)
|
||||||
toast.success("Импортировано: " + result.imported + ", пропущено: " + result.skipped)
|
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
} catch (e) {
|
||||||
finally { finishSettingsMutation(abort) }
|
toast.error(e instanceof Error ? e.message : "Ошибка синхронизации")
|
||||||
|
} finally {
|
||||||
|
setSyncBusy(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleManualBackup() {
|
async function handleManualBackup() {
|
||||||
if (checkpoint) { await job.run(); return }
|
if (manualServers.size === 0 || opBusy) return
|
||||||
if (!canCreate || !manualServers.size || settingsLock.current || opBusy) return
|
setOpError(null)
|
||||||
if ([...manualServers].some(id => !liveServers.some(server => server.id === id))) { setOpError("Выбранный сервер отсутствует; обновите выбор"); return }
|
try {
|
||||||
await job.run({ serverIds: [...manualServers], notes: manualNotes || undefined })
|
const res = await createBackupsAsync(backendUrl, {
|
||||||
|
serverIds: [...manualServers],
|
||||||
|
notes: manualNotes || undefined,
|
||||||
|
})
|
||||||
|
setBackupJobId(res.jobId)
|
||||||
|
setManualOpen(false)
|
||||||
|
setManualServers(new Set())
|
||||||
|
setManualNotes("")
|
||||||
|
setTab("history")
|
||||||
|
} catch (e) {
|
||||||
|
setOpError(e instanceof Error ? e.message : "Ошибка создания бэкапа")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!backupJobId) return
|
||||||
|
toast.info("Бэкап выполняется в фоне...")
|
||||||
|
let cancelled = false
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const job = await getBackupJob(backendUrl, backupJobId)
|
||||||
|
if (cancelled) return
|
||||||
|
if (job.status === "done") {
|
||||||
|
clearInterval(timer)
|
||||||
|
setBackupJobId(null)
|
||||||
|
if (job.failures.length > 0) {
|
||||||
|
setOpError(`Часть бэкапов не создалась: ${job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ")}`)
|
||||||
|
} else {
|
||||||
|
toast.success("Бэкап успешно завершён")
|
||||||
|
}
|
||||||
|
await loadLive()
|
||||||
|
} else if (job.status === "failed") {
|
||||||
|
clearInterval(timer)
|
||||||
|
setBackupJobId(null)
|
||||||
|
setOpError(job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ") || "Ошибка фоновой задачи бэкапа")
|
||||||
|
await loadLive()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
clearInterval(timer)
|
||||||
|
setBackupJobId(null)
|
||||||
|
setOpError(e instanceof Error ? e.message : "Ошибка опроса задачи бэкапа")
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, 1200)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, [backendUrl, backupJobId, loadLive])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (opError) toast.error(opError)
|
if (opError) toast.error(opError)
|
||||||
}, [opError])
|
}, [opError])
|
||||||
|
|
||||||
function startOperation() {
|
|
||||||
if (!canWrite || !alive.current || operationLock.current || settingsLock.current || backupJobId) return null
|
|
||||||
operationLock.current = true
|
|
||||||
const abort = new AbortController(); operationController.current = abort
|
|
||||||
reader.current?.abort(); revision.current += 1; setLoading(false)
|
|
||||||
setOpBusy(true); setOpError(null)
|
|
||||||
return abort
|
|
||||||
}
|
|
||||||
function finishOperation(abort: AbortController) {
|
|
||||||
if (operationController.current !== abort) return
|
|
||||||
operationLock.current = false; operationController.current = null
|
|
||||||
if (alive.current) setOpBusy(false)
|
|
||||||
}
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
const target = deleteTarget, abort = startOperation(); if (!abort) return
|
setOpBusy(true)
|
||||||
|
setOpError(null)
|
||||||
try {
|
try {
|
||||||
await deleteBackup(backendUrl, target.id, { signal: abort.signal }, backupFileMissing(target))
|
await deleteBackup(backendUrl, deleteTarget.id)
|
||||||
if (!alive.current || abort.signal.aborted) return
|
setDeleteTarget(null)
|
||||||
setBackupList(previous => previous.filter(backup => backup.id !== target.id)); setDeleteTarget(null)
|
|
||||||
toast.success("API подтвердил удаление резервной копии")
|
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
} catch (e) {
|
||||||
finally { finishOperation(abort) }
|
setOpError(e instanceof Error ? e.message : "Ошибка удаления бэкапа")
|
||||||
|
} finally {
|
||||||
|
setOpBusy(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRestore() {
|
async function handleRestore() {
|
||||||
if (!restoreTarget || !restoreTarget.serverId || !restoreGuard.canStart) return
|
if (!restoreTarget) return
|
||||||
if (backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget)) {
|
setOpBusy(true)
|
||||||
setOpError("Локальный файл отсутствует. Создайте новую резервную копию.")
|
setOpError(null)
|
||||||
return
|
|
||||||
}
|
|
||||||
const target = restoreTarget, abort = startOperation(); if (!abort) return
|
|
||||||
let sent = false
|
|
||||||
try {
|
try {
|
||||||
if (!restoreGuard.begin({ backupId: target.id, serverId: Number(target.serverId), filename: target.filename, serverName: target.server })) return
|
await restoreBackup(backendUrl, restoreTarget.id)
|
||||||
sent = true
|
toast.success(`Конфигурация восстановлена на ${restoreTarget.server}`)
|
||||||
const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal }, Number(target.serverId))
|
setRestoreTarget(null)
|
||||||
if (!alive.current || abort.signal.aborted) return
|
} catch (e) {
|
||||||
if (result.filename !== target.filename) throw new Error("Ответ восстановления относится к другому файлу")
|
setOpError(e instanceof Error ? e.message : "Ошибка восстановления")
|
||||||
toast.success("API подтвердил импорт " + result.filename + " на " + result.serverName)
|
} finally {
|
||||||
restoreGuard.clear(); setRestoreTarget(null)
|
setOpBusy(false)
|
||||||
} catch (error) {
|
|
||||||
if (alive.current && !abort.signal.aborted) {
|
|
||||||
if (sent && restoreWasNotApplied(error)) restoreGuard.clear()
|
|
||||||
setOpError(userApiError(error) + (sent && !restoreWasNotApplied(error) ? ". Результат импорта неизвестен; проверьте роутер перед повтором" : ""))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally { finishOperation(abort) }
|
|
||||||
}
|
}
|
||||||
async function handleDownload(id: string, filename: string) {
|
|
||||||
if (!isLive || !canRead || !alive.current || downloadControllers.current.has(id)) return
|
async function handleDownload(id: string, fallbackFilename: string) {
|
||||||
const abort = new AbortController(); downloadControllers.current.set(id, abort)
|
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
|
||||||
try {
|
const blob = await res.blob()
|
||||||
const blob = await downloadBackup(backendUrl, id, { signal: abort.signal })
|
const url = URL.createObjectURL(blob)
|
||||||
if (!alive.current || abort.signal.aborted) return
|
const a = document.createElement("a")
|
||||||
const url = URL.createObjectURL(blob)
|
a.href = url
|
||||||
try {
|
a.download = fallbackFilename
|
||||||
const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click()
|
a.click()
|
||||||
} finally { URL.revokeObjectURL(url) }
|
URL.revokeObjectURL(url)
|
||||||
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
|
||||||
finally { if (downloadControllers.current.get(id) === abort) downloadControllers.current.delete(id) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoCount = backupList.filter((b) => b.kind === "auto").length
|
const autoCount = backupList.filter((b) => b.kind === "auto").length
|
||||||
@@ -357,21 +334,19 @@ function BackupsContent() {
|
|||||||
const s3Count = backupList.filter((b) => b.storage === "s3" || b.storage === "both").length
|
const s3Count = backupList.filter((b) => b.storage === "s3" || b.storage === "both").length
|
||||||
|
|
||||||
const schedSummary = (() => {
|
const schedSummary = (() => {
|
||||||
const confirmed = isLive ? savedSchedule : defaultSchedule
|
if (!schedule.enabled) return "Расписание отключено"
|
||||||
if (!confirmed) return "Расписание пока неизвестно"
|
const t = `${String(schedule.hour).padStart(2, "0")}:${String(schedule.minute).padStart(2, "0")}`
|
||||||
if (!confirmed.enabled) return "Расписание отключено"
|
if (schedule.frequency === "daily") return `Каждый день в ${t}`
|
||||||
const time = String(confirmed.hour).padStart(2, "0") + ":" + String(confirmed.minute).padStart(2, "0")
|
if (schedule.frequency === "weekly") return `Каждую неделю (${WEEK_DAYS[schedule.weekDay]}) в ${t}`
|
||||||
if (confirmed.frequency === "daily") return "Каждый день в " + time
|
return `${schedule.monthDay}-го числа каждого месяца в ${t}`
|
||||||
if (confirmed.frequency === "weekly") return "Каждую неделю (" + WEEK_DAYS[Number(confirmed.weekDay)] + ") в " + time
|
|
||||||
return confirmed.monthDay + "-го числа каждого месяца в " + time
|
|
||||||
})()
|
})()
|
||||||
const confirmedStorage = isLive ? savedStorage : defaultStorageForm
|
|
||||||
const destinationLabel = !confirmedStorage ? "Хранилище пока неизвестно" : confirmedStorage.provider === "s3"
|
const destinationLabel = storage.provider === "s3"
|
||||||
? (confirmedStorage.keepLocalCopy ? "локально и в S3" : "в S3")
|
? (storage.keepLocalCopy ? "локально и в S3" : "в S3")
|
||||||
: "локально (storage/backups)"
|
: "локально (storage/backups)"
|
||||||
|
|
||||||
const statusDetail = [
|
const statusDetail = [
|
||||||
backupJobId ? null : `${destinationLabel} · ${known("servers") ? liveServers.length + " серверов в каталоге" : "Серверы пока неизвестны"}`,
|
backupJobId ? null : `${destinationLabel} · ${selectedServers.size} из ${liveServers.length} серверов`,
|
||||||
scheduleLastError,
|
scheduleLastError,
|
||||||
savedStorage?.lastTestError,
|
savedStorage?.lastTestError,
|
||||||
].filter(Boolean).join(" · ")
|
].filter(Boolean).join(" · ")
|
||||||
@@ -383,8 +358,6 @@ function BackupsContent() {
|
|||||||
: "default"
|
: "default"
|
||||||
|
|
||||||
function openCreate(all: boolean) {
|
function openCreate(all: boolean) {
|
||||||
if (checkpoint) { setManualStep(3); setManualOpen(true); return }
|
|
||||||
if (!canCreate) return
|
|
||||||
setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set())
|
setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set())
|
||||||
setManualNotes("")
|
setManualNotes("")
|
||||||
setManualStep(1)
|
setManualStep(1)
|
||||||
@@ -392,7 +365,7 @@ function BackupsContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 min-w-0 flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
|
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
|
||||||
actions={
|
actions={
|
||||||
@@ -402,7 +375,7 @@ function BackupsContent() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openCreate(true)}
|
onClick={() => openCreate(true)}
|
||||||
disabled={!canCreate || loading || job.busy}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="size-4" />
|
<RefreshCwIcon className="size-4" />
|
||||||
Снять со всех
|
Снять со всех
|
||||||
@@ -411,69 +384,45 @@ function BackupsContent() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openCreate(false)}
|
onClick={() => openCreate(false)}
|
||||||
disabled={checkpoint ? !canRead : !canCreate || loading || job.busy}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
{checkpoint ? "Задача резервного копирования" : "Новый бэкап"}
|
Новый бэкап
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{urlError && <Alert variant="warning" aria-label="Контекст резервных копий"><AlertTitle>Не удалось прочитать ссылку</AlertTitle><AlertDescription>{urlError}</AlertDescription><AlertAction><Button type="button" variant="outline" onClick={resetContext}>Сбросить контекст</Button></AlertAction></Alert>}
|
|
||||||
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демонстрационные данные. Операции и сохранение недоступны.</p>}
|
|
||||||
{!canRead && <p role="status">Нет права mm:backups:read</p>}
|
|
||||||
{isLive && !canReadServers && <p role="status">Каталог устройств недоступен без mm:servers:read.</p>}
|
|
||||||
{isLive && canRead && <div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
|
||||||
<Button type="button" variant="outline" size="sm" ref={refreshTrigger} onClick={() => void loadLive()} disabled={loading || scheduleSaveBusy || opBusy}>Обновить</Button>
|
|
||||||
<span role="status">{loading ? "Обновление данных…" : resources.backups.receivedAt ? "Получено: " + new Date(resources.backups.receivedAt).toLocaleString("ru-RU") : "Резервные копии пока неизвестны"}</span>
|
|
||||||
</div>}
|
|
||||||
{isLive && (Object.keys(resources) as Resource[]).filter(resource => resources[resource].error).map(resource => <Alert key={resource} variant="warning" aria-label={"Данные резервных копий: " + resource}>
|
|
||||||
<AlertTitle>{resources[resource].known ? "Последние данные сохранены" : "Данные пока неизвестны"}</AlertTitle>
|
|
||||||
<AlertDescription>{({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error}</AlertDescription>
|
|
||||||
</Alert>)}
|
|
||||||
|
|
||||||
{isLive && (restoreGuard.checkpoint || restoreGuard.error) && <Alert variant="warning" aria-label="Подтверждение восстановления">
|
|
||||||
<AlertTitle>{opBusy && restoreTarget ? "Импорт ожидает подтверждения" : "Проверьте результат восстановления"}</AlertTitle>
|
|
||||||
<AlertDescription className="flex flex-col gap-3">
|
|
||||||
<span>{restoreGuard.error || (restoreGuard.checkpoint ? restoreGuard.checkpoint.filename + " → " + restoreGuard.checkpoint.serverName + ". Ответ ещё не подтверждён. Закрытие страницы не отменяет импорт." : "")}</span>
|
|
||||||
<label className="flex items-start gap-2"><Checkbox checked={restoreReviewed} disabled={opBusy || !canWrite} onCheckedChange={value => setRestoreReviewed(value === true)} />Я проверил состояние роутера и понимаю последствия повторного импорта</label>
|
|
||||||
<Button type="button" variant="outline" disabled={!restoreReviewed || opBusy || !canWrite} onClick={() => { if (restoreGuard.clear()) { setRestoreReviewed(false); setOpError(null) } }}>Разрешить новое восстановление</Button>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>}
|
|
||||||
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
|
|
||||||
{(job.error || job.storageError || persistedJobError) && <Alert variant="warning" aria-label="Задача резервного копирования"><AlertTitle>{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}</AlertTitle><AlertDescription>{job.error || job.storageError || persistedJobError}</AlertDescription></Alert>}
|
|
||||||
<div><h1 className="text-xl font-semibold">Резервные копии</h1><p className="text-sm text-muted-foreground">История файлов, расписание и подтверждения операций</p></div>
|
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка бэкапов"
|
aria-label="Сводка бэкапов"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
id: "all",
|
id: "all",
|
||||||
label: "Всего бэкапов",
|
label: "Всего бэкапов",
|
||||||
value: known("backups") ? backupList.length : "—",
|
value: backupList.length,
|
||||||
icon: <HardDriveIcon className="size-4" />,
|
icon: <HardDriveIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "auto",
|
id: "auto",
|
||||||
label: "Авто",
|
label: "Авто",
|
||||||
value: known("backups") ? autoCount : "—",
|
value: autoCount,
|
||||||
icon: <ClockIcon className="size-4" />,
|
icon: <ClockIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "manual",
|
id: "manual",
|
||||||
label: "Вручную",
|
label: "Вручную",
|
||||||
value: known("backups") ? manualCount : "—",
|
value: manualCount,
|
||||||
icon: <PlusIcon className="size-4" />,
|
icon: <PlusIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "s3",
|
id: "s3",
|
||||||
label: "В S3",
|
label: "В S3",
|
||||||
value: known("backups") ? s3Count : "—",
|
value: s3Count,
|
||||||
icon: <CloudIcon className="size-4" />,
|
icon: <CloudIcon className="size-4" />,
|
||||||
iconClassName: "text-primary",
|
iconClassName: "text-primary",
|
||||||
},
|
},
|
||||||
@@ -483,11 +432,11 @@ function BackupsContent() {
|
|||||||
<Alert variant={alertVariant}>
|
<Alert variant={alertVariant}>
|
||||||
<ClockIcon />
|
<ClockIcon />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
{backupJobId ? (checkpoint?.accepted ? "Задача наблюдается" : "Результат запроса пока неизвестен") : schedSummary}
|
{backupJobId ? "Задача выполняется" : schedSummary}
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{backupJobId
|
{backupJobId
|
||||||
? "Закрытие формы не отменяет задачу. Проверка продолжает чтение того же ID."
|
? "Снимок конфигурации снимается в фоне. Список обновится после завершения."
|
||||||
: statusDetail}
|
: statusDetail}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
<AlertAction>
|
<AlertAction>
|
||||||
@@ -497,7 +446,6 @@ function BackupsContent() {
|
|||||||
</AlertAction>
|
</AlertAction>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Frame dense className="min-w-0"><FramePanel className="min-w-0 p-3 sm:p-5">
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={(v) => setTab(String(v) as PageTab)}
|
onValueChange={(v) => setTab(String(v) as PageTab)}
|
||||||
@@ -508,7 +456,7 @@ function BackupsContent() {
|
|||||||
<TabsTrigger value="settings">Настройки</TabsTrigger>
|
<TabsTrigger value="settings">Настройки</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="history" className="flex flex-col gap-5">
|
<TabsContent value="history" className="flex flex-col gap-5">
|
||||||
{urlError ? null : !canRead ? <p role="status">История недоступна без права чтения</p> : !known("backups") ? <p role="status">{resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}</p> : <BackupsHistory
|
<BackupsHistory
|
||||||
backups={backupList}
|
backups={backupList}
|
||||||
kindFilter={kindFilter}
|
kindFilter={kindFilter}
|
||||||
onKindFilterChange={setKindFilter}
|
onKindFilterChange={setKindFilter}
|
||||||
@@ -517,17 +465,12 @@ function BackupsContent() {
|
|||||||
filters={advancedFilters}
|
filters={advancedFilters}
|
||||||
onFiltersChange={setAdvancedFilters}
|
onFiltersChange={setAdvancedFilters}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onRestore={backup => { captureDialogFocus(); setOpError(null); setRestoreTarget(backup) }}
|
onRestore={setRestoreTarget}
|
||||||
onDelete={backup => { captureDialogFocus(); setOpError(null); setDeleteTarget(backup) }}
|
onDelete={setDeleteTarget}
|
||||||
onCreate={() => openCreate(false)}
|
onCreate={() => openCreate(false)}
|
||||||
canCreate={canCreate}
|
/>
|
||||||
canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId}
|
|
||||||
canRestore={restoreGuard.canStart}
|
|
||||||
canDownload={isLive && canRead}
|
|
||||||
/>}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="settings">
|
<TabsContent value="settings">
|
||||||
|
|
||||||
<BackupsSettings
|
<BackupsSettings
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
onScheduleChange={setSched}
|
onScheduleChange={setSched}
|
||||||
@@ -537,7 +480,6 @@ function BackupsContent() {
|
|||||||
servers={liveServers}
|
servers={liveServers}
|
||||||
selectedServers={selectedServers}
|
selectedServers={selectedServers}
|
||||||
onToggleServer={(id) => {
|
onToggleServer={(id) => {
|
||||||
scheduleDirty.current = true
|
|
||||||
setSelectedServers((prev) => {
|
setSelectedServers((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(id)) next.delete(id)
|
if (next.has(id)) next.delete(id)
|
||||||
@@ -545,27 +487,17 @@ function BackupsContent() {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onSelectAll={() => { scheduleDirty.current = true; setAllEnabled(false); setSelectedServers(new Set(liveServers.map(s => s.id))) }}
|
onSelectAll={() => setSelectedServers(new Set(liveServers.map((s) => s.id)))}
|
||||||
onClearServers={() => { scheduleDirty.current = true; setSelectedServers(new Set()) }}
|
onClearServers={() => setSelectedServers(new Set())}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onSaveStorage={handleSaveStorage}
|
|
||||||
scheduleKnown={known("schedule")}
|
|
||||||
storageKnown={known("storage")}
|
|
||||||
storageDirty={storageIsDirty}
|
|
||||||
scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers") && !backupJobId}
|
|
||||||
storageWritable={canWrite && current("storage") && !backupJobId}
|
|
||||||
allEnabled={allEnabled}
|
|
||||||
onAllEnabledChange={value => { scheduleDirty.current = true; setAllEnabled(value) }}
|
|
||||||
onTest={handleTestStorage}
|
onTest={handleTestStorage}
|
||||||
onSync={handleSyncStorage}
|
onSync={handleSyncStorage}
|
||||||
saveBusy={scheduleSaveBusy}
|
saveBusy={scheduleSaveBusy}
|
||||||
testBusy={testBusy}
|
testBusy={testBusy}
|
||||||
syncBusy={syncBusy}
|
syncBusy={syncBusy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</FramePanel></Frame>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -575,7 +507,7 @@ function BackupsContent() {
|
|||||||
step={manualStep}
|
step={manualStep}
|
||||||
onStepChange={setManualStep}
|
onStepChange={setManualStep}
|
||||||
servers={liveServers}
|
servers={liveServers}
|
||||||
selected={checkpoint ? new Set(checkpoint.request.serverIds.map(String)) : manualServers}
|
selected={manualServers}
|
||||||
onToggle={(id) => {
|
onToggle={(id) => {
|
||||||
setManualServers((prev) => {
|
setManualServers((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -586,35 +518,22 @@ function BackupsContent() {
|
|||||||
}}
|
}}
|
||||||
onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
|
onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
|
||||||
onClear={() => setManualServers(new Set())}
|
onClear={() => setManualServers(new Set())}
|
||||||
notes={checkpoint?.request.notes ?? manualNotes}
|
notes={manualNotes}
|
||||||
onNotesChange={setManualNotes}
|
onNotesChange={setManualNotes}
|
||||||
destinationLabel={destinationLabel}
|
destinationLabel={destinationLabel}
|
||||||
busy={job.busy}
|
busy={backupJobId !== null}
|
||||||
locked={!!checkpoint}
|
|
||||||
terminal={jobTerminal}
|
|
||||||
canObserve={canRead}
|
|
||||||
jobId={checkpoint?.request.requestId}
|
|
||||||
jobStatus={checkpoint?.job ? ({ queued: "В очереди", running: "Выполняется", done: checkpoint?.job && backupJobOutcome(checkpoint.job).fullSuccess ? "Завершена" : "Завершена с ограничениями", failed: "Прервана" })[checkpoint.job.status] + ": " + checkpoint.job.completed + "/" + checkpoint.job.total : checkpoint?.accepted ? "Запрос принят, результат ещё неизвестен" : "Результат запроса ещё неизвестен"}
|
|
||||||
error={job.error || job.storageError || persistedJobError}
|
|
||||||
result={checkpoint?.job && jobTerminal ? "Создано " + backupJobOutcome(checkpoint.job).created + " из " + checkpoint.job.total : undefined}
|
|
||||||
onReset={() => { if (job.reset()) { setManualServers(new Set()); setManualNotes(""); setManualStep(1) } }}
|
|
||||||
onSubmit={handleManualBackup}
|
onSubmit={handleManualBackup}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BackupDeleteDialog
|
<BackupDeleteDialog
|
||||||
returnFocus={returnDialogFocus}
|
|
||||||
backup={deleteTarget}
|
backup={deleteTarget}
|
||||||
busy={opBusy}
|
busy={opBusy}
|
||||||
error={opError}
|
|
||||||
onConfirm={() => void handleDelete()}
|
onConfirm={() => void handleDelete()}
|
||||||
onCancel={() => setDeleteTarget(null)}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
/>
|
/>
|
||||||
<BackupRestoreDialog
|
<BackupRestoreDialog
|
||||||
returnFocus={returnDialogFocus}
|
|
||||||
backup={restoreTarget}
|
backup={restoreTarget}
|
||||||
busy={opBusy}
|
busy={opBusy}
|
||||||
blocked={!restoreGuard.canStart || Boolean(restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget))}
|
|
||||||
error={restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget) ? "Локальный файл отсутствует. Создайте новую резервную копию." : opError}
|
|
||||||
onConfirm={() => void handleRestore()}
|
onConfirm={() => void handleRestore()}
|
||||||
onCancel={() => setRestoreTarget(null)}
|
onCancel={() => setRestoreTarget(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+178
-160
@@ -1,30 +1,27 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Suspense, useState, useMemo, useEffect } from "react"
|
import { useState, useMemo, useEffect } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
|
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
|
||||||
import { useBgpUrlState } from "@/hooks/use-bgp-url-state"
|
import type { Filter } from "@/components/reui/filters"
|
||||||
import { QueryFilters } from "@/components/data-grids/query-filters"
|
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
|
||||||
import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
|
|
||||||
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
||||||
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||||
import { bgpCatalogSchema, bgpServersSchema, type BackendBgpSession, type BgpFailure } from "@/lib/bgp/response"
|
|
||||||
import { fmtBgpNum, sumBgpCounters } from "@/lib/bgp/helpers"
|
|
||||||
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
RefreshCwIcon, DownloadIcon,
|
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||||||
ActivityIcon, BarChart3Icon,
|
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
||||||
ArrowDownIcon, ServerIcon,
|
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
||||||
AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
|
XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
@@ -36,6 +33,8 @@ import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-ra
|
|||||||
|
|
||||||
type BgpSession = BgpSessionRow
|
type BgpSession = BgpSessionRow
|
||||||
type BgpTab = "sessions" | "routers" | "analytics"
|
type BgpTab = "sessions" | "routers" | "analytics"
|
||||||
|
type StateFilter = "all" | BgpState
|
||||||
|
type TypeFilter = "all" | BgpType
|
||||||
|
|
||||||
const AS_NAMES = BGP_AS_NAMES
|
const AS_NAMES = BGP_AS_NAMES
|
||||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||||
@@ -185,7 +184,6 @@ const SESSIONS: BgpSession[] = [
|
|||||||
// ─── visual helpers ───────────────────────────────────────────────────────────
|
// ─── visual helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
|
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
|
||||||
Unknown: { bg: "bg-muted", text: "text-muted-foreground", dot: "bg-muted-foreground", label: "Неизвестно" },
|
|
||||||
Established: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", label: "Established" },
|
Established: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", label: "Established" },
|
||||||
Active: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", dot: "bg-amber-500", label: "Active" },
|
Active: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", dot: "bg-amber-500", label: "Active" },
|
||||||
Idle: { bg: "bg-slate-500/10", text: "text-slate-500 dark:text-slate-400", dot: "bg-slate-500", label: "Idle" },
|
Idle: { bg: "bg-slate-500/10", text: "text-slate-500 dark:text-slate-400", dot: "bg-slate-500", label: "Idle" },
|
||||||
@@ -194,32 +192,79 @@ const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; lab
|
|||||||
OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" },
|
OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" },
|
||||||
}
|
}
|
||||||
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
|
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
|
||||||
Unknown: { bg: "bg-muted", text: "text-muted-foreground" },
|
|
||||||
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
|
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
|
||||||
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
|
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StateBadge({ state }: { state: BgpState }) {
|
||||||
|
const s = STATE_STYLE[state]
|
||||||
|
return (
|
||||||
|
<span className={cn("inline-flex items-center gap-1.5 rounded border px-2 py-0.5 text-[11px] font-semibold",
|
||||||
|
s.bg, s.text, "border-current/20")}>
|
||||||
|
<span className={cn("size-1.5 rounded-full shrink-0", s.dot)} />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function TypeBadge({ type }: { type: BgpType }) {
|
function TypeBadge({ type }: { type: BgpType }) {
|
||||||
const s = TYPE_STYLE[type]
|
const s = TYPE_STYLE[type]
|
||||||
return (
|
return (
|
||||||
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
|
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
|
||||||
s.bg, s.text, "border-current/20")}>
|
s.bg, s.text, "border-current/20")}>
|
||||||
{type === "Unknown" ? "Тип неизвестен" : type}
|
{type}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const fmtNum = fmtBgpNum
|
function fmtNum(n: number) {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── backend mapping ──────────────────────────────────────────────────────────
|
// ─── backend mapping ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type BackendServer = ReturnType<typeof bgpServersSchema.parse>[number]
|
interface BackendBgpSession {
|
||||||
type InventoryServer = Server & { monitoringUnknown?: boolean }
|
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
|
||||||
function mapBackendServer(s: BackendServer): InventoryServer {
|
name: string; peerIp: string; remoteAs: number; localAs: number
|
||||||
return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", site: s.site ?? "",
|
localId: string; remoteId: string; state: string; type: "eBGP" | "iBGP"
|
||||||
country: s.country || "UN", asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled,
|
uptime: string | null; holdTime: number; keepalive: number
|
||||||
status: s.status ?? "offline", monitoringUnknown: s.status == null, latency: s.latency ?? null, sessions: 0 }
|
prefixesRx: number; prefixesTx: number
|
||||||
|
inputMessages: number; outputMessages: number
|
||||||
|
capabilities: string[]; lastError: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackendServer {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
type?: Server["type"]
|
||||||
|
site?: string
|
||||||
|
country: string
|
||||||
|
asn?: string
|
||||||
|
enabled: boolean
|
||||||
|
status?: Server["status"]
|
||||||
|
latency?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBackendServer(s: BackendServer): Server {
|
||||||
|
return {
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name || s.host,
|
||||||
|
host: s.host,
|
||||||
|
model: "—",
|
||||||
|
os: "—",
|
||||||
|
site: s.site ?? "",
|
||||||
|
country: s.country || "UN",
|
||||||
|
asn: s.asn ?? "",
|
||||||
|
type: s.type ?? "exit-node",
|
||||||
|
enabled: s.enabled,
|
||||||
|
status: s.status ?? "online",
|
||||||
|
latency: s.latency ?? null,
|
||||||
|
sessions: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function backendToFrontend(b: BackendBgpSession): BgpSession {
|
function backendToFrontend(b: BackendBgpSession): BgpSession {
|
||||||
@@ -233,15 +278,15 @@ function backendToFrontend(b: BackendBgpSession): BgpSession {
|
|||||||
localAs: b.localAs,
|
localAs: b.localAs,
|
||||||
routerId: b.remoteId || b.localId,
|
routerId: b.remoteId || b.localId,
|
||||||
description: b.name,
|
description: b.name,
|
||||||
state: Object.hasOwn(STATE_STYLE, b.state) ? b.state as BgpState : "Unknown",
|
state: (b.state as BgpState) || "Idle",
|
||||||
type: b.type,
|
type: b.type,
|
||||||
afi: b.afi ?? "Неизвестно",
|
afi: "IPv4 Unicast",
|
||||||
uptime: b.uptime,
|
uptime: b.uptime,
|
||||||
holdTime: b.holdTime,
|
holdTime: b.holdTime,
|
||||||
keepalive: b.keepalive,
|
keepalive: b.keepalive,
|
||||||
prefixesRx: b.prefixesRx,
|
prefixesRx: b.prefixesRx,
|
||||||
prefixesTx: b.prefixesTx,
|
prefixesTx: b.prefixesTx,
|
||||||
prefixesActive: null,
|
prefixesActive: b.prefixesRx,
|
||||||
inputMessages: b.inputMessages,
|
inputMessages: b.inputMessages,
|
||||||
outputMessages: b.outputMessages,
|
outputMessages: b.outputMessages,
|
||||||
capabilities: b.capabilities,
|
capabilities: b.capabilities,
|
||||||
@@ -257,12 +302,14 @@ const BGP_FILTER_ACCESSORS = {
|
|||||||
afi: (s: BgpSession) => s.afi,
|
afi: (s: BgpSession) => s.afi,
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession }: { sessions: BgpSession[]; emptyTitle?: string; selectedSessionId: string | null; onSelectSession: (id: string | null) => void }) {
|
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||||
const { search, setSearch, stateFilter, setStateFilter, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset } = useBgpUrlState()
|
const [search, setSearch] = useState("")
|
||||||
const fields = useMemo(() => BGP_FILTER_FIELDS.map(field => field.id === "afi" ? { ...field, options: Array.from(new Set(sessions.map(s => s.afi))).sort().map(value => ({ value, label: value })) } : field), [sessions])
|
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
||||||
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||||
|
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||||
|
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (urlError) return []
|
|
||||||
const base = sessions.filter((s) => {
|
const base = sessions.filter((s) => {
|
||||||
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
||||||
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
||||||
@@ -270,48 +317,46 @@ function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession
|
|||||||
q &&
|
q &&
|
||||||
!s.peerIp.includes(q) &&
|
!s.peerIp.includes(q) &&
|
||||||
!s.description.toLowerCase().includes(q) &&
|
!s.description.toLowerCase().includes(q) &&
|
||||||
!s.serverLabel.toLowerCase().includes(q) &&
|
!s.serverLabel.includes(q) &&
|
||||||
!String(s.remoteAs).includes(q) &&
|
!String(s.remoteAs).includes(q) &&
|
||||||
!(s.remoteAs === null ? "" : AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
|
!(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return applyFilterQuery(base, advancedFilters, BGP_FILTER_ACCESSORS)
|
return applyReuiFilters(base, advancedFilters, BGP_FILTER_ACCESSORS)
|
||||||
}, [sessions, q, stateFilter, typeFilter, advancedFilters, urlError])
|
}, [sessions, q, stateFilter, typeFilter, advancedFilters])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{urlError && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>{urlError}<Button variant="outline" size="sm" onClick={reset}>Сбросить фильтры BGP</Button></AlertDescription></Alert>}
|
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
className="[&>div:first-child]:max-w-full [&>div:first-child]:overflow-x-auto [&>div:first-child_button]:shrink-0 [&>div:first-child_button]:whitespace-nowrap"
|
|
||||||
segmented={{
|
segmented={{
|
||||||
value: stateFilter,
|
value: stateFilter,
|
||||||
onChange: setStateFilter,
|
onChange: setStateFilter,
|
||||||
options: [
|
options: [
|
||||||
{ value: "all", label: "Все", count: sessions.length },
|
{ value: "all", label: "Все", count: sessions.length },
|
||||||
{ value: "Unknown", label: "Неизвестно", count: sessions.filter(s => s.state === "Unknown").length },
|
|
||||||
{ value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
|
{ value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
|
||||||
{ value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
|
{ value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
|
||||||
{ value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
|
{ value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
|
||||||
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
|
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
filterControl={<QueryFilters fields={fields} query={advancedFilters} onQueryChange={setAdvancedFilters} />}
|
filters={advancedFilters}
|
||||||
|
onFiltersChange={setAdvancedFilters}
|
||||||
|
filterFields={BGP_FILTER_FIELDS}
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="IP, AS, описание…"
|
searchPlaceholder="IP, AS, описание…"
|
||||||
countLabel={`${filtered.length} из ${sessions.length}`}
|
countLabel={`${filtered.length} из ${sessions.length}`}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
|
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
|
||||||
{(["all", "eBGP", "iBGP", "Unknown"] as const).map((t) => (
|
{(["all", "eBGP", "iBGP"] as const).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTypeFilter(t)}
|
onClick={() => setTypeFilter(t)}
|
||||||
aria-pressed={typeFilter === t}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2.5 py-1 text-[11px] rounded transition-colors",
|
"px-2.5 py-1 text-[11px] rounded transition-colors",
|
||||||
typeFilter === t
|
typeFilter === t
|
||||||
@@ -319,15 +364,13 @@ function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession
|
|||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t === "all" ? "Все типы" : t === "Unknown" ? "Неизвестно" : t}
|
{t === "all" ? "Все типы" : t}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{selectedSessionId && sessions.some(s => s.id === selectedSessionId) && !filtered.some(s => s.id === selectedSessionId) && <p role="status" className="px-5 py-2 text-xs text-muted-foreground">Сессия из ссылки скрыта текущими фильтрами.</p>}
|
<BgpSessionsDataGrid sessions={filtered} />
|
||||||
{hasFilters && !urlError && <Button variant="ghost" size="sm" onClick={reset}>Сбросить фильтры BGP</Button>}
|
|
||||||
<BgpSessionsDataGrid sessions={filtered} emptyTitle={urlError ? "Некорректные фильтры BGP" : emptyTitle} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />
|
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -349,8 +392,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
{byRouter.map(router => {
|
{byRouter.map(router => {
|
||||||
const estCnt = router.sessions.filter(s => s.state === "Established").length
|
const estCnt = router.sessions.filter(s => s.state === "Established").length
|
||||||
const downCnt = router.sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
|
const downCnt = router.sessions.length - estCnt
|
||||||
const totalRx = sumBgpCounters(router.sessions.map(s => s.prefixesRx))
|
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame key={router.id} dense className="w-full overflow-hidden">
|
<Frame key={router.id} dense className="w-full overflow-hidden">
|
||||||
@@ -360,7 +403,7 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
|
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium leading-none">{router.label}</p>
|
<p className="text-sm font-medium leading-none">{router.label}</p>
|
||||||
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · {Array.from(new Set(router.sessions.map(s => s.localAs === null ? "AS неизвестна" : "AS" + s.localAs))).join(", ")}</p>
|
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · AS65001</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{estCnt > 0 && (
|
{estCnt > 0 && (
|
||||||
@@ -391,19 +434,19 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
<TypeBadge type={s.type} />
|
<TypeBadge type={s.type} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
<span className="text-[10px] text-muted-foreground">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
|
<span className="text-[10px] text-muted-foreground">AS{s.remoteAs}</span>
|
||||||
{s.remoteAs !== null && AS_NAMES[s.remoteAs] && (
|
{AS_NAMES[s.remoteAs] && (
|
||||||
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
|
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right">
|
<div className="shrink-0 text-right">
|
||||||
{s.prefixesRx !== null && s.prefixesRx > 0 && (
|
{s.prefixesRx > 0 && (
|
||||||
<p className="text-[10px] font-mono text-emerald-500 tabular-nums">
|
<p className="text-[10px] font-mono text-emerald-500 tabular-nums">
|
||||||
↓ {fmtNum(s.prefixesRx)}
|
↓ {fmtNum(s.prefixesRx)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className={cn("text-[10px] font-semibold", ss.text)}>{ss.label}</p>
|
<p className={cn("text-[10px] font-semibold", ss.text)}>{s.state}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -411,7 +454,7 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* footer summary */}
|
{/* footer summary */}
|
||||||
{totalRx !== null && totalRx > 0 && (
|
{totalRx > 0 && (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
|
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
|
||||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||||
<span className="text-[11px] font-mono text-muted-foreground">
|
<span className="text-[11px] font-mono text-muted-foreground">
|
||||||
@@ -432,9 +475,9 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||||
// top peers by prefixes received (eBGP only)
|
// top peers by prefixes received (eBGP only)
|
||||||
const topPeers = useMemo(() =>
|
const topPeers = useMemo(() =>
|
||||||
(sessions.some(s => s.prefixesRx === null) ? [] : [...sessions])
|
[...sessions]
|
||||||
.filter(s => s.prefixesRx !== null && s.prefixesRx > 0)
|
.filter(s => s.prefixesRx > 0)
|
||||||
.sort((a, b) => (b.prefixesRx ?? 0) - (a.prefixesRx ?? 0))
|
.sort((a, b) => b.prefixesRx - a.prefixesRx)
|
||||||
.slice(0, 8),
|
.slice(0, 8),
|
||||||
[sessions]
|
[sessions]
|
||||||
)
|
)
|
||||||
@@ -448,8 +491,8 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
}, [sessions])
|
}, [sessions])
|
||||||
|
|
||||||
// total prefix stats
|
// total prefix stats
|
||||||
const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
|
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||||||
const totalActive = sumBgpCounters(sessions.map(s => s.prefixesActive))
|
const totalActive = sessions.reduce((a, s) => a + s.prefixesActive, 0)
|
||||||
const ebgpSessions = sessions.filter(s => s.type === "eBGP").length
|
const ebgpSessions = sessions.filter(s => s.type === "eBGP").length
|
||||||
const ibgpSessions = sessions.filter(s => s.type === "iBGP").length
|
const ibgpSessions = sessions.filter(s => s.type === "iBGP").length
|
||||||
|
|
||||||
@@ -460,30 +503,29 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
id: "rx",
|
id: "rx",
|
||||||
label: "Префиксы",
|
label: "Всего префиксов",
|
||||||
value: fmtNum(totalRx),
|
value: fmtNum(totalRx),
|
||||||
icon: <DownloadIcon className="size-4" />,
|
icon: <DownloadIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "active",
|
id: "active",
|
||||||
label: "Маршруты",
|
label: "Активных маршрутов",
|
||||||
hint: totalActive === null ? "нет данных API" : undefined,
|
|
||||||
value: fmtNum(totalActive),
|
value: fmtNum(totalActive),
|
||||||
icon: <GitMergeIcon className="size-4" />,
|
icon: <GitMergeIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ebgp",
|
id: "ebgp",
|
||||||
label: "eBGP",
|
label: "eBGP сессий",
|
||||||
value: sessions.some(s => s.type === "Unknown") ? "—" : ebgpSessions,
|
value: ebgpSessions,
|
||||||
icon: <ActivityIcon className="size-4" />,
|
icon: <ActivityIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ibgp",
|
id: "ibgp",
|
||||||
label: "iBGP",
|
label: "iBGP сессий",
|
||||||
value: sessions.some(s => s.type === "Unknown") ? "—" : ibgpSessions,
|
value: ibgpSessions,
|
||||||
icon: <ServerIcon className="size-4" />,
|
icon: <ServerIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
@@ -495,7 +537,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
|
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{topPeers.map((s, i) => {
|
{topPeers.map((s, i) => {
|
||||||
const pct = ((s.prefixesRx ?? 0) / maxRx) * 100
|
const pct = (s.prefixesRx / maxRx) * 100
|
||||||
const ss = STATE_STYLE[s.state]
|
const ss = STATE_STYLE[s.state]
|
||||||
return (
|
return (
|
||||||
<div key={s.id} className="flex items-center gap-3">
|
<div key={s.id} className="flex items-center gap-3">
|
||||||
@@ -506,7 +548,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
<div className="flex items-center justify-between mb-1 gap-2">
|
<div className="flex items-center justify-between mb-1 gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-xs font-mono truncate">{s.peerIp}</span>
|
<span className="text-xs font-mono truncate">{s.peerIp}</span>
|
||||||
<span className="text-[10px] text-muted-foreground shrink-0">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
|
<span className="text-[10px] text-muted-foreground shrink-0">AS{s.remoteAs}</span>
|
||||||
<TypeBadge type={s.type} />
|
<TypeBadge type={s.type} />
|
||||||
</div>
|
</div>
|
||||||
<span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}>
|
<span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}>
|
||||||
@@ -525,7 +567,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{topPeers.length === 0 && (
|
{topPeers.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">Недостаточно данных о префиксах для сравнения</p>
|
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
@@ -544,7 +586,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
return (
|
return (
|
||||||
<div key={state} className="flex items-center gap-2">
|
<div key={state} className="flex items-center gap-2">
|
||||||
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
|
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
|
||||||
<span className="text-xs w-24">{ss.label}</span>
|
<span className="text-xs w-24">{state}</span>
|
||||||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||||
<div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
|
<div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -558,7 +600,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
|
|
||||||
{/* eBGP vs iBGP */}
|
{/* eBGP vs iBGP */}
|
||||||
<OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
|
<OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
|
||||||
{sessions.length === 0 || sessions.some(s => s.type === "Unknown") ? (
|
{sessions.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">Нет данных</p>
|
<p className="text-xs text-muted-foreground">Нет данных</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -585,7 +627,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{Object.entries(
|
{Object.entries(
|
||||||
sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => {
|
sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => {
|
||||||
if (s.remoteAs !== null) acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
|
acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
|
||||||
}, {})
|
}, {})
|
||||||
).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => (
|
).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => (
|
||||||
<div key={as} className="flex items-center justify-between text-[11px]">
|
<div key={as} className="flex items-center justify-between text-[11px]">
|
||||||
@@ -614,44 +656,17 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function BgpPage() {
|
export default function BgpPage() {
|
||||||
return <Suspense fallback={<div role="status">Загрузка BGP…</div>}><BgpRoute /></Suspense>
|
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
|
||||||
}
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
function BgpRoute() {
|
|
||||||
const { mode, backendUrl } = useDataSource()
|
|
||||||
const params = useSearchParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const selectedServerId = params.get("server") ?? ALL_SERVERS_ID
|
|
||||||
const selectedSessionId = params.get("session")
|
|
||||||
const tab = params.get("tab")
|
|
||||||
const activeTab: BgpTab = tab === "routers" || tab === "analytics" ? tab : "sessions"
|
|
||||||
function navigate(values: Record<string, string | null>) {
|
|
||||||
const next = new URLSearchParams(params.toString())
|
|
||||||
for (const [key, value] of Object.entries(values)) {
|
|
||||||
if (value === null) next.delete(key)
|
|
||||||
else next.set(key, value)
|
|
||||||
}
|
|
||||||
router.push("/bgp" + (next.size ? "?" + next.toString() : ""))
|
|
||||||
}
|
|
||||||
return <BgpPageContent key={JSON.stringify([mode, backendUrl])} selectedServerId={selectedServerId} selectedSessionId={selectedSessionId} activeTab={activeTab}
|
|
||||||
onSelectServer={id => navigate({ server: id === ALL_SERVERS_ID ? null : id, session: null })}
|
|
||||||
onSelectSession={id => navigate({ session: id })}
|
|
||||||
onSelectTab={tab => navigate({ tab: tab === "sessions" ? null : tab })} />
|
|
||||||
}
|
|
||||||
function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSelectServer, onSelectSession, onSelectTab }: {
|
|
||||||
selectedServerId: string; selectedSessionId: string | null; activeTab: BgpTab;
|
|
||||||
onSelectServer: (id: string) => void; onSelectSession: (id: string | null) => void; onSelectTab: (tab: BgpTab) => void;
|
|
||||||
}) {
|
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
||||||
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
const [loading, setLoading] = useState(isLive)
|
const [loading, setLoading] = useState(false)
|
||||||
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
|
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
|
||||||
const [liveError, setLiveError] = useState<string | null>(null)
|
const [liveError, setLiveError] = useState<string | null>(null)
|
||||||
const [failures, setFailures] = useState<BgpFailure[]>([])
|
|
||||||
const [hasLoaded, setHasLoaded] = useState(false)
|
|
||||||
const [readReceipts, setReadReceipts] = useState<Record<string, string>>({})
|
|
||||||
const [fetchTick, setFetchTick] = useState(0)
|
const [fetchTick, setFetchTick] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -667,25 +682,16 @@ function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSele
|
|||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setLiveError(null)
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
requestJson<unknown>(backendUrl, "/api/bgp/catalog"),
|
requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions"),
|
||||||
requestJson<unknown>(backendUrl, "/api/servers"),
|
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
||||||
])
|
])
|
||||||
.then(([data, servers]) => {
|
.then(([data, servers]) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const catalog = bgpCatalogSchema.parse(data)
|
setLiveSessions(data.map(backendToFrontend))
|
||||||
const inventory = bgpServersSchema.parse(servers)
|
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
|
||||||
const enabled = new Set(inventory.filter(s => s.enabled).map(s => s.id))
|
setFetchedAt(new Date())
|
||||||
if (catalog.sessions.some(s => !enabled.has(s.serverId)) || catalog.failures.some(f => !enabled.has(f.serverId))) throw new Error("Чужой контекст BGP-каталога")
|
|
||||||
const failed = new Set(catalog.failures.map(f => String(f.serverId)))
|
|
||||||
setLiveSessions(previous => [...catalog.sessions.map(backendToFrontend), ...previous.filter(s => enabled.has(Number(s.serverId)) && failed.has(s.serverId))])
|
|
||||||
setLiveServers(inventory.filter(s => s.enabled).map(mapBackendServer))
|
|
||||||
setFailures(catalog.failures)
|
|
||||||
setLiveError(null)
|
|
||||||
setHasLoaded(true)
|
|
||||||
const receivedAt = new Date()
|
|
||||||
setFetchedAt(receivedAt)
|
|
||||||
setReadReceipts(previous => ({ ...Object.fromEntries(Object.entries(previous).filter(([id]) => enabled.has(Number(id)))), ...Object.fromEntries([...enabled].filter(id => !failed.has(String(id))).map(id => [String(id), receivedAt.toISOString()])) }))
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
@@ -697,13 +703,13 @@ function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSele
|
|||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [isLive, backendUrl, fetchTick])
|
}, [isLive, backendUrl, fetchTick])
|
||||||
|
|
||||||
const unknownIds = useMemo(() => new Set(isLive ? (liveError ? liveServers.map(s => s.id) : failures.map(f => String(f.serverId))) : []), [isLive, liveError, liveServers, failures])
|
const allSessions = isLive ? liveSessions : SESSIONS
|
||||||
const allSessions: BgpSession[] = isLive ? liveSessions.map(s => unknownIds.has(s.serverId) ? { ...s, state: "Unknown" } : s) : SESSIONS
|
|
||||||
const unavailableContext = isLive && hasLoaded && selectedServerId !== ALL_SERVERS_ID && !liveServers.some(s => s.id === selectedServerId)
|
|
||||||
const incomplete = isLive && (unavailableContext || !hasLoaded || Boolean(liveError) || (selectedServerId === ALL_SERVERS_ID ? failures.length > 0 : unknownIds.has(selectedServerId)))
|
|
||||||
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||||
|
|
||||||
const effectiveServerId = selectedServerId
|
const effectiveServerId =
|
||||||
|
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||||||
|
? selectedServerId
|
||||||
|
: ALL_SERVERS_ID
|
||||||
|
|
||||||
const sessions = useMemo(() => {
|
const sessions = useMemo(() => {
|
||||||
if (effectiveServerId === ALL_SERVERS_ID) return allSessions
|
if (effectiveServerId === ALL_SERVERS_ID) return allSessions
|
||||||
@@ -721,26 +727,27 @@ function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSele
|
|||||||
host: s.host,
|
host: s.host,
|
||||||
site: s.site,
|
site: s.site,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
status: "monitoringUnknown" in s && s.monitoringUnknown ? undefined : s.status,
|
status: s.status,
|
||||||
meta: "monitoringUnknown" in s && s.monitoringUnknown ? "Статус неизвестен" : undefined,
|
|
||||||
type: s.type,
|
type: s.type,
|
||||||
count: isLive && (!hasLoaded || unknownIds.has(s.id)) ? undefined : counts.get(s.id) ?? 0,
|
count: counts.get(s.id) ?? 0,
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
||||||
}))
|
}))
|
||||||
}, [displayServers, allSessions, hasLoaded, isLive, unknownIds])
|
}, [displayServers, allSessions])
|
||||||
|
|
||||||
const established = sessions.filter(s => s.state === "Established").length
|
const established = sessions.filter(s => s.state === "Established").length
|
||||||
const notEstab = sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
|
const notEstab = sessions.length - established
|
||||||
const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
|
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||||||
|
const serverCount = useMemo(
|
||||||
|
() => new Set(sessions.map(s => s.serverId)).size,
|
||||||
|
[sessions],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerRailLayout
|
<ServerRailLayout
|
||||||
preserveSelection
|
|
||||||
items={railItems}
|
items={railItems}
|
||||||
selectedId={effectiveServerId}
|
selectedId={effectiveServerId}
|
||||||
onSelect={onSelectServer}
|
onSelect={setSelectedServerId}
|
||||||
showAll
|
showAll
|
||||||
allCount={displayServers.length}
|
allCount={displayServers.length}
|
||||||
loading={isLive && loading && displayServers.length === 0}
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
@@ -750,21 +757,22 @@ function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSele
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<ServerRailMobileButton />
|
<ServerRailMobileButton />
|
||||||
<Button variant="outline" size="sm" disabled={!isLive || loading} onClick={() => setFetchTick(t => t + 1)}>
|
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
banner={
|
banner={
|
||||||
<div className="border-b bg-background shrink-0">
|
<div className="border-b bg-background shrink-0">
|
||||||
<div className="flex min-w-0 items-center overflow-x-auto px-4 sm:px-6">
|
<div className="flex items-center px-6">
|
||||||
{TABS.map(t => (
|
{TABS.map(t => (
|
||||||
<button key={t.id} type="button" aria-pressed={activeTab === t.id} onClick={() => onSelectTab(t.id)}
|
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex shrink-0 items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||||
activeTab === t.id
|
activeTab === t.id
|
||||||
? "border-primary text-foreground"
|
? "border-primary text-foreground"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||||
@@ -778,8 +786,6 @@ function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSele
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{unavailableContext && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Устройство BGP недоступно в этом каталоге</AlertTitle><AlertDescription>Контекст {selectedServerId} сохранён. Выберите доступное устройство.<Button variant="outline" size="sm" onClick={() => onSelectServer(ALL_SERVERS_ID)}>Все устройства BGP</Button></AlertDescription></Alert>}
|
|
||||||
{selectedSessionId && hasLoaded && !sessions.some(s => s.id === selectedSessionId) && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>Сессия из ссылки отсутствует в выбранном снимке. Контекст ссылки сохранён.<Button variant="outline" size="sm" onClick={() => onSelectSession(null)}>Все сессии BGP</Button></AlertDescription></Alert>}
|
|
||||||
{/* data source banner */}
|
{/* data source banner */}
|
||||||
{isLive && loading && (
|
{isLive && loading && (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
@@ -787,23 +793,36 @@ function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSele
|
|||||||
Загрузка BGP-сессий…
|
Загрузка BGP-сессий…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isLive && fetchedAt && (
|
{isLive && !loading && fetchedAt && !liveError && (
|
||||||
<div role="group" aria-label="Свежесть BGP" className="text-xs text-muted-foreground">
|
<div className="flex items-center gap-2">
|
||||||
Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>.
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
Это время получения браузером, не время измерения RouterOS.
|
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||||||
{displayServers.filter(s => effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId).map(s => <p key={s.id}>{s.name}: {readReceipts[s.id] ? <time data-bgp-server={s.id} dateTime={readReceipts[s.id]}>{new Date(readReceipts[s.id]).toLocaleTimeString("ru")}</time> : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}</p>)}
|
Живые данные · {serverCount} серверов · обновлено {fetchedAt.toLocaleTimeString("ru")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setFetchTick(t => t + 1)}
|
||||||
|
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
<RefreshCwIcon className="size-3" />
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isLive && (liveError || failures.length > 0) && (
|
{isLive && liveError && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning" className="py-2">
|
||||||
<AlertCircleIcon />
|
<AlertCircleIcon />
|
||||||
<AlertTitle>{liveError ? "Не удалось загрузить BGP" : "Не удалось опросить часть роутеров BGP"}</AlertTitle>
|
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||||||
<AlertDescription>
|
|
||||||
{liveError ?? failures.map(f => f.serverName + ": " + f.error).join("; ")}. {hasLoaded ? "Показаны последние полученные данные; статус отказавших устройств неизвестен." : "Данные пока неизвестны."}
|
|
||||||
<Button variant="outline" size="sm" disabled={loading} onClick={() => setFetchTick(t => t + 1)} aria-label="Повторить загрузку BGP">Повторить</Button>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
{isLive && !loading && allSessions.length === 0 && !liveError && fetchedAt && (
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
BGP не настроен ни на одном сервере
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLive && !loading && allSessions.length > 0 && sessions.length === 0 && !liveError && (
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
На выбранном сервере нет BGP-сессий
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{mode === "mock" && (
|
{mode === "mock" && (
|
||||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
Моковые данные
|
Моковые данные
|
||||||
@@ -815,31 +834,30 @@ function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSele
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
id: "sessions",
|
id: "sessions",
|
||||||
label: "Сессий",
|
label: "Сессий всего",
|
||||||
value: incomplete ? "—" : sessions.length,
|
value: sessions.length,
|
||||||
hint: incomplete ? "неизвестно" : undefined,
|
|
||||||
icon: <GitMergeIcon className="size-4" />,
|
icon: <GitMergeIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "established",
|
id: "established",
|
||||||
label: "Established",
|
label: "Established",
|
||||||
value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : established,
|
value: established,
|
||||||
icon: <CheckCircleIcon className="size-4" />,
|
icon: <CheckCircleIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "not-estab",
|
id: "not-estab",
|
||||||
label: "Не поднято",
|
label: "Не установлено",
|
||||||
value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : notEstab,
|
value: notEstab,
|
||||||
icon: <AlertCircleIcon className="size-4" />,
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
|
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
|
||||||
variant: notEstab > 0 ? "warning" : "default",
|
variant: notEstab > 0 ? "warning" : "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "prefixes",
|
id: "prefixes",
|
||||||
label: "Префиксы",
|
label: "Получено префиксов",
|
||||||
value: incomplete ? "—" : fmtNum(totalRx),
|
value: fmtNum(totalRx),
|
||||||
icon: <DownloadIcon className="size-4" />,
|
icon: <DownloadIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
@@ -847,22 +865,22 @@ function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSele
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* alert: not-established sessions */}
|
{/* alert: not-established sessions */}
|
||||||
{!incomplete && notEstab > 0 && (
|
{notEstab > 0 && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertCircleIcon />
|
<AlertCircleIcon />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
|
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Проверьте {sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").map(s => s.peerIp).join(", ")}
|
Проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* tab content */}
|
{/* tab content */}
|
||||||
{activeTab === "sessions" && <SessionsTab sessions={sessions} emptyTitle={incomplete && sessions.length === 0 ? "Данные BGP неизвестны" : undefined} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />}
|
{activeTab === "sessions" && <SessionsTab sessions={sessions} />}
|
||||||
{activeTab === "routers" && (incomplete ? <p role="status">Сводка по роутерам неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <RoutersTab sessions={sessions} />)}
|
{activeTab === "routers" && <RoutersTab sessions={sessions} />}
|
||||||
{activeTab === "analytics" && (incomplete ? <p role="status">Аналитика неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <AnalyticsTab sessions={sessions} />)}
|
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</ServerRailLayout>
|
</ServerRailLayout>
|
||||||
|
|||||||
+342
-157
@@ -1,18 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Suspense, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { readCertificatesUrl, patchCertificatesUrl, type CertificateStatusFilter } from "@/lib/certificates-url-state"
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||||
import type { Server } from "@/lib/data"
|
import type { CertStatus, Server } from "@/lib/data"
|
||||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
||||||
import { CertificateAcmeSettings } from "@/components/certificates/certificate-acme-settings"
|
|
||||||
import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
|
import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
@@ -30,11 +28,17 @@ import {
|
|||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { can } from "@/lib/auth"
|
import { listServers } from "@/shared/api/servers"
|
||||||
import { useCertificateCatalog } from "@/lib/use-certificate-catalog"
|
|
||||||
import type { CertificateView } from "@/lib/certificate-view"
|
|
||||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||||
import { useCertificateIssue } from "@/lib/use-certificate-issue"
|
import {
|
||||||
|
createCertificateIssueJob,
|
||||||
|
getAcmeSettings,
|
||||||
|
getCertificateIssueJob,
|
||||||
|
listCertificates,
|
||||||
|
putAcmeSettings,
|
||||||
|
refreshCertificates,
|
||||||
|
testAcmeSettings,
|
||||||
|
} from "@/shared/api/certificates"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
@@ -55,7 +59,7 @@ import {
|
|||||||
StepperSeparator,
|
StepperSeparator,
|
||||||
StepperTitle,
|
StepperTitle,
|
||||||
StepperTrigger,
|
StepperTrigger,
|
||||||
} from "@/components/reui-kit/accessible-stepper"
|
} from "@/components/reui/stepper"
|
||||||
|
|
||||||
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
||||||
return {
|
return {
|
||||||
@@ -75,12 +79,12 @@ function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function CertPartAlertExpired({ expired }: { expired: CertificateView[] }) {
|
function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-destructive/5 border border-destructive/20 px-4 py-3 text-sm">
|
<div className="flex items-start gap-3 rounded-lg bg-red-500/5 border border-red-500/20 px-4 py-3 text-sm">
|
||||||
<AlertCircleIcon className="size-5 text-destructive shrink-0 mt-0.5" />
|
<AlertCircleIcon className="size-5 text-red-500 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-destructive">
|
<p className="font-medium text-red-600 dark:text-red-400">
|
||||||
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
|
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs mt-0.5">
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
@@ -91,12 +95,12 @@ function CertPartAlertExpired({ expired }: { expired: CertificateView[] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CertPartAlertExpiring({ expiring }: { expiring: CertificateView[] }) {
|
function CertPartAlertExpiring({ expiring }: { expiring: CertificateDto[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-warning/5 border border-warning/20 px-4 py-3 text-sm">
|
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
|
||||||
<AlertTriangleIcon className="size-5 text-warning shrink-0 mt-0.5" />
|
<AlertTriangleIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-warning">
|
<p className="font-medium text-amber-600 dark:text-amber-400">
|
||||||
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
|
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs mt-0.5">
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
@@ -111,14 +115,10 @@ function CertPartKpi({
|
|||||||
displayCerts,
|
displayCerts,
|
||||||
expiring,
|
expiring,
|
||||||
expired,
|
expired,
|
||||||
complete,
|
|
||||||
totalKnown,
|
|
||||||
}: {
|
}: {
|
||||||
displayCerts: CertificateView[]
|
displayCerts: CertificateDto[]
|
||||||
expiring: CertificateView[]
|
expiring: CertificateDto[]
|
||||||
expired: CertificateView[]
|
expired: CertificateDto[]
|
||||||
complete: boolean
|
|
||||||
totalKnown: boolean
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
@@ -127,21 +127,21 @@ function CertPartKpi({
|
|||||||
{
|
{
|
||||||
id: "all",
|
id: "all",
|
||||||
label: "Всего",
|
label: "Всего",
|
||||||
value: totalKnown ? displayCerts.length : "—",
|
value: displayCerts.length,
|
||||||
icon: <ShieldCheckIcon className="size-4" />,
|
icon: <ShieldCheckIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "valid",
|
id: "valid",
|
||||||
label: "Действующих",
|
label: "Действующих",
|
||||||
value: complete ? displayCerts.filter((c) => c.status === "valid").length : "—",
|
value: displayCerts.filter((c) => c.status === "valid").length,
|
||||||
icon: <BadgeCheckIcon className="size-4" />,
|
icon: <BadgeCheckIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "expiring",
|
id: "expiring",
|
||||||
label: "Истекают",
|
label: "Истекают",
|
||||||
value: complete ? expiring.length : "—",
|
value: expiring.length,
|
||||||
icon: <AlertTriangleIcon className="size-4" />,
|
icon: <AlertTriangleIcon className="size-4" />,
|
||||||
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
|
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
|
||||||
variant: expiring.length > 0 ? "warning" : "default",
|
variant: expiring.length > 0 ? "warning" : "default",
|
||||||
@@ -149,7 +149,7 @@ function CertPartKpi({
|
|||||||
{
|
{
|
||||||
id: "expired",
|
id: "expired",
|
||||||
label: "Истёкших",
|
label: "Истёкших",
|
||||||
value: complete ? expired.length : "—",
|
value: expired.length,
|
||||||
icon: <AlertCircleIcon className="size-4" />,
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
|
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
|
||||||
variant: expired.length > 0 ? "destructive" : "default",
|
variant: expired.length > 0 ? "destructive" : "default",
|
||||||
@@ -159,6 +159,87 @@ function CertPartKpi({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CertPartAcmeSettings({
|
||||||
|
acmeDirectoryUrl,
|
||||||
|
setAcmeDirectoryUrl,
|
||||||
|
acmeZoneId,
|
||||||
|
setAcmeZoneId,
|
||||||
|
acmeTokenDraft,
|
||||||
|
setAcmeTokenDraft,
|
||||||
|
acmeTokenConfigured,
|
||||||
|
acmeSaveBusy,
|
||||||
|
onTest,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
acmeDirectoryUrl: string
|
||||||
|
setAcmeDirectoryUrl: (v: string) => void
|
||||||
|
acmeZoneId: string
|
||||||
|
setAcmeZoneId: (v: string) => void
|
||||||
|
acmeTokenDraft: string
|
||||||
|
setAcmeTokenDraft: (v: string) => void
|
||||||
|
acmeTokenConfigured: boolean
|
||||||
|
acmeSaveBusy: boolean
|
||||||
|
onTest: () => void
|
||||||
|
onSave: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OpsPanel
|
||||||
|
title="ACME · Cloudflare DNS-01"
|
||||||
|
description="Публичные Let's Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+."
|
||||||
|
contentClassName="px-5 py-4 flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className="grid gap-3 md:grid-cols-2"
|
||||||
|
autoComplete="off"
|
||||||
|
onSubmit={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium">ACME directory URL</label>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
name="acme-directory-url"
|
||||||
|
value={acmeDirectoryUrl}
|
||||||
|
onChange={(e) => setAcmeDirectoryUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium">Cloudflare zone id (опционально)</label>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
name="cloudflare-zone-id"
|
||||||
|
value={acmeZoneId}
|
||||||
|
onChange={(e) => setAcmeZoneId(e.target.value)}
|
||||||
|
placeholder="Авто по домену"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 md:col-span-2">
|
||||||
|
<label className="text-sm font-medium">Cloudflare API token</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
name="cloudflare-api-token"
|
||||||
|
value={acmeTokenDraft}
|
||||||
|
onChange={(e) => setAcmeTokenDraft(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
acmeTokenConfigured
|
||||||
|
? "Токен сохранён — введите новый для замены"
|
||||||
|
: "API token с правом DNS"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={acmeSaveBusy} onClick={onTest}>
|
||||||
|
Проверить Cloudflare
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled={acmeSaveBusy} onClick={onSave}>
|
||||||
|
Сохранить настройки
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</OpsPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function CertPartReference() {
|
function CertPartReference() {
|
||||||
return (
|
return (
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
@@ -199,7 +280,7 @@ function CertPartReference() {
|
|||||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
|
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
|
||||||
{b.title}
|
{b.title}
|
||||||
</p>
|
</p>
|
||||||
<pre tabIndex={0} role="region" aria-label={"Команды RouterOS: " + b.title} className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2">
|
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||||||
{b.lines.join("\n")}
|
{b.lines.join("\n")}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,7 +329,6 @@ function CertPartIssueForm({
|
|||||||
<SectionTitle>Основные</SectionTitle>
|
<SectionTitle>Основные</SectionTitle>
|
||||||
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
||||||
<select
|
<select
|
||||||
aria-label="Сервер выпуска"
|
|
||||||
value={issueServerId}
|
value={issueServerId}
|
||||||
onChange={(e) => setIssueServerId(e.target.value)}
|
onChange={(e) => setIssueServerId(e.target.value)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
@@ -266,7 +346,6 @@ function CertPartIssueForm({
|
|||||||
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
aria-label="Имя сертификата"
|
|
||||||
value={issueCertName}
|
value={issueCertName}
|
||||||
onChange={(e) => setIssueCertName(e.target.value)}
|
onChange={(e) => setIssueCertName(e.target.value)}
|
||||||
placeholder="router-le"
|
placeholder="router-le"
|
||||||
@@ -281,7 +360,6 @@ function CertPartIssueForm({
|
|||||||
<FormField label="Common Name" required hint="Основное имя в сертификате">
|
<FormField label="Common Name" required hint="Основное имя в сертификате">
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
aria-label="Основной домен"
|
|
||||||
value={issueCommonName}
|
value={issueCommonName}
|
||||||
onChange={(e) => setIssueCommonName(e.target.value)}
|
onChange={(e) => setIssueCommonName(e.target.value)}
|
||||||
placeholder="vpn.example.com"
|
placeholder="vpn.example.com"
|
||||||
@@ -290,7 +368,6 @@ function CertPartIssueForm({
|
|||||||
<FormField label="SAN" hint="По одному имени в строке">
|
<FormField label="SAN" hint="По одному имени в строке">
|
||||||
<textarea
|
<textarea
|
||||||
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
aria-label="Дополнительные домены"
|
|
||||||
value={issueSans}
|
value={issueSans}
|
||||||
onChange={(e) => setIssueSans(e.target.value)}
|
onChange={(e) => setIssueSans(e.target.value)}
|
||||||
placeholder="www.example.com"
|
placeholder="www.example.com"
|
||||||
@@ -311,16 +388,15 @@ function CertPartIssueForm({
|
|||||||
<p className="text-sm font-medium">Trust store · www</p>
|
<p className="text-sm font-medium">Trust store · www</p>
|
||||||
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
|
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
|
||||||
</div>
|
</div>
|
||||||
<FormToggle ariaLabel="Trust store www" checked={issueTrustWww} onChange={setIssueTrustWww} />
|
<FormToggle checked={issueTrustWww} onChange={setIssueTrustWww} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Trust store · api</p>
|
<p className="text-sm font-medium">Trust store · api</p>
|
||||||
<p className="text-xs text-muted-foreground">REST API и управление</p>
|
<p className="text-xs text-muted-foreground">REST API и управление</p>
|
||||||
</div>
|
</div>
|
||||||
<FormToggle ariaLabel="Trust store api" checked={issueTrustApi} onChange={setIssueTrustApi} />
|
<FormToggle checked={issueTrustApi} onChange={setIssueTrustApi} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">После импорта сертификат будет назначен сервисам www-ssl и api-ssl; оба сервиса будут включены. Переключатели выше задают trust store сертификата.</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -332,8 +408,6 @@ function CertPartIssueForm({
|
|||||||
<li>Имя: {issueCertName || "—"}</li>
|
<li>Имя: {issueCertName || "—"}</li>
|
||||||
<li>CN: {issueCommonName || "—"}</li>
|
<li>CN: {issueCommonName || "—"}</li>
|
||||||
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
|
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
|
||||||
<li>Сервисы: www-ssl и api-ssl будут включены и получат этот сертификат.</li>
|
|
||||||
<li>DNS: приложение попробует синхронизировать A-записи доменов с IPv4 сервера; CNAME сохраняются.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -342,42 +416,21 @@ function CertPartIssueForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CertificatesPage() {
|
export default function CertificatesPage() {
|
||||||
return <Suspense fallback={<p role="status">Загрузка контекста сертификатов…</p>}><CertificatesSource /></Suspense>
|
|
||||||
}
|
|
||||||
|
|
||||||
function CertificatesSource() {
|
|
||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
|
||||||
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
|
|
||||||
return <CertificatesContent key={mode + ":" + backendUrl} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function CertificatesContent() {
|
|
||||||
const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource()
|
const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource()
|
||||||
const isLive = prefsHydrated && mode === "live"
|
const isLive = prefsHydrated && mode === "live"
|
||||||
const liveReady = isLive && backendStatus === true
|
const liveReady = isLive && backendStatus === true
|
||||||
|
|
||||||
const params = useSearchParams()
|
const [search, setSearch] = useState("")
|
||||||
const context = readCertificatesUrl(params)
|
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
|
||||||
const search = context.search
|
const [certificates, setCertificates] = useState<CertificateDto[]>([])
|
||||||
const statusFilter = context.status
|
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle")
|
||||||
const selectedServerId = context.server
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
const urlError = context.error
|
const [serverList, setServerList] = useState<Server[]>([])
|
||||||
function writeContext(changes: Record<string, string | null>, replace = false) {
|
|
||||||
const href = patchCertificatesUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
|
|
||||||
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
|
|
||||||
}
|
|
||||||
const setSearch = (value: string) => writeContext({ q: value }, true)
|
|
||||||
const setStatusFilter = (value: CertificateStatusFilter) => writeContext({ status: value === "all" ? null : value })
|
|
||||||
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
|
|
||||||
const canRead = can("mm:certificates:read")
|
|
||||||
const canWrite = can("mm:certificates:write")
|
|
||||||
const canReadServers = can("mm:servers:read")
|
|
||||||
const catalog = useCertificateCatalog(backendUrl, isLive, canRead, canReadServers)
|
|
||||||
const { snapshots, loading, error: loadError, serverError } = catalog
|
|
||||||
const serverList = useMemo(() => catalog.servers.map(toFrontendServer), [catalog.servers])
|
|
||||||
|
|
||||||
const [issueOpen, setIssueOpen] = useState(false)
|
const [issueOpen, setIssueOpen] = useState(false)
|
||||||
const [issueStep, setIssueStep] = useState(1)
|
const [issueStep, setIssueStep] = useState(1)
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
const [issueBusy, setIssueBusy] = useState(false)
|
||||||
const [issueServerId, setIssueServerId] = useState("")
|
const [issueServerId, setIssueServerId] = useState("")
|
||||||
const [issueCertName, setIssueCertName] = useState("")
|
const [issueCertName, setIssueCertName] = useState("")
|
||||||
const [issueCommonName, setIssueCommonName] = useState("")
|
const [issueCommonName, setIssueCommonName] = useState("")
|
||||||
@@ -385,20 +438,28 @@ function CertificatesContent() {
|
|||||||
const [issueTrustWww, setIssueTrustWww] = useState(true)
|
const [issueTrustWww, setIssueTrustWww] = useState(true)
|
||||||
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
||||||
|
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
|
|
||||||
|
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
|
||||||
|
"https://acme-v02.api.letsencrypt.org/directory",
|
||||||
|
)
|
||||||
|
const [acmeZoneId, setAcmeZoneId] = useState("")
|
||||||
|
const [acmeTokenDraft, setAcmeTokenDraft] = useState("")
|
||||||
|
const [acmeTokenConfigured, setAcmeTokenConfigured] = useState(false)
|
||||||
|
const [acmeSaveBusy, setAcmeSaveBusy] = useState(false)
|
||||||
|
|
||||||
const displayCerts = useMemo(() => {
|
const displayCerts = useMemo(() => {
|
||||||
if (!prefsHydrated) return []
|
if (!prefsHydrated) return []
|
||||||
if (isLive) return Object.values(snapshots).flatMap(snapshot => snapshot.confirmed?.certificates ?? [])
|
if (isLive) return certificates
|
||||||
return routerCertificates.map(mockToDto)
|
return routerCertificates.map(mockToDto)
|
||||||
}, [prefsHydrated, isLive, snapshots])
|
}, [prefsHydrated, isLive, certificates])
|
||||||
|
|
||||||
const displayServers = isLive ? serverList : mockServers
|
const displayServers = isLive ? serverList : mockServers
|
||||||
|
|
||||||
const scopedCerts = useMemo(() => {
|
const scopedCerts = useMemo(() => {
|
||||||
if (urlError) return []
|
|
||||||
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
|
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
|
||||||
return displayCerts.filter((c) => c.serverId === selectedServerId)
|
return displayCerts.filter((c) => c.serverId === selectedServerId)
|
||||||
}, [displayCerts, selectedServerId, urlError])
|
}, [displayCerts, selectedServerId])
|
||||||
|
|
||||||
const certRailItems = useMemo<ServerTileItem[]>(() => (
|
const certRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
displayServers.map((s) => ({
|
displayServers.map((s) => ({
|
||||||
@@ -407,12 +468,12 @@ function CertificatesContent() {
|
|||||||
host: s.host,
|
host: s.host,
|
||||||
site: s.site,
|
site: s.site,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
status: isLive ? catalog.servers.find(server => String(server.id) === s.id)?.status ?? undefined : s.status,
|
status: s.status,
|
||||||
type: s.type,
|
type: s.type,
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
meta: isLive && !snapshots[s.id]?.confirmed ? "—" : String(displayCerts.filter((c) => c.serverId === s.id).length),
|
meta: String(displayCerts.filter((c) => c.serverId === s.id).length),
|
||||||
}))
|
}))
|
||||||
), [displayServers, displayCerts, isLive, catalog.servers, snapshots])
|
), [displayServers, displayCerts])
|
||||||
|
|
||||||
const serverById = useMemo(() => {
|
const serverById = useMemo(() => {
|
||||||
const map = new Map<string, Server>()
|
const map = new Map<string, Server>()
|
||||||
@@ -420,21 +481,69 @@ function CertificatesContent() {
|
|||||||
return map
|
return map
|
||||||
}, [isLive, serverList])
|
}, [isLive, serverList])
|
||||||
|
|
||||||
const loadLive = catalog.reload
|
const loadLive = useCallback(
|
||||||
const issue = useCertificateIssue(backendUrl, liveReady && canRead, liveReady && canWrite, loadLive, isLive)
|
async (silent = false) => {
|
||||||
const issueBusy = issue.busy
|
if (!isLive) return
|
||||||
const issueTerminal = issue.checkpoint?.job?.status === "done" || issue.checkpoint?.job?.status === "failed"
|
if (!silent) setLoadState("loading")
|
||||||
|
setLoadError(null)
|
||||||
|
try {
|
||||||
|
const [certRes, serversRes] = await Promise.all([
|
||||||
|
listCertificates(backendUrl),
|
||||||
|
listServers(backendUrl),
|
||||||
|
])
|
||||||
|
setCertificates(certRes.certificates)
|
||||||
|
setServerList(serversRes.map(toFrontendServer))
|
||||||
|
if (certRes.failures.length > 0 && certRes.certificates.length === 0) {
|
||||||
|
setLoadError(
|
||||||
|
certRes.failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; "),
|
||||||
|
)
|
||||||
|
} else if (certRes.failures.length > 0) {
|
||||||
|
toast.warning(`Часть серверов недоступна: ${certRes.failures.length}`)
|
||||||
|
}
|
||||||
|
setLoadState("idle")
|
||||||
|
} catch (e) {
|
||||||
|
setCertificates([])
|
||||||
|
setLoadError(e instanceof Error ? e.message : "Не удалось загрузить сертификаты")
|
||||||
|
setLoadState("error")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[backendUrl, isLive],
|
||||||
|
)
|
||||||
|
|
||||||
|
const loadAcmeSettings = useCallback(async () => {
|
||||||
|
if (!liveReady) return
|
||||||
|
try {
|
||||||
|
const s = await getAcmeSettings(backendUrl)
|
||||||
|
setAcmeDirectoryUrl(s.directoryUrl)
|
||||||
|
setAcmeZoneId(s.defaultZoneId ?? "")
|
||||||
|
setAcmeTokenConfigured(s.tokenConfigured)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, [backendUrl, liveReady])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setCertificates([])
|
||||||
|
setServerList([])
|
||||||
|
setLoadState("idle")
|
||||||
|
setLoadError(null)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadLive()
|
||||||
|
void loadAcmeSettings()
|
||||||
|
})
|
||||||
|
}, [isLive, loadLive, loadAcmeSettings])
|
||||||
|
|
||||||
const expiring = useMemo(
|
const expiring = useMemo(
|
||||||
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30),
|
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||||
[scopedCerts],
|
[scopedCerts],
|
||||||
)
|
)
|
||||||
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
|
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
|
||||||
|
|
||||||
const scopeSnapshots = Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId)
|
|
||||||
const scopeKnown = !urlError && (!isLive && (selectedServerId === ALL_SERVERS_ID || mockServers.some(server => server.id === selectedServerId)) || canRead && catalog.hasState && !loadError && (selectedServerId === ALL_SERVERS_ID || !!snapshots[selectedServerId]) && scopeSnapshots.every(snapshot => snapshot.latest.error === null))
|
|
||||||
const classificationsKnown = scopeKnown && scopedCerts.every(cert => cert.status !== "unknown" && cert.daysLeft !== null)
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return scopedCerts.filter((c) => {
|
return scopedCerts.filter((c) => {
|
||||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||||
@@ -450,19 +559,93 @@ function CertificatesContent() {
|
|||||||
}, [scopedCerts, search, statusFilter])
|
}, [scopedCerts, search, statusFilter])
|
||||||
|
|
||||||
async function handleRefresh() {
|
async function handleRefresh() {
|
||||||
await loadLive()
|
if (!liveReady) return
|
||||||
|
try {
|
||||||
|
const res = await refreshCertificates(backendUrl)
|
||||||
|
setCertificates(res.certificates)
|
||||||
|
toast.success("Список сертификатов обновлён")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Ошибка обновления")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollIssueJob(jobId: string) {
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000))
|
||||||
|
const job = await getCertificateIssueJob(backendUrl, jobId)
|
||||||
|
if (job.status === "done") {
|
||||||
|
toast.success("Сертификат выпущен и импортирован на роутер")
|
||||||
|
setIssueOpen(false)
|
||||||
|
await loadLive(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (job.status === "failed") {
|
||||||
|
throw new Error(job.error ?? "Выпуск не удался")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Таймаут ожидания выпуска сертификата")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleIssue() {
|
async function handleIssue() {
|
||||||
if (issue.checkpoint) { await issue.run(); return }
|
if (!liveReady) return
|
||||||
if (!liveReady || !canWrite || !canRead || urlError || !catalog.hasServers || serverError || !catalog.servers.some(server => String(server.id) === issueServerId && server.enabled)) return
|
const domains = [
|
||||||
const domains = [issueCommonName.trim(), ...issueSans.split(/[\n,;]+/).map(value => value.trim())].filter(Boolean)
|
issueCommonName.trim(),
|
||||||
if (!issueServerId || !issueCertName.trim() || domains.length === 0) { toast.error("Укажите сервер, имя сертификата и домены"); return }
|
...issueSans.split(/[\n,;]+/).map((s) => s.trim()),
|
||||||
await issue.run({ serverId: issueServerId, certName: issueCertName.trim(), domainNames: domains, trustStore: [...(issueTrustWww ? ["www" as const] : []), ...(issueTrustApi ? ["api" as const] : [])] })
|
].filter(Boolean)
|
||||||
|
if (!issueServerId || !issueCertName.trim() || domains.length === 0) {
|
||||||
|
toast.error("Укажите сервер, имя сертификата и домены")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIssueBusy(true)
|
||||||
|
try {
|
||||||
|
const trustStore: Array<"www" | "api"> = []
|
||||||
|
if (issueTrustWww) trustStore.push("www")
|
||||||
|
if (issueTrustApi) trustStore.push("api")
|
||||||
|
const { jobId } = await createCertificateIssueJob(backendUrl, {
|
||||||
|
serverId: issueServerId,
|
||||||
|
certName: issueCertName.trim(),
|
||||||
|
domainNames: domains,
|
||||||
|
trustStore,
|
||||||
|
})
|
||||||
|
toast.message("Выпуск сертификата запущен…")
|
||||||
|
await pollIssueJob(jobId)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Ошибка выпуска")
|
||||||
|
} finally {
|
||||||
|
setIssueBusy(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function startNewIssue() {
|
|
||||||
if (!issue.reset()) return
|
async function handleSaveAcmeSettings() {
|
||||||
setIssueCertName(""); setIssueCommonName(""); setIssueSans(""); setIssueStep(1)
|
if (!liveReady) return
|
||||||
|
setAcmeSaveBusy(true)
|
||||||
|
try {
|
||||||
|
const saved = await putAcmeSettings(backendUrl, {
|
||||||
|
directoryUrl: acmeDirectoryUrl.trim(),
|
||||||
|
defaultZoneId: acmeZoneId.trim() || null,
|
||||||
|
cloudflareApiToken: acmeTokenDraft.trim() ? acmeTokenDraft.trim() : undefined,
|
||||||
|
})
|
||||||
|
setAcmeTokenConfigured(saved.tokenConfigured)
|
||||||
|
setAcmeTokenDraft("")
|
||||||
|
toast.success("Настройки ACME сохранены")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось сохранить настройки")
|
||||||
|
} finally {
|
||||||
|
setAcmeSaveBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTestAcme() {
|
||||||
|
if (!liveReady) return
|
||||||
|
try {
|
||||||
|
const res = await testAcmeSettings(backendUrl, {
|
||||||
|
cloudflareApiToken: acmeTokenDraft.trim() || undefined,
|
||||||
|
})
|
||||||
|
if (res.ok) toast.success(res.message ?? "Cloudflare API доступен")
|
||||||
|
else toast.error(res.message ?? "Проверка не прошла")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Проверка Cloudflare не удалась")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -473,8 +656,7 @@ function CertificatesContent() {
|
|||||||
onSelect={setSelectedServerId}
|
onSelect={setSelectedServerId}
|
||||||
showAll
|
showAll
|
||||||
allCount={displayServers.length}
|
allCount={displayServers.length}
|
||||||
preserveSelection
|
loading={isLive && loadState === "loading" && displayServers.length === 0}
|
||||||
loading={isLive && loading && displayServers.length === 0}
|
|
||||||
header={
|
header={
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||||
@@ -484,59 +666,47 @@ function CertificatesContent() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!isLive || loading || (!canRead && !canReadServers)}
|
disabled={!liveReady || loadState === "loading"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleRefresh()
|
void handleRefresh()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" disabled title="Импорт сертификатов пока недоступен">
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
<UploadIcon className="size-4" />
|
<UploadIcon className="size-4" />
|
||||||
Импорт
|
Импорт
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!issue.hydrated || !liveReady || !canRead || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
|
disabled={!liveReady || issueBusy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIssueStep(issue.checkpoint ? 4 : 1)
|
setIssueStep(1)
|
||||||
if (!issue.checkpoint && selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
||||||
setIssueOpen(true)
|
setIssueOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
{issue.checkpoint ? "Задача выпуска" : "Выпустить сертификат"}
|
Выпустить сертификат
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-5 min-w-0">
|
<div className="flex flex-col gap-5">
|
||||||
{!isLive && <p role="status" className="text-xs text-muted-foreground">Моковые данные</p>}
|
|
||||||
{urlError && <div role="alert" aria-label="Контекст сертификатов">{urlError} <Button variant="outline" size="sm" onClick={() => writeContext({ server: null, status: null, q: null }, true)}>Сбросить контекст</Button></div>}
|
|
||||||
{!urlError && selectedServerId !== ALL_SERVERS_ID && (isLive ? catalog.hasState && !snapshots[selectedServerId] : !mockServers.some(server => server.id === selectedServerId)) && <p role="alert">Выбранный сервер недоступен в каталоге сертификатов. Выберите другой сервер.</p>}
|
|
||||||
{isLive && backendStatus === false && (
|
{isLive && backendStatus === false && (
|
||||||
<div className="rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
|
<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">
|
||||||
Backend недоступен — live-операции отключены.
|
Backend недоступен — live-операции отключены.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loadError && isLive && (
|
{loadError && isLive && (
|
||||||
<div role="alert" aria-label="Каталог сертификатов" className="[overflow-wrap:anywhere] rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||||
{catalog.hasState ? "Последние данные сохранены. " : "Сертификаты пока неизвестны. "}{loadError}
|
{loadError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLive && serverError && <p role="alert" aria-label="Серверы сертификатов" className="[overflow-wrap:anywhere]">{catalog.hasServers ? "Последние серверы сохранены. " : "Каталог серверов пока неизвестен. "}{serverError}</p>}
|
|
||||||
{isLive && !canRead && <p role="status">Нет права mm:certificates:read.</p>}
|
|
||||||
{isLive && !canReadServers && <p role="status">Нет права mm:servers:read. Выбор устройства для выпуска недоступен.</p>}
|
|
||||||
{isLive && catalog.collectedAt && <p className="text-xs text-muted-foreground">Последнее чтение каталога: {new Date(catalog.collectedAt).toLocaleString("ru-RU")}</p>}
|
|
||||||
{isLive && Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId).map(snapshot => <div key={snapshot.latest.serverId}>
|
|
||||||
{snapshot.latest.error && <p role="alert" aria-label={"Сертификаты сервера " + snapshot.latest.serverId} className="[overflow-wrap:anywhere]">{snapshot.latest.serverName}: {snapshot.latest.error}. {snapshot.confirmed ? "Последние сертификаты сохранены." : "Сертификаты пока неизвестны."}</p>}
|
|
||||||
<p className="text-xs text-muted-foreground">{snapshot.latest.serverName}: {snapshot.confirmed ? "Подтверждённые данные от " + new Date(snapshot.confirmed.collectedAt).toLocaleString("ru-RU") : "Успешного чтения нет"}</p>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
{(expiring.length > 0 || expired.length > 0) && (
|
{(expiring.length > 0 || expired.length > 0) && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{expired.length > 0 && <CertPartAlertExpired expired={expired} />}
|
{expired.length > 0 && <CertPartAlertExpired expired={expired} />}
|
||||||
@@ -544,38 +714,51 @@ function CertificatesContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!classificationsKnown && <p role="status" className="text-xs text-muted-foreground">Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.</p>}
|
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
||||||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} complete={classificationsKnown} totalKnown={scopeKnown} />
|
|
||||||
|
|
||||||
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
|
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} />
|
||||||
|
|
||||||
<CertificateAcmeSettings backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
|
{liveReady && (
|
||||||
|
<CertPartAcmeSettings
|
||||||
|
acmeDirectoryUrl={acmeDirectoryUrl}
|
||||||
|
setAcmeDirectoryUrl={setAcmeDirectoryUrl}
|
||||||
|
acmeZoneId={acmeZoneId}
|
||||||
|
setAcmeZoneId={setAcmeZoneId}
|
||||||
|
acmeTokenDraft={acmeTokenDraft}
|
||||||
|
setAcmeTokenDraft={setAcmeTokenDraft}
|
||||||
|
acmeTokenConfigured={acmeTokenConfigured}
|
||||||
|
acmeSaveBusy={acmeSaveBusy}
|
||||||
|
onTest={() => {
|
||||||
|
void handleTestAcme()
|
||||||
|
}}
|
||||||
|
onSave={() => {
|
||||||
|
void handleSaveAcmeSettings()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по имени, CN, эмитенту…"
|
searchPlaceholder="Поиск по имени, CN, эмитенту…"
|
||||||
filterControl={<div role="group" aria-label="Статус сертификатов" className="w-full sm:w-auto max-w-full overflow-x-auto"><SegmentedControl className="whitespace-nowrap"
|
segmented={{
|
||||||
value={statusFilter}
|
value: statusFilter,
|
||||||
onChange={setStatusFilter}
|
onChange: setStatusFilter,
|
||||||
options={[
|
options: [
|
||||||
{ value: "all", label: "Все" },
|
{ value: "all", label: "Все" },
|
||||||
{ value: "valid", label: "Действующие" },
|
{ value: "valid", label: "Действующие" },
|
||||||
{ value: "expired", label: "Истёкшие" },
|
{ value: "expired", label: "Истёкшие" },
|
||||||
{ value: "revoked", label: "Отозванные" },
|
{ value: "revoked", label: "Отозванные" },
|
||||||
{ value: "unknown", label: "Неизвестные" },
|
],
|
||||||
]}
|
}}
|
||||||
/></div>}
|
countLabel={`${filtered.length} сертификатов`}
|
||||||
countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`}
|
|
||||||
/>
|
/>
|
||||||
{urlError ? <p role="status" className="p-5">Исправьте контекст ссылки</p> : isLive && scopedCerts.length === 0 && !scopeKnown ? <p role="status" className="p-5">{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}</p> : <CertificatesDataGrid
|
<CertificatesDataGrid
|
||||||
certificates={filtered}
|
certificates={filtered}
|
||||||
serverMap={serverById}
|
serverMap={serverById}
|
||||||
emptyTitle={search || statusFilter !== "all" ? "Нет совпадений" : "Нет сертификатов"}
|
isLoading={prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0}
|
||||||
emptyDescription={search || statusFilter !== "all" ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}
|
/>
|
||||||
isLoading={isLive && loading && displayCerts.length === 0}
|
|
||||||
/>}
|
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
<CertPartReference />
|
<CertPartReference />
|
||||||
@@ -583,7 +766,7 @@ function CertificatesContent() {
|
|||||||
</ServerRailLayout>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
||||||
<SheetContent side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg min-w-0 flex flex-col gap-0 p-0">
|
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>Выпуск сертификата</SheetTitle>
|
<SheetTitle>Выпуск сертификата</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
@@ -591,11 +774,6 @@ function CertificatesContent() {
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="px-6 pt-3 text-sm min-w-0 [overflow-wrap:anywhere]">
|
|
||||||
{(issue.error || issue.storageError) && <p role="alert" aria-label="Выпуск сертификата">{issue.error || issue.storageError}</p>}
|
|
||||||
{issue.checkpoint && <p className="text-xs text-muted-foreground">Закрытие формы не отменяет серверную задачу. Её можно проверить повторно.</p>}
|
|
||||||
{issue.checkpoint && <p role="status">Задача {issue.checkpoint.request.requestId}: {issue.checkpoint.job ? ({ queued: "В очереди", running: "Выполняется", done: "Завершена", failed: "Ошибка" }[issue.checkpoint.job.status]) + " · " + issue.checkpoint.job.step : issue.checkpoint.accepted ? "Принята; результат пока неизвестен" : "Принятие запроса пока не подтверждено"}. Сервер {issue.checkpoint.request.serverId}, сертификат {issue.checkpoint.request.certName}.</p>}
|
|
||||||
</div>
|
|
||||||
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
<StepperNav className="mb-5">
|
<StepperNav className="mb-5">
|
||||||
{[
|
{[
|
||||||
@@ -616,33 +794,30 @@ function CertificatesContent() {
|
|||||||
<StepperPanel className="flex-1 overflow-y-auto">
|
<StepperPanel className="flex-1 overflow-y-auto">
|
||||||
{[1, 2, 3, 4].map((s) => (
|
{[1, 2, 3, 4].map((s) => (
|
||||||
<StepperContent key={s} value={s}>
|
<StepperContent key={s} value={s}>
|
||||||
<fieldset disabled={issueBusy || !!issue.checkpoint} className="min-w-0">
|
|
||||||
<CertPartIssueForm
|
<CertPartIssueForm
|
||||||
step={s as 1 | 2 | 3 | 4}
|
step={s as 1 | 2 | 3 | 4}
|
||||||
serverList={displayServers}
|
serverList={displayServers}
|
||||||
issueServerId={String(issue.checkpoint?.request.serverId ?? issueServerId)}
|
issueServerId={issueServerId}
|
||||||
setIssueServerId={setIssueServerId}
|
setIssueServerId={setIssueServerId}
|
||||||
issueCertName={issue.checkpoint?.request.certName ?? issueCertName}
|
issueCertName={issueCertName}
|
||||||
setIssueCertName={setIssueCertName}
|
setIssueCertName={setIssueCertName}
|
||||||
issueCommonName={issue.checkpoint?.request.domainNames[0] ?? issueCommonName}
|
issueCommonName={issueCommonName}
|
||||||
setIssueCommonName={setIssueCommonName}
|
setIssueCommonName={setIssueCommonName}
|
||||||
issueSans={issue.checkpoint ? issue.checkpoint.request.domainNames.slice(1).join("\n") : issueSans}
|
issueSans={issueSans}
|
||||||
setIssueSans={setIssueSans}
|
setIssueSans={setIssueSans}
|
||||||
issueTrustWww={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("www") ?? true : issueTrustWww}
|
issueTrustWww={issueTrustWww}
|
||||||
setIssueTrustWww={setIssueTrustWww}
|
setIssueTrustWww={setIssueTrustWww}
|
||||||
issueTrustApi={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("api") ?? true : issueTrustApi}
|
issueTrustApi={issueTrustApi}
|
||||||
setIssueTrustApi={setIssueTrustApi}
|
setIssueTrustApi={setIssueTrustApi}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
|
||||||
</StepperContent>
|
</StepperContent>
|
||||||
))}
|
))}
|
||||||
</StepperPanel>
|
</StepperPanel>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
{issueTerminal && canWrite && <Button variant="outline" disabled={issueBusy} onClick={startNewIssue}>Новый выпуск</Button>}
|
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}>
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>
|
Отмена
|
||||||
{issue.checkpoint ? "Закрыть" : "Отмена"}
|
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
{issueStep > 1 && (
|
{issueStep > 1 && (
|
||||||
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
|
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
|
||||||
@@ -652,7 +827,7 @@ function CertificatesContent() {
|
|||||||
{issueStep < 4 ? (
|
{issueStep < 4 ? (
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={issueBusy || !issue.checkpoint && issueStep === 1 && (!issueServerId || !issueCertName)}
|
disabled={issueStep === 1 && (!issueServerId || !issueCertName)}
|
||||||
onClick={() => setIssueStep((s) => s + 1)}
|
onClick={() => setIssueStep((s) => s + 1)}
|
||||||
>
|
>
|
||||||
Далее
|
Далее
|
||||||
@@ -660,16 +835,26 @@ function CertificatesContent() {
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!issue.hydrated || !liveReady || !canRead || issueBusy || issueTerminal || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
|
disabled={!liveReady || issueBusy}
|
||||||
onClick={() => { void handleIssue() }}
|
onClick={() => { void handleIssue() }}
|
||||||
>
|
>
|
||||||
{issueBusy ? "Проверяем…" : issueTerminal ? "Задача завершена" : issue.checkpoint ? "Продолжить проверку" : "Выпустить"}
|
{issueBusy ? "Выпуск…" : "Выпустить"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт сертификата"
|
||||||
|
description="Загрузите PEM, CRT или PKCS#12 для импорта на RouterOS"
|
||||||
|
accept=".pem,.crt,.cer,.p12,.pfx"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useMemo } from "react"
|
import { useState, useMemo, useEffect } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import {
|
import {
|
||||||
@@ -8,20 +8,21 @@ import {
|
|||||||
type CommunityRow,
|
type CommunityRow,
|
||||||
TYPE_LABELS,
|
TYPE_LABELS,
|
||||||
ACTION_LABELS,
|
ACTION_LABELS,
|
||||||
|
ACTION_COLOR,
|
||||||
} from "@/components/data-grids/communities-data-grid"
|
} from "@/components/data-grids/communities-data-grid"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
PlusIcon, SearchIcon, TagIcon, FilterIcon,
|
PlusIcon, SearchIcon, TagIcon, FilterIcon,
|
||||||
CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { filters } from "@/lib/data"
|
import { filters } from "@/lib/data"
|
||||||
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -97,31 +98,24 @@ const COMMUNITIES: Community[] = [
|
|||||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CommunitiesPage() {
|
export default function CommunitiesPage() {
|
||||||
const { sourceKey } = useEvoBGP()
|
const { mode } = useDataSource()
|
||||||
return <CommunitiesContent key={sourceKey} />
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||||
}
|
const useEvoCatalog = mode === "live" && enabled
|
||||||
function CommunitiesContent() {
|
|
||||||
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
|
||||||
|
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
||||||
const [copied, setCopied] = useState<string | null>(null)
|
const [copied, setCopied] = useState<string | null>(null)
|
||||||
const [copying, setCopying] = useState(false)
|
const [selected, setSelected] = useState<Community | null>(null)
|
||||||
const [copyError, setCopyError] = useState<string | null>(null)
|
|
||||||
const copyRequest = useRef({ active: true, pending: false, revision: 0, timer: null as ReturnType<typeof setTimeout> | null })
|
|
||||||
useEffect(() => {
|
|
||||||
const state = copyRequest.current; state.active = true
|
|
||||||
return () => { state.active = false; state.revision++; if (state.timer) clearTimeout(state.timer) }
|
|
||||||
}, [])
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const listData = useMemo((): Community[] => {
|
const listData = useMemo((): Community[] => {
|
||||||
if (mode === "mock") return COMMUNITIES
|
if (!useEvoCatalog) return COMMUNITIES
|
||||||
if (loading && !snapshot) return []
|
if (loading && !snapshot) return []
|
||||||
return snapshot?.communities ?? []
|
return snapshot?.communities ?? []
|
||||||
}, [mode, loading, snapshot])
|
}, [useEvoCatalog, loading, snapshot])
|
||||||
|
|
||||||
const selected = known ? listData.find(row => row.id === selectedId) ?? null : null
|
useEffect(() => {
|
||||||
|
setSelected(null)
|
||||||
|
}, [useEvoCatalog])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
@@ -132,24 +126,10 @@ function CommunitiesContent() {
|
|||||||
})
|
})
|
||||||
}, [search, typeFilter, listData])
|
}, [search, typeFilter, listData])
|
||||||
|
|
||||||
const handleCopy = async (value: string) => {
|
const handleCopy = (value: string) => {
|
||||||
const state = copyRequest.current
|
navigator.clipboard.writeText(value).catch(() => {})
|
||||||
if (!state.active || state.pending) return
|
setCopied(value)
|
||||||
state.pending = true; const revision = ++state.revision
|
setTimeout(() => setCopied(null), 1500)
|
||||||
if (state.timer) clearTimeout(state.timer)
|
|
||||||
const current = () => state.active && state.revision === revision
|
|
||||||
setCopying(true); setCopied(null); setCopyError(null)
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value)
|
|
||||||
if (!current()) return
|
|
||||||
setCopied(value)
|
|
||||||
state.timer = setTimeout(() => { if (current()) setCopied(null) }, 1500)
|
|
||||||
} catch {
|
|
||||||
if (current()) setCopyError("Браузер не разрешил запись в буфер обмена. Повторите копирование или выделите значение вручную.")
|
|
||||||
} finally {
|
|
||||||
state.pending = false
|
|
||||||
if (current()) setCopying(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
|
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
|
||||||
@@ -158,7 +138,8 @@ function CommunitiesContent() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
|
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
|
||||||
actions={mode === "live" ? null : <Button size="sm">
|
actions={
|
||||||
|
<Button size="sm">
|
||||||
<PlusIcon className="size-4" />Добавить
|
<PlusIcon className="size-4" />Добавить
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -166,10 +147,19 @@ function CommunitiesContent() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<EvoBgpCatalogStatus />
|
{useEvoCatalog && (
|
||||||
{copyError && <Alert variant="warning"><AlertTitle>Community не скопировано</AlertTitle><AlertDescription>{copyError}</AlertDescription></Alert>}
|
<p className={cn(
|
||||||
{copying && <p role="status" className="text-xs text-muted-foreground">Копирование community…</p>}
|
"text-xs flex items-center gap-1.5 -mt-1",
|
||||||
{copied && <p role="status" className="text-xs text-muted-foreground">Community {copied} скопировано</p>}
|
error ? "text-destructive" : "text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||||
|
{error
|
||||||
|
? `EvoBGP: ${error}`
|
||||||
|
: snapshot?.fetchedAt
|
||||||
|
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||||
|
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка communities"
|
aria-label="Сводка communities"
|
||||||
@@ -177,35 +167,35 @@ function CommunitiesContent() {
|
|||||||
{
|
{
|
||||||
id: "all",
|
id: "all",
|
||||||
label: "Всего communities",
|
label: "Всего communities",
|
||||||
value: known ? String(listData.length) : "—",
|
value: String(listData.length),
|
||||||
icon: <TagIcon className="size-4" />,
|
icon: <TagIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "enabled",
|
id: "enabled",
|
||||||
label: "Активных",
|
label: "Активных",
|
||||||
value: known ? String(listData.filter((c) => c.enabled).length) : "—",
|
value: String(listData.filter((c) => c.enabled).length),
|
||||||
icon: <CheckIcon className="size-4" />,
|
icon: <CheckIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "standard",
|
id: "standard",
|
||||||
label: "Стандартных",
|
label: "Стандартных",
|
||||||
value: known ? String(listData.filter((c) => c.type === "standard").length) : "—",
|
value: String(listData.filter((c) => c.type === "standard").length),
|
||||||
icon: <TagIcon className="size-4" />,
|
icon: <TagIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "filters",
|
id: "filters",
|
||||||
label: "Использует фильтры",
|
label: "Использует фильтры",
|
||||||
value: mode === "mock" ? String(new Set(listData.flatMap((c) => c.filterIds)).size) : "—",
|
value: String(new Set(listData.flatMap((c) => c.filterIds)).size),
|
||||||
icon: <FilterIcon className="size-4" />,
|
icon: <FilterIcon className="size-4" />,
|
||||||
iconClassName: "text-primary",
|
iconClassName: "text-primary",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-5">
|
<div className="grid grid-cols-[1fr_320px] gap-5">
|
||||||
{/* ── main table ── */}
|
{/* ── main table ── */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* toolbar */}
|
{/* toolbar */}
|
||||||
@@ -214,7 +204,7 @@ function CommunitiesContent() {
|
|||||||
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
|
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
|
||||||
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
|
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex gap-1">
|
||||||
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
|
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
|
||||||
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
|
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
|
||||||
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
|
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
|
||||||
@@ -226,14 +216,13 @@ function CommunitiesContent() {
|
|||||||
|
|
||||||
{/* list */}
|
{/* list */}
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
{known ? <CommunitiesDataGrid
|
<CommunitiesDataGrid
|
||||||
communities={filtered}
|
communities={filtered}
|
||||||
selectedId={selected?.id}
|
selectedId={selected?.id}
|
||||||
copiedValue={copied}
|
copiedValue={copied}
|
||||||
copying={copying}
|
onSelect={setSelected}
|
||||||
onSelect={row => setSelectedId(row.id)}
|
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
/> : <EvoBgpCatalogUnavailable />}
|
/>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -245,8 +234,8 @@ function CommunitiesContent() {
|
|||||||
description={selected.name}
|
description={selected.name}
|
||||||
headerRight={
|
headerRight={
|
||||||
<div className="flex gap-1 shrink-0">
|
<div className="flex gap-1 shrink-0">
|
||||||
<Button size="sm" variant="ghost" aria-label="Изменить community" disabled className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
||||||
<Button size="sm" variant="ghost" aria-label="Удалить community" disabled className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
|
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
|
||||||
@@ -267,7 +256,7 @@ function CommunitiesContent() {
|
|||||||
{[
|
{[
|
||||||
["Тип", TYPE_LABELS[selected.type]],
|
["Тип", TYPE_LABELS[selected.type]],
|
||||||
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
|
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
|
||||||
["Маршрутов", selected.prefixCount?.toLocaleString("ru-RU") ?? "—"],
|
["Маршрутов", selected.prefixCount.toLocaleString("ru-RU")],
|
||||||
["Серверов", selected.serverCount.toLocaleString("ru-RU")],
|
["Серверов", selected.serverCount.toLocaleString("ru-RU")],
|
||||||
].map(([k, v]) => (
|
].map(([k, v]) => (
|
||||||
<div key={k} className="flex justify-between gap-2">
|
<div key={k} className="flex justify-between gap-2">
|
||||||
@@ -293,7 +282,7 @@ function CommunitiesContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pt-2 border-t flex flex-col gap-2">
|
<div className="pt-2 border-t flex flex-col gap-2">
|
||||||
<Button size="sm" variant="outline" className="w-full justify-start gap-2" disabled={copying} onClick={() => { void handleCopy(selected.value) }}>
|
<Button size="sm" variant="outline" className="w-full justify-start gap-2" onClick={() => handleCopy(selected.value)}>
|
||||||
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||||
Скопировать значение
|
Скопировать значение
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+234
-238
@@ -1,8 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { readContainersUrl, patchContainersUrl, type ContainerStatusFilter } from "@/lib/containers-url-state"
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
|
import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
|
||||||
import type { RouterContainer, Server } from "@/lib/data"
|
import type { RouterContainer, Server } from "@/lib/data"
|
||||||
@@ -12,7 +10,6 @@ import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
|||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
@@ -27,18 +24,10 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { mutateContainer, type ContainerAction, readContainersState } from "@/shared/api/containers"
|
|
||||||
import { serverListSchema } from "@mmapp/contracts/servers"
|
|
||||||
import type { ContainerServerState, ContainersState } from "@mmapp/contracts/containers"
|
|
||||||
import { can } from "@/lib/auth"
|
|
||||||
import { userApiError } from "@/shared/api/users"
|
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
type ContainerView = Omit<RouterContainer, "status" | "startOnBoot"> & { status: RouterContainer["status"] | "unknown"; startOnBoot: boolean | null }
|
|
||||||
type ContainerServer = Omit<Server, "status"> & {status: Server["status"] | null}
|
|
||||||
|
|
||||||
interface BackendServer {
|
interface BackendServer {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -48,11 +37,15 @@ interface BackendServer {
|
|||||||
country: string
|
country: string
|
||||||
asn?: string
|
asn?: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
status?: Server["status"] | null
|
status?: Server["status"]
|
||||||
latency?: number | null
|
latency?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapBackendServer(s: BackendServer): ContainerServer {
|
interface ContainersApiResponse {
|
||||||
|
containers: RouterContainer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBackendServer(s: BackendServer): Server {
|
||||||
return {
|
return {
|
||||||
id: String(s.id),
|
id: String(s.id),
|
||||||
name: s.name || s.host,
|
name: s.name || s.host,
|
||||||
@@ -64,44 +57,103 @@ function mapBackendServer(s: BackendServer): ContainerServer {
|
|||||||
asn: s.asn ?? "",
|
asn: s.asn ?? "",
|
||||||
type: s.type ?? "exit-node",
|
type: s.type ?? "exit-node",
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
status: s.status ?? null,
|
status: s.status ?? "online",
|
||||||
latency: s.latency ?? null,
|
latency: s.latency ?? null,
|
||||||
sessions: 0,
|
sessions: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusConfig(status: ContainerView["status"]) {
|
function statusConfig(status: RouterContainer["status"]) {
|
||||||
return {
|
return {
|
||||||
unknown: { dot: "bg-muted-foreground", badge: "bg-muted text-muted-foreground border-border", label: "Неизвестно" },
|
|
||||||
running: {
|
running: {
|
||||||
dot: "bg-success",
|
dot: "bg-emerald-500 animate-pulse",
|
||||||
badge: "bg-success/10 text-success border-success/20",
|
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||||
label: "Работает",
|
label: "Running",
|
||||||
},
|
},
|
||||||
stopped: {
|
stopped: {
|
||||||
dot: "bg-muted-foreground",
|
dot: "bg-muted-foreground",
|
||||||
badge: "bg-muted/50 text-muted-foreground border-border",
|
badge: "bg-muted/50 text-muted-foreground border-border",
|
||||||
label: "Остановлен",
|
label: "Stopped",
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
dot: "bg-destructive",
|
dot: "bg-red-500 animate-pulse",
|
||||||
badge: "bg-destructive/10 text-destructive border-destructive/20",
|
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||||
label: "Ошибка",
|
label: "Error",
|
||||||
},
|
},
|
||||||
}[status]
|
}[status]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContainerExport = { container: ContainerView; collectedAt: string | null; failures: ContainerServerState["failures"]; source: "live" | "mock" }
|
function generateContainerRsc(c: RouterContainer, serverById: Record<string, Server>): string {
|
||||||
function ExportSheet({ selection, onClose }: {selection: ContainerExport | null; onClose: () => void}) {
|
const srv = serverById[c.serverId]
|
||||||
const formats = useMemo(() => [{
|
const lines: string[] = []
|
||||||
id: "json", label: "Снимок JSON", filename: "container-" + (selection?.container.id ?? "snapshot").replace(/[^a-zA-Z0-9_-]/g, "_") + ".json",
|
lines.push(`# RouterOS Container — ${c.name}`)
|
||||||
code: selection ? JSON.stringify(selection, null, 2) : "",
|
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
||||||
}], [selection])
|
lines.push(`# Образ: ${c.image}:${c.tag}`)
|
||||||
return <CodeExportSheet open={!!selection} onClose={onClose} title="Снимок контейнера" description="Данные каталога; конфигурация устройства и резервная копия в снимок не входят."
|
lines.push(`# RouterOS 7.4+ · /container`)
|
||||||
className="data-[side=right]:w-full data-[side=right]:sm:max-w-xl min-w-0 [&_[data-slot=sheet-footer]]:flex-wrap"
|
lines.push(``)
|
||||||
beforeCode={<p className="text-xs text-muted-foreground">{selection?.source === "mock" ? "Демонстрационные данные." : "Чтение RouterOS: " + (selection?.collectedAt ? new Date(selection.collectedAt).toLocaleString("ru-RU") : "время неизвестно")}{selection?.failures.length ? " Настройки получены частично; ошибки включены в JSON." : ""}</p>}
|
|
||||||
returnFocus={() => Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === "Действия контейнера " + selection?.container.name) ?? null}
|
for (const iface of c.interfaces) {
|
||||||
formats={formats} />
|
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
|
||||||
|
}
|
||||||
|
lines.push(``)
|
||||||
|
|
||||||
|
if (c.envs.length > 0) {
|
||||||
|
lines.push(`/container/envs/add name=${c.name}-envs \\`)
|
||||||
|
for (const { key, value } of c.envs) {
|
||||||
|
lines.push(` ${key}="${value}" \\`)
|
||||||
|
}
|
||||||
|
lines.push(``)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of c.mounts) {
|
||||||
|
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
|
||||||
|
if (m.src) lines.push(` src=${m.src} \\`)
|
||||||
|
lines.push(` dst=${m.dst}`)
|
||||||
|
lines.push(``)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`/container/add \\`)
|
||||||
|
lines.push(` remote-image=${c.image}:${c.tag} \\`)
|
||||||
|
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
|
||||||
|
if (c.envs.length > 0) lines.push(` envlist=${c.name}-envs \\`)
|
||||||
|
if (c.mounts.length > 0) lines.push(` mounts=${c.mounts.map((m) => `${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)}`).join(",")} \\`)
|
||||||
|
if (c.cmd) lines.push(` cmd="${c.cmd}" \\`)
|
||||||
|
if (c.startOnBoot) lines.push(` start-on-boot=yes \\`)
|
||||||
|
if (c.comment) lines.push(` comment="${c.comment}" \\`)
|
||||||
|
lines.push(` logging=yes`)
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExportSheet({
|
||||||
|
open, container, onClose, serverById,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
container: RouterContainer | null
|
||||||
|
onClose: () => void
|
||||||
|
serverById: Record<string, Server>
|
||||||
|
}) {
|
||||||
|
const code = useMemo(
|
||||||
|
() => (container ? generateContainerRsc(container, serverById) : ""),
|
||||||
|
[container, serverById],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContainerCard({
|
function ContainerCard({
|
||||||
@@ -115,8 +167,8 @@ function ContainerCard({
|
|||||||
onRestart,
|
onRestart,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
container: ContainerView
|
container: RouterContainer
|
||||||
server?: ContainerServer
|
server?: Server
|
||||||
live: boolean
|
live: boolean
|
||||||
busy: boolean
|
busy: boolean
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
@@ -126,7 +178,7 @@ function ContainerCard({
|
|||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
}) {
|
}) {
|
||||||
const cfg = statusConfig(container.status)
|
const cfg = statusConfig(container.status)
|
||||||
const canMutate = live && Boolean(container.rosId) && !busy
|
const canMutate = live && Boolean(container.rosId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense className="w-full overflow-hidden">
|
<Frame dense className="w-full overflow-hidden">
|
||||||
@@ -141,26 +193,26 @@ function ContainerCard({
|
|||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger render={
|
<DropdownMenuTrigger render={
|
||||||
<Button variant="ghost" size="icon" aria-label={"Действия контейнера " + container.name} className="size-11 sm:size-8 shrink-0" disabled={busy}>
|
<Button variant="ghost" size="icon" className="size-7 shrink-0" disabled={busy}>
|
||||||
<MoreHorizontalIcon aria-hidden="true" className="size-4" />
|
<MoreHorizontalIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
} />
|
} />
|
||||||
<DropdownMenuContent side="bottom" align="end">
|
<DropdownMenuContent side="bottom" align="end">
|
||||||
{container.status === "running" ? (
|
{container.status === "running" ? (
|
||||||
<DropdownMenuItem disabled={!canMutate} onClick={onStop}>
|
<DropdownMenuItem disabled={!canMutate} onClick={onStop}>
|
||||||
<StopCircleIcon aria-hidden="true" className="size-4 text-warning" />Остановить
|
<StopCircleIcon className="size-4 text-amber-500" />Остановить
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onStart}>
|
<DropdownMenuItem disabled={!canMutate} onClick={onStart}>
|
||||||
<PlayIcon aria-hidden="true" className="size-4 text-success" />Запустить
|
<PlayIcon className="size-4 text-emerald-500" />Запустить
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem disabled><TerminalIcon aria-hidden="true" className="size-4" />Логи</DropdownMenuItem>
|
<DropdownMenuItem disabled><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
|
||||||
<DropdownMenuItem disabled><PencilIcon aria-hidden="true" className="size-4" />Редактировать</DropdownMenuItem>
|
<DropdownMenuItem disabled><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon aria-hidden="true" className="size-4" />Снимок JSON</DropdownMenuItem>
|
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onRestart}>
|
<DropdownMenuItem disabled={!canMutate} onClick={onRestart}>
|
||||||
<PowerIcon aria-hidden="true" className="size-4" />Перезапустить
|
<PowerIcon className="size-4" />Перезапустить
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
|
<DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
|
||||||
@@ -172,17 +224,17 @@ function ContainerCard({
|
|||||||
|
|
||||||
<div className="px-4 py-3 flex flex-col gap-3">
|
<div className="px-4 py-3 flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BoxIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
<span className="font-mono text-xs text-foreground/80 min-w-0 [overflow-wrap:anywhere]">
|
<span className="font-mono text-xs text-foreground/80">
|
||||||
{container.image || "Образ неизвестен"}{container.tag && <>:<span className="text-muted-foreground">{container.tag}</span></>}
|
{container.image}:<span className="text-muted-foreground">{container.tag}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{server && (
|
{server && (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<ServerIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
<ServerIcon className="size-3.5 shrink-0" />
|
||||||
<Flag code={server.country} size={12} />
|
<Flag code={server.country} size={12} />
|
||||||
<span className="font-mono truncate" title={server.name}>{server.name}</span>
|
<span className="font-mono">{server.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -190,7 +242,7 @@ function ContainerCard({
|
|||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
|
||||||
{container.uptime && (
|
{container.uptime && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ActivityIcon aria-hidden="true" className="size-3 text-success" />
|
<ActivityIcon className="size-3 text-emerald-500" />
|
||||||
<span>{container.uptime}</span>
|
<span>{container.uptime}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -198,7 +250,7 @@ function ContainerCard({
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">CPU </span>
|
<span className="text-muted-foreground">CPU </span>
|
||||||
<span className={cn("font-mono font-medium",
|
<span className={cn("font-mono font-medium",
|
||||||
container.cpu > 50 ? "text-warning" : "text-foreground")}>
|
container.cpu > 50 ? "text-amber-500" : "text-foreground")}>
|
||||||
{container.cpu}%
|
{container.cpu}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +258,7 @@ function ContainerCard({
|
|||||||
{container.memMb !== undefined && (
|
{container.memMb !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">RAM </span>
|
<span className="text-muted-foreground">RAM </span>
|
||||||
<span className="font-mono font-medium">{container.memMb} МиБ</span>
|
<span className="font-mono font-medium">{container.memMb} МБ</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -235,7 +287,7 @@ function ContainerCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{container.comment && (
|
{container.comment && (
|
||||||
<p className="text-xs text-muted-foreground border-t pt-2 [overflow-wrap:anywhere]">{container.comment}</p>
|
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
@@ -243,97 +295,62 @@ function ContainerCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContainersPage() { return <Suspense fallback={<p role="status">Загрузка контекста контейнеров…</p>}><ContainersSource /></Suspense> }
|
export default function ContainersPage() {
|
||||||
function ContainersSource() {
|
|
||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
|
||||||
if (!prefsHydrated) return <p role="status">Загрузка настроек контейнеров…</p>
|
|
||||||
return <ContainersContent key={JSON.stringify([mode, backendUrl])} />
|
|
||||||
}
|
|
||||||
function ContainersContent() {
|
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
const canRead = can("mm:dashboard:read")
|
|
||||||
const canReadServers = can("mm:servers:read")
|
const [search, setSearch] = useState("")
|
||||||
const canWrite = can("mm:servers:write")
|
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
|
||||||
const params = useSearchParams()
|
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
|
||||||
const context = readContainersUrl(params)
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
const search = context.search
|
|
||||||
const statusFilter = context.status
|
const [liveContainers, setLiveContainers] = useState<RouterContainer[]>([])
|
||||||
const selectedServerId = context.server
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
const urlError = context.error
|
const [loading, setLoading] = useState(false)
|
||||||
function writeContext(changes: Record<string, string | null>, replace = false) {
|
|
||||||
const href = patchContainersUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
|
|
||||||
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
|
|
||||||
}
|
|
||||||
const setSearch = (value: string) => writeContext({ q: value }, true)
|
|
||||||
const setStatusFilter = (value: ContainerStatusFilter) => writeContext({ status: value === "all" ? null : value })
|
|
||||||
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
|
|
||||||
const [exportContainer, setExportContainer] = useState<ContainerExport | null>(null)
|
|
||||||
const [state, setState] = useState<ContainersState | null>(null)
|
|
||||||
const [lastStates, setLastStates] = useState<Record<number, ContainerServerState>>({})
|
|
||||||
const [liveServers, setLiveServers] = useState<ContainerServer[]>([])
|
|
||||||
const [loading, setLoading] = useState(isLive && (canRead || canReadServers))
|
|
||||||
const [busyId, setBusyId] = useState<string | null>(null)
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ContainerView | null>(null)
|
|
||||||
const deleteReturnLabel = useRef<string | null>(null)
|
|
||||||
useEffect(() => {
|
|
||||||
if (deleteTarget || busyId || !deleteReturnLabel.current) return
|
|
||||||
const frame = requestAnimationFrame(() => {
|
|
||||||
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === deleteReturnLabel.current)
|
|
||||||
;(target ?? Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent?.trim() === "Обновить"))?.focus()
|
|
||||||
})
|
|
||||||
return () => cancelAnimationFrame(frame)
|
|
||||||
}, [deleteTarget, busyId])
|
|
||||||
const [operationError, setOperationError] = useState<string | null>(null)
|
|
||||||
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
|
||||||
const [commandNeedsRead, setCommandNeedsRead] = useState(false)
|
|
||||||
const mutationLock = useRef(false)
|
|
||||||
const mutationController = useRef<AbortController | null>(null)
|
|
||||||
const alive = useRef(true)
|
|
||||||
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
|
|
||||||
const [liveError, setLiveError] = useState<string | null>(null)
|
const [liveError, setLiveError] = useState<string | null>(null)
|
||||||
const [serversError, setServersError] = useState<string | null>(null)
|
|
||||||
const [readAt, setReadAt] = useState<number | null>(null)
|
|
||||||
const [serversReadAt, setServersReadAt] = useState<number | null>(null)
|
|
||||||
const loadController = useRef<AbortController | null>(null)
|
|
||||||
const loadRevision = useRef(0)
|
|
||||||
const loadLive = useCallback(async () => {
|
const loadLive = useCallback(async () => {
|
||||||
if (!isLive) return false
|
if (!isLive) return
|
||||||
loadController.current?.abort()
|
|
||||||
const controller = new AbortController()
|
|
||||||
loadController.current = controller
|
|
||||||
const revision = ++loadRevision.current
|
|
||||||
const current = () => !controller.signal.aborted && loadRevision.current === revision
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
setLiveError(null)
|
||||||
let stateComplete = !canRead
|
try {
|
||||||
const tasks = await Promise.allSettled([
|
const [cRes, sRes] = await Promise.all([
|
||||||
canRead ? readContainersState(backendUrl, { signal }).then(result => {
|
requestJson<ContainersApiResponse>(backendUrl, "/api/containers"),
|
||||||
stateComplete = result.servers.every(node => node.availability !== "error")
|
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
||||||
if (current()) {
|
])
|
||||||
setState(result); setReadAt(Date.now()); setLiveError(null)
|
setLiveContainers(cRes.containers ?? [])
|
||||||
if (result.servers.every(node => node.availability !== "error")) setCommandNeedsRead(false)
|
setLiveServers(sRes.filter((s) => s.enabled).map(mapBackendServer))
|
||||||
setLastStates(previous => Object.fromEntries(result.servers.map(node => [node.serverId, node.availability === "error" ? previous[node.serverId] ?? node : node])))
|
} catch (e) {
|
||||||
}
|
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||||
}).catch(error => { if (current()) setLiveError(userApiError(error)); throw error }) : Promise.resolve(),
|
setLiveContainers([])
|
||||||
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
|
} finally {
|
||||||
const result = serverListSchema.parse(payload)
|
setLoading(false)
|
||||||
if (new Set(result.map(server => server.id)).size !== result.length) throw new Error("Каталог серверов содержит дубли ID")
|
}
|
||||||
if (current()) { setLiveServers(result.filter(server => server.enabled).map(mapBackendServer)); setServersReadAt(Date.now()); setServersError(null) }
|
}, [isLive, backendUrl])
|
||||||
}).catch(error => { if (current()) setServersError(userApiError(error)); throw error }) : Promise.resolve(),
|
|
||||||
])
|
useEffect(() => {
|
||||||
if (current()) setLoading(false)
|
if (!isLive) {
|
||||||
return current() && stateComplete && tasks.every(task => task.status === "fulfilled")
|
queueMicrotask(() => {
|
||||||
}, [isLive, backendUrl, canRead, canReadServers])
|
setLiveContainers([])
|
||||||
useEffect(() => { let cancelled = false; queueMicrotask(() => { if (!cancelled) void loadLive() }); return () => { cancelled = true; loadController.current?.abort() } }, [loadLive])
|
setLiveServers([])
|
||||||
const displayContainers: ContainerView[] = useMemo(() => isLive ? Object.values(lastStates).flatMap(node => node.containers) : mockContainers, [isLive, lastStates])
|
setLiveError(null)
|
||||||
const displayServers = isLive ? liveServers : mockServers.filter(server => server.enabled)
|
})
|
||||||
const effectiveServerId = selectedServerId
|
return
|
||||||
const relevantStates = state?.servers.filter(node => selectedServerId === ALL_SERVERS_ID || String(node.serverId) === selectedServerId)
|
}
|
||||||
const known = !isLive || !!state && (selectedServerId === ALL_SERVERS_ID || !!relevantStates?.length) && relevantStates!.every(node => node.availability !== "error")
|
queueMicrotask(() => {
|
||||||
const stale = isLive && !!liveError
|
void loadLive()
|
||||||
const writeReady = !isLive || canWrite && !urlError && !commandNeedsRead && !loading && !stale && !!serversReadAt && !serversError
|
})
|
||||||
const nodeFresh = (serverId: string) => !isLive || writeReady && state?.servers.some(node => String(node.serverId) === serverId && node.availability === "available" && displayServers.some(server => server.id === serverId))
|
}, [isLive, loadLive])
|
||||||
|
|
||||||
|
const displayContainers = isLive ? liveContainers : mockContainers
|
||||||
|
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||||
|
|
||||||
|
const effectiveServerId =
|
||||||
|
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||||||
|
? selectedServerId
|
||||||
|
: ALL_SERVERS_ID
|
||||||
|
|
||||||
const scoped = useMemo(() => {
|
const scoped = useMemo(() => {
|
||||||
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
|
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
|
||||||
return displayContainers.filter((c) => c.serverId === effectiveServerId)
|
return displayContainers.filter((c) => c.serverId === effectiveServerId)
|
||||||
@@ -351,12 +368,12 @@ function ContainersContent() {
|
|||||||
host: s.host,
|
host: s.host,
|
||||||
site: s.site,
|
site: s.site,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
status: s.status ?? "unknown",
|
status: s.status,
|
||||||
type: s.type,
|
type: s.type,
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
meta: isLive && (!state || !state.servers.some(node => String(node.serverId) === s.id && node.availability !== "error")) ? "—" : String(displayContainers.filter((c) => c.serverId === s.id).length),
|
meta: String(displayContainers.filter((c) => c.serverId === s.id).length),
|
||||||
}))
|
}))
|
||||||
), [displayServers, displayContainers, state, isLive])
|
), [displayServers, displayContainers])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return scoped.filter((c) => {
|
return scoped.filter((c) => {
|
||||||
@@ -374,39 +391,26 @@ function ContainersContent() {
|
|||||||
const running = scoped.filter((c) => c.status === "running").length
|
const running = scoped.filter((c) => c.status === "running").length
|
||||||
const stopped = scoped.filter((c) => c.status === "stopped").length
|
const stopped = scoped.filter((c) => c.status === "stopped").length
|
||||||
const errors = scoped.filter((c) => c.status === "error").length
|
const errors = scoped.filter((c) => c.status === "error").length
|
||||||
const statusKnown = known && scoped.every(container => container.status !== "unknown")
|
|
||||||
const currentDeleteTarget = deleteTarget ? displayContainers.find(row => row.id === deleteTarget.id && row.rosId === deleteTarget.rosId && row.serverId === deleteTarget.serverId) : undefined
|
|
||||||
const deleteStopped = currentDeleteTarget?.status === "stopped"
|
|
||||||
|
|
||||||
async function mutate(c: ContainerView, action: ContainerAction) {
|
async function mutate(c: RouterContainer, action: "start" | "stop" | "restart" | "remove") {
|
||||||
if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId) || mutationLock.current || !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId)) return
|
if (!isLive || !c.rosId) {
|
||||||
if (action === "remove" && !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId && row.status === "stopped")) return
|
toast.info("Действие доступно только в live-режиме")
|
||||||
mutationLock.current = true
|
return
|
||||||
const controller = new AbortController()
|
}
|
||||||
mutationController.current = controller
|
if (action === "remove" && !window.confirm(`Удалить контейнер ${c.name}?`)) return
|
||||||
const current = () => alive.current && !controller.signal.aborted
|
setBusyId(c.id)
|
||||||
setBusyId(c.id); setOperationError(null); setOperationNotice(null)
|
|
||||||
try {
|
try {
|
||||||
await mutateContainer(backendUrl, c.serverId, c.rosId, action, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
await requestJson(backendUrl, `/api/servers/${c.serverId}/containers/${action}`, {
|
||||||
if (!current()) return
|
method: "POST",
|
||||||
const labels = { start: "запуск", stop: "остановка", restart: "перезапуск", remove: "удаление" }
|
body: JSON.stringify({ rosId: c.rosId }),
|
||||||
const message = c.name + ": API подтвердил команду «" + labels[action] + "». Состояние проверяется повторным чтением."
|
})
|
||||||
setOperationNotice(message)
|
const labels = { start: "запущен", stop: "остановлен", restart: "перезапущен", remove: "удалён" }
|
||||||
if (action === "remove") {
|
toast.success(`${c.name}: ${labels[action]}`)
|
||||||
setLastStates(previous => Object.fromEntries(Object.entries(previous).map(([id, node]) => [id, { ...node, containers: node.containers.filter(row => row.id !== c.id) }])))
|
await loadLive()
|
||||||
setDeleteTarget(null)
|
} catch (e) {
|
||||||
}
|
toast.error(e instanceof Error ? e.message : "Ошибка RouterOS")
|
||||||
toast.success("Команда подтверждена API.")
|
|
||||||
const refreshed = await loadLive()
|
|
||||||
if (current() && !refreshed) setOperationNotice(message + " Обновление каталогов не удалось; повторите чтение.")
|
|
||||||
} catch (error) {
|
|
||||||
if (!current()) return
|
|
||||||
const uncertain = !(error instanceof ApiClientError && error.status >= 400 && error.status < 500)
|
|
||||||
setCommandNeedsRead(uncertain)
|
|
||||||
setOperationError(c.name + ": " + userApiError(error) + (uncertain ? " Результат команды не подтверждён; она могла выполниться частично. Обновите каталог перед новым действием." : " Команда отклонена API."))
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
setBusyId(null)
|
||||||
if (alive.current) setBusyId(null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +421,6 @@ function ContainersContent() {
|
|||||||
selectedId={effectiveServerId}
|
selectedId={effectiveServerId}
|
||||||
onSelect={setSelectedServerId}
|
onSelect={setSelectedServerId}
|
||||||
showAll
|
showAll
|
||||||
preserveSelection
|
|
||||||
allCount={displayServers.length}
|
allCount={displayServers.length}
|
||||||
loading={isLive && loading && displayServers.length === 0}
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
header={
|
header={
|
||||||
@@ -430,33 +433,32 @@ function ContainersContent() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { void loadLive() }}
|
onClick={() => { void loadLive() }}
|
||||||
disabled={!isLive || loading || !canRead && !canReadServers || !!busyId}
|
disabled={!isLive || loading}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon aria-hidden="true" className={cn("size-4", loading && "animate-spin")} />
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" disabled title="Создание контейнеров ещё не подключено">
|
<Button size="sm">
|
||||||
<BoxIcon aria-hidden="true" className="size-4" />Новый контейнер
|
<BoxIcon className="size-4" />Новый контейнер
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col min-w-0 gap-5 [overflow-wrap:anywhere]">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{urlError && <Alert role="alert" aria-label="Контекст контейнеров" variant="warning"><AlertDescription>{urlError}<Button type="button" variant="outline" onClick={() => writeContext({server:null,status:null,q:null})}>Сбросить контекст</Button></AlertDescription></Alert>}
|
{isLive && liveError && (
|
||||||
{operationNotice && <p role="status" className="text-sm [overflow-wrap:anywhere]">{operationNotice}</p>}
|
<Alert variant="warning" className="py-2">
|
||||||
{operationError && !deleteTarget && <Alert role="alert" aria-label="Команда контейнера" variant="warning"><AlertDescription>{operationError}</AlertDescription></Alert>}
|
<AlertCircleIcon />
|
||||||
{isLive && !canRead && <p role="status">Для каталога контейнеров требуется mm:dashboard:read.</p>}
|
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||||||
{isLive && !canReadServers && <p role="status">Для каталога серверов требуется mm:servers:read. Команды недоступны.</p>}
|
</Alert>
|
||||||
{isLive && loading && !state && <p role="status">Загрузка контейнеров…</p>}
|
)}
|
||||||
{isLive && readAt && <p role="status" className="text-xs text-muted-foreground">Каталог контейнеров получен: {new Date(readAt).toLocaleTimeString("ru-RU")}</p>}
|
{isLive && !loading && displayContainers.length === 0 && !liveError && (
|
||||||
{isLive && serversReadAt && <p role="status" className="text-xs text-muted-foreground">Серверы получены: {new Date(serversReadAt).toLocaleTimeString("ru-RU")}</p>}
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
{isLive && liveError && <Alert variant="warning" role="alert" aria-label="Каталог контейнеров"><AlertDescription>{state ? "Последние данные сохранены." : "Контейнеры пока неизвестны."} {liveError}</AlertDescription></Alert>}
|
Контейнеры не найдены. Нужен пакет container (RouterOS 7.4+).
|
||||||
{isLive && serversError && <Alert variant="warning" role="alert" aria-label="Серверы контейнеров"><AlertDescription>{serversReadAt ? "Последний каталог сохранён." : "Каталог серверов пока неизвестен."} {serversError}</AlertDescription></Alert>}
|
</div>
|
||||||
{isLive && relevantStates?.map(node => node.availability === "error" || node.failures.length ? <Alert key={node.serverId} variant="warning" role="alert" aria-label={"Контейнеры сервера " + node.serverId}><AlertDescription>Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: {node.failures.map(f => f.resource + ": " + f.error).join("; ")}. {node.availability === "available" ? "Контейнеры получены; настройки загружены частично." : lastStates[node.serverId]?.containers.length ? "Последние контейнеры сохранены." : "Список неизвестен."}</AlertDescription></Alert> : node.availability === "unsupported" ? <p key={node.serverId} role="status">Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: пакет container недоступен.</p> : null)}
|
)}
|
||||||
{isLive && selectedServerId !== ALL_SERVERS_ID && !displayServers.some(server => server.id === selectedServerId) && <p role="alert">Выбранный сервер недоступен в каталоге. Выберите другое устройство; фильтр сохранён.</p>}
|
|
||||||
{mode === "mock" && (
|
{mode === "mock" && (
|
||||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
Моковые данные
|
Моковые данные
|
||||||
@@ -469,95 +471,95 @@ function ContainersContent() {
|
|||||||
{
|
{
|
||||||
id: "all",
|
id: "all",
|
||||||
label: "Всего",
|
label: "Всего",
|
||||||
value: known ? scoped.length : "—",
|
value: scoped.length,
|
||||||
icon: <BoxIcon aria-hidden="true" className="size-4" />,
|
icon: <BoxIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "running",
|
id: "running",
|
||||||
label: "Работает",
|
label: "Running",
|
||||||
value: statusKnown ? running : "—",
|
value: running,
|
||||||
icon: <PlayIcon aria-hidden="true" className="size-4" />,
|
icon: <PlayIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "stopped",
|
id: "stopped",
|
||||||
label: "Остановлен",
|
label: "Stopped",
|
||||||
value: statusKnown ? stopped : "—",
|
value: stopped,
|
||||||
icon: <StopCircleIcon aria-hidden="true" className="size-4" />,
|
icon: <StopCircleIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "errors",
|
id: "errors",
|
||||||
label: "Ошибок",
|
label: "Ошибок",
|
||||||
value: statusKnown ? errors : "—",
|
value: errors,
|
||||||
icon: <AlertCircleIcon aria-hidden="true" className="size-4" />,
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
iconClassName: "text-destructive",
|
iconClassName: "text-destructive",
|
||||||
variant: errors > 0 ? "destructive" : "default",
|
variant: errors > 0 ? "destructive" : "default",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-info/5 border border-info/20 px-4 py-3 text-sm">
|
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
|
||||||
<BoxIcon aria-hidden="true" className="size-5 text-info shrink-0 mt-0.5" />
|
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-foreground">Контейнеры доступны с RouterOS 7.4+</p>
|
<p className="font-medium text-violet-600 dark:text-violet-400">Контейнеры доступны с RouterOS 7.4+</p>
|
||||||
<p className="text-muted-foreground text-xs mt-0.5">
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
|
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
|
||||||
Сеть veth настраивается на устройстве. Создание контейнеров в интерфейсе пока недоступно.
|
Интерфейсы veth создаются автоматически.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-0 w-full sm:w-auto">
|
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
||||||
<SearchIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
<input
|
<input
|
||||||
className="flex-1 min-w-0 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||||
aria-label="Поиск контейнеров" placeholder="Поиск по имени, образу, серверу…"
|
placeholder="Поиск по имени, образу, серверу…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div role="group" aria-label="Статус контейнеров" className="flex flex-wrap items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||||
{(["all", "running", "stopped", "error", "unknown"] as const).map((s) => (
|
{(["all", "running", "stopped", "error"] as const).map((s) => (
|
||||||
<button key={s}
|
<button key={s}
|
||||||
type="button" aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}
|
onClick={() => setStatusFilter(s)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
||||||
statusFilter === s
|
statusFilter === s
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "bg-background text-foreground shadow-sm"
|
||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}>
|
)}>
|
||||||
{s === "all" ? "Все" : s === "running" ? "Работает" : s === "stopped" ? "Остановлен" : s === "unknown" ? "Неизвестно" : "Ошибка"}
|
{s === "all" ? "Все" : s === "running" ? "Running" : s === "stopped" ? "Stopped" : "Error"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{known ? filtered.length : "—"} контейнеров</span>
|
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (!known ? <p role="status">Список контейнеров пока неизвестен.</p> : (
|
{filtered.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||||
<BoxIcon aria-hidden="true" className="size-10 mb-3 opacity-20" />
|
<BoxIcon className="size-10 mb-3 opacity-20" />
|
||||||
<p className="text-sm font-medium">Контейнеры не найдены</p>
|
<p className="text-sm font-medium">Контейнеры не найдены</p>
|
||||||
<p className="text-xs mt-1">{scoped.length ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}</p>
|
<p className="text-xs mt-1">Добавьте первый контейнер или измените фильтр</p>
|
||||||
</div>
|
</div>
|
||||||
)) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{filtered.map((c) => (
|
{filtered.map((c) => (
|
||||||
<ContainerCard
|
<ContainerCard
|
||||||
key={c.id}
|
key={c.id}
|
||||||
container={c}
|
container={c}
|
||||||
server={serverById[c.serverId]}
|
server={serverById[c.serverId]}
|
||||||
live={isLive && !!nodeFresh(c.serverId)}
|
live={isLive}
|
||||||
busy={!!busyId}
|
busy={busyId === c.id}
|
||||||
onExport={() => { const snapshot = lastStates[Number(c.serverId)]; setExportContainer({ container: c, collectedAt: snapshot?.collectedAt ?? null, failures: snapshot?.failures ?? [], source: isLive ? "live" : "mock" }) }}
|
onExport={() => setExportContainer(c)}
|
||||||
onStart={() => { void mutate(c, "start") }}
|
onStart={() => { void mutate(c, "start") }}
|
||||||
onStop={() => { void mutate(c, "stop") }}
|
onStop={() => { void mutate(c, "stop") }}
|
||||||
onRestart={() => { void mutate(c, "restart") }}
|
onRestart={() => { void mutate(c, "restart") }}
|
||||||
onRemove={() => { deleteReturnLabel.current = "Действия контейнера " + c.name; setOperationError(null); setDeleteTarget(c) }}
|
onRemove={() => { void mutate(c, "remove") }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -613,7 +615,7 @@ function ContainersContent() {
|
|||||||
].map((b) => (
|
].map((b) => (
|
||||||
<div key={b.title}>
|
<div key={b.title}>
|
||||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||||||
<pre className="bg-muted rounded-md p-2.5 text-foreground text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||||||
{b.lines.join("\n")}
|
{b.lines.join("\n")}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -624,18 +626,12 @@ function ContainersContent() {
|
|||||||
</div>
|
</div>
|
||||||
</ServerRailLayout>
|
</ServerRailLayout>
|
||||||
|
|
||||||
{deleteTarget && <AlertDialog open onOpenChange={open => { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}>
|
<ExportSheet
|
||||||
<AlertDialogContent>
|
open={!!exportContainer}
|
||||||
<AlertDialogHeader><AlertDialogTitle>Удалить контейнер?</AlertDialogTitle><AlertDialogDescription>{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. {deleteStopped ? "Будет удалён остановленный контейнер. Дополнительная очистка файлов не выполняется." : "Сначала закройте это окно, остановите контейнер через меню действий и обновите каталог. Удаление доступно только после подтверждённой остановки."}</AlertDialogDescription></AlertDialogHeader>
|
container={exportContainer}
|
||||||
{operationError && <p role="alert" aria-label="Удаление контейнера" className="text-sm text-destructive [overflow-wrap:anywhere]">{operationError}</p>}
|
onClose={() => setExportContainer(null)}
|
||||||
<AlertDialogFooter>
|
serverById={serverById}
|
||||||
{(commandNeedsRead || !deleteStopped) && <Button type="button" variant="outline" disabled={loading || !!busyId} onClick={() => { void loadLive() }}>Обновить каталог</Button>}
|
/>
|
||||||
<AlertDialogCancel disabled={!!busyId}>Отмена</AlertDialogCancel>
|
|
||||||
<AlertDialogAction type="button" variant="destructive" disabled={!!busyId || !deleteStopped || !nodeFresh(deleteTarget.serverId)} aria-busy={!!busyId} onClick={() => { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"}</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>}
|
|
||||||
<ExportSheet selection={exportContainer} onClose={() => setExportContainer(null)} />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+198
-206
@@ -1,7 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import {
|
import {
|
||||||
ActivityIcon,
|
ActivityIcon,
|
||||||
@@ -26,9 +24,9 @@ import { DashboardEventsTimeline } from "@/components/dashboard/dashboard-events
|
|||||||
import { InternetPathPanel } from "@/components/dashboard/internet-path-panel"
|
import { InternetPathPanel } from "@/components/dashboard/internet-path-panel"
|
||||||
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
|
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
import { DashboardDataStatus } from "@/components/dashboard/dashboard-data-status"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { fmtRate } from "@/lib/fmt-rate"
|
import { fmtRate } from "@/lib/fmt-rate"
|
||||||
import { useDashboardLive, type AttentionRow } from "@/hooks/use-dashboard-live"
|
import { useDashboardLive, type AttentionRow } from "@/hooks/use-dashboard-live"
|
||||||
@@ -38,44 +36,31 @@ function fmtIntRu(n: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AttentionRows({ rows }: { rows: AttentionRow[] }) {
|
function AttentionRows({ rows }: { rows: AttentionRow[] }) {
|
||||||
const [expanded,setExpanded]=useState(false)
|
|
||||||
const [visible,setVisible]=useState(55)
|
|
||||||
function renderRow(row: AttentionRow) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={row.id}
|
|
||||||
href={row.href}
|
|
||||||
className="hover:bg-muted/40 focus-visible:ring-ring flex min-w-0 items-start justify-between gap-2 rounded-md px-1 py-1.5 focus-visible:ring-2"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 wrap-anywhere text-sm font-medium">{row.title}</span>
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
variant={row.tone === "warning" ? "warning-light" : "destructive-light"}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
{row.hint}
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{rows.slice(0, 5).map(renderRow)}
|
{rows.map((row) => (
|
||||||
{rows.length > 5 && (
|
<Link
|
||||||
<details onToggle={event=>{setExpanded(event.currentTarget.open);if(!event.currentTarget.open)setVisible(55)}}>
|
key={row.id}
|
||||||
<summary className="cursor-pointer rounded-md px-1 py-2 text-xs text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring">Ещё {rows.length - 5}</summary>
|
href={row.href}
|
||||||
{expanded && rows.slice(5,visible).map(renderRow)}
|
className="hover:bg-muted/40 flex min-w-0 items-center justify-between gap-2 rounded-md px-1 py-1.5"
|
||||||
{expanded && rows.length>visible && <Button type="button" variant="ghost" size="sm" onClick={()=>setVisible(count=>Math.min(count+50,rows.length))}>Показать ещё {Math.min(50,rows.length-visible)}</Button>}
|
>
|
||||||
</details>
|
<span className="truncate text-sm font-medium">{row.title}</span>
|
||||||
)}
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant={row.tone === "warning" ? "warning-light" : "destructive-light"}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{row.hint}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const dash = useDashboardLive()
|
const dash = useDashboardLive()
|
||||||
const offline = dash.offlineCount
|
const offline = Math.max(0, dash.totalServers - dash.onlineCount)
|
||||||
const health = dash.probeDown + dash.probeWarn
|
const health = dash.probeDown + dash.probeWarn
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,7 +69,17 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
<div className="flex flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||||
{dash.isLive && <DashboardDataStatus sources={dash.sources} refreshing={dash.refreshing} onRefresh={dash.retry} />}
|
{dash.error && !dash.loading ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Не удалось обновить дашборд</AlertTitle>
|
||||||
|
<AlertDescription className="flex flex-wrap items-center gap-2">
|
||||||
|
<span>{dash.error}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={dash.retry}>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка дашборда"
|
aria-label="Сводка дашборда"
|
||||||
@@ -92,25 +87,25 @@ export default function DashboardPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
id: "fleet",
|
id: "fleet",
|
||||||
label: "Серверы онлайн",
|
label: "Флот",
|
||||||
value: dash.availability.servers ? `${dash.onlineCount} / ${dash.totalServers}` : "—",
|
value: `${dash.onlineCount} / ${dash.totalServers}`,
|
||||||
hint: !dash.availability.servers ? "нет данных" : !dash.isLive
|
hint: !dash.isLive
|
||||||
? "демо"
|
? "демо"
|
||||||
: offline > 0 || dash.unknownServerCount > 0 || dash.degradedServerCount > 0
|
: offline > 0
|
||||||
? [offline > 0 ? `${offline} не в сети` : null,dash.unknownServerCount > 0 ? `Статус неизвестен: ${dash.unknownServerCount}` : null,dash.degradedServerCount > 0 ? `Ухудшение: ${dash.degradedServerCount}` : null].filter(Boolean).join(" · ")
|
? `${offline} offline`
|
||||||
: dash.totalServers > 0
|
: dash.totalServers > 0
|
||||||
? "все в сети"
|
? "все online"
|
||||||
: "нет серверов",
|
: "нет узлов",
|
||||||
href: "/servers",
|
href: "/servers",
|
||||||
icon: <ServerIcon />,
|
icon: <ServerIcon />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
variant: offline > 0 || dash.unknownServerCount > 0 || dash.degradedServerCount > 0 ? "warning" : "default",
|
variant: offline > 0 ? "warning" : "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "overlay",
|
id: "overlay",
|
||||||
label: "Туннели работают",
|
label: "Оверлей",
|
||||||
value: dash.availability.overlay ? String(dash.overlay.up) : "—",
|
value: String(dash.overlay.up),
|
||||||
hint: !dash.availability.overlay ? "не все источники доступны" : !dash.reliable.overlay ? "Последний успешный снимок; не все источники обновлены" : dash.overlay.total > 0 ? `Не работают: ${dash.overlay.down}` : "нет туннелей",
|
hint: dash.overlay.total > 0 ? `${dash.overlay.down} down` : "нет туннелей",
|
||||||
href: "/gre",
|
href: "/gre",
|
||||||
icon: <CableIcon />,
|
icon: <CableIcon />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
@@ -118,18 +113,18 @@ export default function DashboardPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "bgp",
|
id: "bgp",
|
||||||
label: "Префиксы BGP",
|
label: "BGP Rx",
|
||||||
value: dash.bgp?.prefixSum != null ? fmtIntRu(dash.bgp.prefixSum) : "—",
|
value: dash.bgp ? fmtIntRu(dash.bgp.prefixSum) : "—",
|
||||||
hint: dash.bgp ? `Сессий установлено: ${dash.bgp.establishedCount ?? "неизвестно"}${dash.bgp.prefixSum === null ? " · префиксы неизвестны" : ""}` : "нет данных",
|
hint: dash.bgp ? `${dash.bgp.establishedCount} Established` : "нет данных",
|
||||||
href: "/bgp",
|
href: "/bgp",
|
||||||
icon: <GitMergeIcon />,
|
icon: <GitMergeIcon />,
|
||||||
iconClassName: "text-primary",
|
iconClassName: "text-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "health",
|
id: "health",
|
||||||
label: "Проверки с проблемами",
|
label: "Здоровье",
|
||||||
value: dash.availability.probes && dash.unknownProbeCount===0 ? String(health) : "—",
|
value: String(health),
|
||||||
hint: dash.availability.probes ? `${dash.probeDown} ошибок · ${dash.probeWarn} предупреждений${dash.unknownProbeCount>0?` · неизвестны: ${dash.unknownProbeCount}`:""}` : "нет данных",
|
hint: `${dash.probeDown} down · ${dash.probeWarn} warn`,
|
||||||
href: "/uptime",
|
href: "/uptime",
|
||||||
icon: <BellIcon />,
|
icon: <BellIcon />,
|
||||||
iconClassName: health > 0 ? "text-destructive" : "text-muted-foreground",
|
iconClassName: health > 0 ? "text-destructive" : "text-muted-foreground",
|
||||||
@@ -138,165 +133,13 @@ export default function DashboardPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section aria-labelledby="dashboard-attention-title" className="flex min-w-0 flex-col gap-2">
|
|
||||||
<h2 id="dashboard-attention-title" className="text-sm font-semibold">Требует внимания</h2>
|
|
||||||
<AttentionQueue
|
|
||||||
className="lg:grid-cols-3"
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
id: "nodes",
|
|
||||||
title: "Серверы",
|
|
||||||
icon: ServerIcon,
|
|
||||||
count: dash.attentionServers.length,
|
|
||||||
countVariant: "warning-light",
|
|
||||||
emptyTitle: dash.reliable.servers ? (dash.totalServers > 0 ? "Серверы без проблем" : "Нет серверов") : "Состояние не подтверждено",
|
|
||||||
emptyDescription: dash.reliable.servers ? "Нет включённых серверов с ошибками доступности." : "Проверьте состояние источников выше.",
|
|
||||||
children: <AttentionRows rows={dash.attentionServers} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "overlay",
|
|
||||||
title: "Туннели",
|
|
||||||
icon: CableIcon,
|
|
||||||
iconClassName: "text-info",
|
|
||||||
count: dash.attentionOverlay.length,
|
|
||||||
countVariant: "destructive-light",
|
|
||||||
emptyTitle: dash.reliable.overlay ? (dash.overlay.total > 0 ? "Туннели работают" : "Нет туннелей") : "Состояние не подтверждено",
|
|
||||||
emptyDescription: dash.reliable.overlay ? "В GRE, WireGuard и VXLAN проблем не обнаружено." : "Проверьте состояние источников выше.",
|
|
||||||
children: <AttentionRows rows={dash.attentionOverlay} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "probes",
|
|
||||||
title: "Проверки",
|
|
||||||
icon: HeartPulseIcon,
|
|
||||||
iconClassName: "text-destructive",
|
|
||||||
count: dash.attentionProbes.length,
|
|
||||||
countVariant: "destructive-light",
|
|
||||||
emptyTitle: dash.reliable.probes ? (dash.probeCount > 0 ? "Проверки без ошибок" : "Нет активных проверок") : "Состояние не подтверждено",
|
|
||||||
emptyDescription: dash.reliable.probes ? "Нет ошибок и предупреждений активных проверок." : "Проверьте состояние источников выше.",
|
|
||||||
children: <AttentionRows rows={dash.attentionProbes} />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_1fr]">
|
|
||||||
<OpsPanel
|
|
||||||
title="Задержка до серверов"
|
|
||||||
description={
|
|
||||||
dash.latency.kind === "mock" || dash.latency.kind === "live"
|
|
||||||
? dash.latency.subtitle
|
|
||||||
: dash.latency.kind === "loading"
|
|
||||||
? "Загрузка…"
|
|
||||||
: "Нет серии RTT"
|
|
||||||
}
|
|
||||||
contentClassName="px-3 pb-3"
|
|
||||||
>
|
|
||||||
{dash.latency.kind === "loading" && (
|
|
||||||
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
|
|
||||||
)}
|
|
||||||
{dash.latency.kind === "empty" && (
|
|
||||||
<p className="text-muted-foreground px-4 py-10 text-center text-sm">{dash.latency.message}</p>
|
|
||||||
)}
|
|
||||||
{(dash.latency.kind === "mock" || dash.latency.kind === "live") && (
|
|
||||||
<LatencyChart series={dash.latency.series} labels={dash.latency.labels} />
|
|
||||||
)}
|
|
||||||
</OpsPanel>
|
|
||||||
|
|
||||||
<OpsPanel
|
|
||||||
title="Последние события"
|
|
||||||
description="Система и BGP"
|
|
||||||
headerRight={
|
|
||||||
<Link href="/alerts" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-7 text-xs")}>
|
|
||||||
Все →
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DashboardEventsTimeline
|
|
||||||
events={dash.recentEvents}
|
|
||||||
loading={dash.eventsLoading}
|
|
||||||
error={dash.eventsError}
|
|
||||||
/>
|
|
||||||
</OpsPanel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<OpsPanel
|
|
||||||
title="Пропускная способность"
|
|
||||||
description={dash.traffic?.demo ? "Суммарный RX / TX · демо" : "Суммарная история RX / TX · 1 ч; значения — последний измеренный снимок"}
|
|
||||||
headerRight={<Link href="/traffic" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>Трафик →</Link>}
|
|
||||||
contentClassName="px-3 pb-3"
|
|
||||||
>
|
|
||||||
{dash.loading && !dash.traffic ? (
|
|
||||||
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
|
|
||||||
) : !dash.traffic ? (
|
|
||||||
<p className="text-muted-foreground px-4 py-10 text-center text-sm">
|
|
||||||
Нет данных трафика за последний час.{" "}
|
|
||||||
<Link href="/traffic" className="underline underline-offset-2">
|
|
||||||
Открыть трафик
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-wrap items-center gap-3 px-2 py-3 text-xs">
|
|
||||||
{dash.traffic.demo ? <Badge variant="outline" size="sm">демо</Badge> : !dash.traffic.observationsKnown ? <Badge variant="outline" size="sm">Измерения не подтверждены API</Badge> : null}
|
|
||||||
<span className="tabular-nums">RX {dash.traffic.rxNow===null?"—":fmtRate(dash.traffic.rxNow)}</span>
|
|
||||||
<span className="tabular-nums">TX {dash.traffic.txNow===null?"—":fmtRate(dash.traffic.txNow)}</span>
|
|
||||||
</div>
|
|
||||||
<TrafficRxTxChart rx={dash.traffic.rx} tx={dash.traffic.tx} range="1h" embedded />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</OpsPanel>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<InternetPathPanel
|
|
||||||
model={dash.internetPath}
|
|
||||||
loading={dash.internetPathLoading}
|
|
||||||
error={dash.isLive ? dash.internetPathError : null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OpsPanel
|
|
||||||
title="Активные пробы"
|
|
||||||
description={
|
|
||||||
<span className={dash.error && dash.isLive ? "text-destructive" : undefined}>{dash.probesSubtitle}</span>
|
|
||||||
}
|
|
||||||
headerRight={
|
|
||||||
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
|
|
||||||
Мониторинг →
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
contentClassName="px-0 pb-0"
|
|
||||||
>
|
|
||||||
{dash.activeProbes.length === 0 && !dash.probesLoading ? (
|
|
||||||
<EmptyState
|
|
||||||
className="py-10"
|
|
||||||
title={dash.availability.probes ? "Нет проб на дашборде" : "Данные проб недоступны"}
|
|
||||||
description={dash.availability.probes ? "Включите пробу и отметьте ★ на странице мониторинга." : "Повторите обновление данных."}
|
|
||||||
action={
|
|
||||||
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
|
||||||
Открыть мониторинг
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DashboardActiveProbesDataGrid
|
|
||||||
probes={dash.activeProbes}
|
|
||||||
catalog={dash.servers}
|
|
||||||
isLoading={dash.probesLoading}
|
|
||||||
emptyDescription="Нет проб с звездой на дашборде."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</OpsPanel>
|
|
||||||
|
|
||||||
<QuickActionGrid
|
<QuickActionGrid
|
||||||
title="Быстрые переходы"
|
description="Разделы операционного контура"
|
||||||
description="Другие разделы управления сетью"
|
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
id: "traffic",
|
id: "traffic",
|
||||||
title: "Трафик",
|
title: "Трафик",
|
||||||
description: "Приём, передача и потоки IPFIX",
|
description: "RX/TX флота и IPFIX",
|
||||||
href: "/traffic",
|
href: "/traffic",
|
||||||
icon: <ActivityIcon />,
|
icon: <ActivityIcon />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
@@ -344,6 +187,155 @@ export default function DashboardPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_1fr]">
|
||||||
|
<OpsPanel
|
||||||
|
title="Задержка до серверов"
|
||||||
|
description={
|
||||||
|
dash.latency.kind === "mock" || dash.latency.kind === "live"
|
||||||
|
? dash.latency.subtitle
|
||||||
|
: dash.latency.kind === "loading"
|
||||||
|
? "Загрузка…"
|
||||||
|
: "Нет серии RTT"
|
||||||
|
}
|
||||||
|
contentClassName="px-3 pb-3"
|
||||||
|
>
|
||||||
|
{dash.latency.kind === "loading" && (
|
||||||
|
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
|
||||||
|
)}
|
||||||
|
{dash.latency.kind === "empty" && (
|
||||||
|
<p className="text-muted-foreground px-4 py-10 text-center text-sm">{dash.latency.message}</p>
|
||||||
|
)}
|
||||||
|
{(dash.latency.kind === "mock" || dash.latency.kind === "live") && (
|
||||||
|
<LatencyChart series={dash.latency.series} labels={dash.latency.labels} />
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
<OpsPanel
|
||||||
|
title="Последние события"
|
||||||
|
description="Система и BGP"
|
||||||
|
headerRight={
|
||||||
|
<Link href="/alerts" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-7 text-xs")}>
|
||||||
|
Все →
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DashboardEventsTimeline
|
||||||
|
events={dash.recentEvents}
|
||||||
|
loading={dash.eventsLoading}
|
||||||
|
error={dash.eventsError}
|
||||||
|
/>
|
||||||
|
</OpsPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[3fr_2fr]">
|
||||||
|
<OpsPanel
|
||||||
|
title="Пропускная способность"
|
||||||
|
description={dash.traffic?.demo ? "Суммарный RX / TX · демо" : "Суммарный RX / TX флота · 1 ч"}
|
||||||
|
headerRight={
|
||||||
|
dash.traffic ? (
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
{dash.traffic.demo ? <Badge variant="outline" size="sm">демо</Badge> : null}
|
||||||
|
<span className="tabular-nums">RX {fmtRate(dash.traffic.rxNow)}</span>
|
||||||
|
<span className="tabular-nums">TX {fmtRate(dash.traffic.txNow)}</span>
|
||||||
|
<Link href="/traffic" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
|
||||||
|
Трафик →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
contentClassName="px-3 pb-3"
|
||||||
|
>
|
||||||
|
{dash.loading && !dash.traffic ? (
|
||||||
|
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
|
||||||
|
) : !dash.traffic ? (
|
||||||
|
<p className="text-muted-foreground px-4 py-10 text-center text-sm">
|
||||||
|
Нет данных трафика за последний час.{" "}
|
||||||
|
<Link href="/traffic" className="underline underline-offset-2">
|
||||||
|
Открыть трафик
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<TrafficRxTxChart rx={dash.traffic.rx} tx={dash.traffic.tx} range="1h" embedded />
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
<AttentionQueue
|
||||||
|
className="@3xl:grid-cols-1"
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
id: "nodes",
|
||||||
|
title: "Узлы",
|
||||||
|
icon: ServerIcon,
|
||||||
|
count: dash.attentionServers.length,
|
||||||
|
countVariant: "warning-light",
|
||||||
|
emptyTitle: "Все online",
|
||||||
|
emptyDescription: "Нет офлайн или degraded узлов.",
|
||||||
|
children: <AttentionRows rows={dash.attentionServers} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "overlay",
|
||||||
|
title: "Оверлей",
|
||||||
|
icon: CableIcon,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
count: dash.attentionOverlay.length,
|
||||||
|
countVariant: "destructive-light",
|
||||||
|
emptyTitle: "Туннели up",
|
||||||
|
emptyDescription: "GRE и WireGuard в норме.",
|
||||||
|
children: <AttentionRows rows={dash.attentionOverlay} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "probes",
|
||||||
|
title: "Пробы",
|
||||||
|
icon: HeartPulseIcon,
|
||||||
|
iconClassName: "text-destructive",
|
||||||
|
count: dash.attentionProbes.length,
|
||||||
|
countVariant: "destructive-light",
|
||||||
|
emptyTitle: "Пробы up",
|
||||||
|
emptyDescription: "Нет down или warn.",
|
||||||
|
children: <AttentionRows rows={dash.attentionProbes} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InternetPathPanel
|
||||||
|
model={dash.internetPath}
|
||||||
|
loading={dash.internetPathLoading}
|
||||||
|
error={dash.isLive ? dash.internetPathError : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OpsPanel
|
||||||
|
title="Активные пробы"
|
||||||
|
description={
|
||||||
|
<span className={dash.error && dash.isLive ? "text-destructive" : undefined}>{dash.probesSubtitle}</span>
|
||||||
|
}
|
||||||
|
headerRight={
|
||||||
|
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
|
||||||
|
Мониторинг →
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
contentClassName="px-0 pb-0"
|
||||||
|
>
|
||||||
|
{dash.activeProbes.length === 0 && !dash.probesLoading ? (
|
||||||
|
<EmptyState
|
||||||
|
className="py-10"
|
||||||
|
title="Нет проб на дашборде"
|
||||||
|
description="Включите пробу и отметьте ★ на странице мониторинга."
|
||||||
|
action={
|
||||||
|
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||||
|
Открыть мониторинг
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DashboardActiveProbesDataGrid
|
||||||
|
probes={dash.activeProbes}
|
||||||
|
catalog={dash.servers}
|
||||||
|
isLoading={dash.probesLoading}
|
||||||
|
emptyDescription="Нет проб с звездой на дашборде."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+424
-297
@@ -1,8 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
@@ -20,15 +21,12 @@ import {
|
|||||||
SCHEDULER_JOB_DESCRIPTIONS,
|
SCHEDULER_JOB_DESCRIPTIONS,
|
||||||
SCHEDULER_JOB_KEYS,
|
SCHEDULER_JOB_KEYS,
|
||||||
SCHEDULER_JOB_LABELS,
|
SCHEDULER_JOB_LABELS,
|
||||||
|
type CollectorSettingsDto,
|
||||||
type SchedulerJobStatusDto,
|
type SchedulerJobStatusDto,
|
||||||
type SchedulerStatusDto,
|
|
||||||
type SchedulerRunRowDto,
|
type SchedulerRunRowDto,
|
||||||
|
type UptimeSettingsDto,
|
||||||
} from "@/lib/scheduler-settings"
|
} from "@/lib/scheduler-settings"
|
||||||
import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings, putTrafficCollectorSettings, putServersApiPingSettings, putUptimeCollectorSettings, putInternetPathSettings } from "@/shared/api/collector-settings"
|
import { requestJson, ApiClientError } from "@/shared/api/http-client"
|
||||||
import { can } from "@/lib/auth"
|
|
||||||
import { getCertificateRenewSettings, putCertificateRenewSettings } from "@/shared/api/certificates"
|
|
||||||
import { getSchedulerStatus, listSchedulerRuns, runSchedulerJobNow, refreshScheduler } from "@/shared/api/scheduler"
|
|
||||||
import { ApiClientError } from "@/shared/api/http-client"
|
|
||||||
import {
|
import {
|
||||||
parseSchedulerRunSnapshot,
|
parseSchedulerRunSnapshot,
|
||||||
type AlertEngineRunSnapshot,
|
type AlertEngineRunSnapshot,
|
||||||
@@ -58,7 +56,6 @@ import {
|
|||||||
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
|
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
|
||||||
import { LogsCollectorSettings } from "@/components/logs/logs-collector-settings"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
AlertCircleIcon,
|
AlertCircleIcon,
|
||||||
@@ -71,13 +68,33 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
|
function makeApiFetch(backendUrl: string) {
|
||||||
|
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
return requestJson<T>(backendUrl, path, init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function extractApiError(reason: unknown): string {
|
function extractApiError(reason: unknown): string {
|
||||||
if (reason instanceof ApiClientError) return reason.message
|
if (reason instanceof ApiClientError) return reason.message
|
||||||
if (reason instanceof Error && reason.name === "ZodError") return "Настройки не подтверждены: получен некорректный ответ сервера"
|
|
||||||
if (reason instanceof Error) return reason.message
|
if (reason instanceof Error) return reason.message
|
||||||
return String(reason)
|
return String(reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSettled<T>(result: PromiseSettledResult<T>): T | null {
|
||||||
|
return result.status === "fulfilled" ? result.value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels: string[]): string[] {
|
||||||
|
const errors: string[] = []
|
||||||
|
for (let i = 0; i < results.length; i += 1) {
|
||||||
|
const result = results[i]
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
errors.push(`${labels[i]}: ${extractApiError(result.reason)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
function fmtMs(ms: number): string {
|
function fmtMs(ms: number): string {
|
||||||
if (ms < 1000) return `${ms} мс`
|
if (ms < 1000) return `${ms} мс`
|
||||||
const s = ms / 1000
|
const s = ms / 1000
|
||||||
@@ -85,7 +102,6 @@ function fmtMs(ms: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||||
if (snap.job === "router_logs") return <div className="flex flex-col gap-2 text-sm">{snap.skipped && <p>Сбор логов выключен или приостановлен.</p>}{snap.servers.map(server => <p key={server.serverId}>{server.name}: +{server.added} событий{server.gap ? " · Возможен разрыв истории" : ""}{server.error ? " · " + server.error : ""}</p>)}</div>
|
|
||||||
if (snap.job === "traffic") {
|
if (snap.job === "traffic") {
|
||||||
const t = snap as TrafficRunSnapshot
|
const t = snap as TrafficRunSnapshot
|
||||||
return (
|
return (
|
||||||
@@ -416,11 +432,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
||||||
const jobTitle = SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey
|
const jobTitle = SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey
|
||||||
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
|
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
|
||||||
const snapshot = useMemo(() => {
|
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
|
||||||
const parsed = parseSchedulerRunSnapshot(r.resultJson)
|
|
||||||
return parsed?.job === r.jobKey ? parsed : null
|
|
||||||
}, [r.resultJson, r.jobKey])
|
|
||||||
const hasSnapshot = r.resultJson != null && (typeof r.resultJson !== "string" || r.resultJson.trim() !== "")
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 text-sm">
|
<div className="flex flex-col gap-4 text-sm">
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
||||||
@@ -489,12 +501,6 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
|||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
|
||||||
{snapshot ? (
|
{snapshot ? (
|
||||||
<SnapshotTables snap={snapshot} />
|
<SnapshotTables snap={snapshot} />
|
||||||
) : hasSnapshot ? (
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertCircleIcon />
|
|
||||||
<AlertTitle>Снимок недоступен</AlertTitle>
|
|
||||||
<AlertDescription>Сохранённые результаты имеют неподдерживаемый формат или относятся к другой задаче.</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Снимок отсутствует (запись до обновления бекенда или задача без детализации).
|
Снимок отсутствует (запись до обновления бекенда или задача без детализации).
|
||||||
@@ -505,34 +511,47 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_LABELS = { traffic: "Трафик", rest: "Серверы REST API", uptime: "Uptime", internet: "Internet path", cert: "Сертификаты" } as const
|
type SchedulerToggleOverrides = {
|
||||||
const SETTINGS_PERMISSIONS = { traffic: "mm:traffic:write", rest: "mm:servers:write", uptime: "mm:uptime:write", internet: "mm:network:write", cert: "mm:certificates:write" } as const
|
trafficEnabled?: boolean
|
||||||
type SettingsKey = keyof typeof SETTINGS_LABELS
|
serversApiEnabled?: boolean
|
||||||
type SettingsRead = { known: boolean; error: string | null; receivedAt: string | null }
|
resourcesEnabled?: boolean
|
||||||
const INITIAL_SETTINGS: Record<SettingsKey, SettingsRead> = Object.fromEntries(Object.keys(SETTINGS_LABELS).map(key => [key, { known: false, error: null, receivedAt: null }])) as Record<SettingsKey, SettingsRead>
|
pingEnabled?: boolean
|
||||||
|
speedEnabled?: boolean
|
||||||
|
internetPathEnabled?: boolean
|
||||||
|
certRenewEnabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySchedulerJobsToDrafts(
|
||||||
|
jobs: SchedulerJobStatusDto[],
|
||||||
|
setters: {
|
||||||
|
setDraftTrafficEnabled: (value: boolean) => void
|
||||||
|
setDraftServersApiEnabled: (value: boolean) => void
|
||||||
|
setDraftResourcesEnabled: (value: boolean) => void
|
||||||
|
setDraftPingEnabled: (value: boolean) => void
|
||||||
|
setDraftSpeedEnabled: (value: boolean) => void
|
||||||
|
setDraftInternetPathEnabled: (value: boolean) => void
|
||||||
|
setDraftCertRenewEnabled: (value: boolean) => void
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const byKey = Object.fromEntries(jobs.map((job) => [job.jobKey, job])) as Record<string, SchedulerJobStatusDto>
|
||||||
|
if (byKey.traffic) setters.setDraftTrafficEnabled(!!byKey.traffic.enabled)
|
||||||
|
if (byKey.servers_rest_ping) setters.setDraftServersApiEnabled(!!byKey.servers_rest_ping.enabled)
|
||||||
|
if (byKey.uptime_resources) setters.setDraftResourcesEnabled(!!byKey.uptime_resources.enabled)
|
||||||
|
if (byKey.uptime_ping) setters.setDraftPingEnabled(!!byKey.uptime_ping.enabled)
|
||||||
|
if (byKey.uptime_speed) setters.setDraftSpeedEnabled(!!byKey.uptime_speed.enabled)
|
||||||
|
if (byKey.internet_path) setters.setDraftInternetPathEnabled(!!byKey.internet_path.enabled)
|
||||||
|
if (byKey.certificates_renew) setters.setDraftCertRenewEnabled(!!byKey.certificates_renew.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
export default function DataCollectionPage() {
|
export default function DataCollectionPage() {
|
||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
|
||||||
if (!prefsHydrated) return <p role="status" className="p-6 text-sm text-muted-foreground">Загрузка настроек источника…</p>
|
|
||||||
return <DataCollectionContent key={JSON.stringify([mode, backendUrl])} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataCollectionContent() {
|
|
||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const isLive = prefsHydrated && mode === "live"
|
const isLive = prefsHydrated && mode === "live"
|
||||||
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [trafficCollector, setTrafficCollector] = useState<Awaited<ReturnType<typeof getTrafficCollectorSettings>> | null>(null)
|
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
||||||
const [serversApiCollector, setServersApiCollector] = useState<Awaited<ReturnType<typeof getServersApiPingSettings>> | null>(null)
|
const [serversApiCollector, setServersApiCollector] = useState<CollectorSettingsDto | null>(null)
|
||||||
const [uptimeCollector, setUptimeCollector] = useState<Awaited<ReturnType<typeof getUptimeCollectorSettings>> | null>(null)
|
const [uptimeCollector, setUptimeCollector] = useState<UptimeSettingsDto | null>(null)
|
||||||
const [internetPathCollector, setInternetPathCollector] = useState<Awaited<ReturnType<typeof getInternetPathSettings>> | null>(null)
|
const [internetPathCollector, setInternetPathCollector] = useState<CollectorSettingsDto | null>(null)
|
||||||
const [settingsReads, setSettingsReads] = useState(INITIAL_SETTINGS)
|
|
||||||
const dirtySettings = useRef(new Set<SettingsKey>())
|
|
||||||
const dirtyFields = useRef<Record<SettingsKey, Set<string>>>({ traffic: new Set(), rest: new Set(), uptime: new Set(), internet: new Set(), cert: new Set() })
|
|
||||||
const [dirtyCount, setDirtyCount] = useState(0)
|
|
||||||
const [saveMessage, setSaveMessage] = useState<string | null>(null)
|
|
||||||
const mutationLock = useRef(false)
|
|
||||||
const writable = (key: SettingsKey) => isLive && can(SETTINGS_PERMISSIONS[key]) && settingsReads[key].known && !settingsReads[key].error
|
|
||||||
const markDirty = (key: SettingsKey, field: string) => { dirtySettings.current.add(key); dirtyFields.current[key].add(field); setDirtyCount(Object.values(dirtyFields.current).reduce((count, fields) => count + fields.size, 0)) }
|
|
||||||
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
|
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
|
||||||
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
|
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
|
||||||
const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300")
|
const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300")
|
||||||
@@ -551,12 +570,6 @@ function DataCollectionContent() {
|
|||||||
const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600")
|
const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600")
|
||||||
const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30")
|
const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30")
|
||||||
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
|
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
|
||||||
const [schedulerStatus, setSchedulerStatus] = useState<SchedulerStatusDto | null>(null)
|
|
||||||
const [schedulerStatusError, setSchedulerStatusError] = useState<string | null>(null)
|
|
||||||
const [schedulerStatusReceivedAt, setSchedulerStatusReceivedAt] = useState<string | null>(null)
|
|
||||||
const [runsKnown, setRunsKnown] = useState(false)
|
|
||||||
const [runsError, setRunsError] = useState<string | null>(null)
|
|
||||||
const [runsReceivedAt, setRunsReceivedAt] = useState<string | null>(null)
|
|
||||||
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
|
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
|
||||||
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
|
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
|
||||||
const [schedulerSaveBusy, setSchedulerSaveBusy] = useState(false)
|
const [schedulerSaveBusy, setSchedulerSaveBusy] = useState(false)
|
||||||
@@ -564,13 +577,6 @@ function DataCollectionContent() {
|
|||||||
const [collectorError, setCollectorError] = useState<string | null>(null)
|
const [collectorError, setCollectorError] = useState<string | null>(null)
|
||||||
const [openRunIds, setOpenRunIds] = useState<Set<string>>(() => new Set())
|
const [openRunIds, setOpenRunIds] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
const reader = useRef<{ active: boolean; revision: number; controller?: AbortController }>({ active: true, revision: 0 })
|
|
||||||
useEffect(() => {
|
|
||||||
reader.current.active = true
|
|
||||||
const state = reader.current
|
|
||||||
return () => { state.active = false; state.revision++; state.controller?.abort() }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const toggleRunOpen = useCallback((id: string, open: boolean) => {
|
const toggleRunOpen = useCallback((id: string, open: boolean) => {
|
||||||
setOpenRunIds((prev) => {
|
setOpenRunIds((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -578,190 +584,265 @@ function DataCollectionContent() {
|
|||||||
else next.delete(id)
|
else next.delete(id)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [setOpenRunIds])
|
}, [])
|
||||||
|
|
||||||
const schedulerJobsByKey = useMemo(() => {
|
const schedulerJobsByKey = useMemo(() => {
|
||||||
const jobs = schedulerStatus?.jobs ?? []
|
const jobs = uptimeCollector?.scheduler?.jobs ?? []
|
||||||
return Object.fromEntries(jobs.map((j) => [j.jobKey, j])) as Record<string, SchedulerJobStatusDto>
|
return Object.fromEntries(jobs.map((j) => [j.jobKey, j])) as Record<string, SchedulerJobStatusDto>
|
||||||
}, [schedulerStatus?.jobs])
|
}, [uptimeCollector?.scheduler?.jobs])
|
||||||
|
|
||||||
const loadCollectors = useCallback(async () => {
|
const loadCollectors = useCallback(async () => {
|
||||||
if (!isLive || !reader.current.active || mutationLock.current) return
|
if (!isLive) return
|
||||||
const revision = ++reader.current.revision
|
setCollectorError(null)
|
||||||
reader.current.controller?.abort()
|
|
||||||
const controller = new AbortController(); reader.current.controller = controller
|
|
||||||
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
|
||||||
const current = () => reader.current.active && revision === reader.current.revision && !controller.signal.aborted
|
|
||||||
setPageBusy(true)
|
setPageBusy(true)
|
||||||
try {
|
try {
|
||||||
const runsFilter = runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number]) ? runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number] : undefined
|
const runsQuery =
|
||||||
const runsRequest = listSchedulerRuns(backendUrl, { limit: 80, jobKey: runsFilter }, { signal }).then(data => {
|
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
|
||||||
if (current()) { setSchedulerRuns(data.runs); setRunsKnown(true); setRunsError(null); setRunsReceivedAt(new Date().toISOString()) }
|
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
|
||||||
return data
|
: "?limit=80"
|
||||||
}, error => { if (current()) setRunsError(extractApiError(error)); throw error })
|
const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes] = await Promise.allSettled([
|
||||||
const statusRequest = getSchedulerStatus(backendUrl, { signal }).then(data => {
|
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
|
||||||
if (current()) { setSchedulerStatus(data); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) }
|
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
|
||||||
return data
|
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
|
||||||
}, error => { if (current()) setSchedulerStatusError(extractApiError(error)); throw error })
|
apiFetch<CollectorSettingsDto>("/api/internet-path/settings"),
|
||||||
function publish<T>(key: SettingsKey, request: Promise<T>, accept: (data: T, updateDraft: boolean) => void) {
|
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"),
|
||||||
return request.then(data => {
|
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
|
||||||
if (current()) {
|
|
||||||
accept(data, !dirtySettings.current.has(key))
|
|
||||||
setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
|
|
||||||
}
|
|
||||||
}, error => {
|
|
||||||
if (current()) setSettingsReads(prev => ({ ...prev, [key]: { ...prev[key], error: extractApiError(error) } }))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await Promise.allSettled([
|
|
||||||
publish("traffic", getTrafficCollectorSettings(backendUrl, { signal }), (data, draft) => {
|
|
||||||
setTrafficCollector(data)
|
|
||||||
if (draft) { setTrafficIntervalDraft(String(data.intervalSec)); setTrafficRetentionDraft(String(data.retentionDays)); setDraftTrafficEnabled(data.enabled) }
|
|
||||||
}),
|
|
||||||
publish("rest", getServersApiPingSettings(backendUrl, { signal }), (data, draft) => {
|
|
||||||
setServersApiCollector(data)
|
|
||||||
if (draft) { setServersApiIntervalDraft(String(data.intervalSec)); setDraftServersApiEnabled(data.enabled) }
|
|
||||||
}),
|
|
||||||
publish("uptime", getUptimeCollectorSettings(backendUrl, { signal }), (data, draft) => {
|
|
||||||
setUptimeCollector(data)
|
|
||||||
if (draft) {
|
|
||||||
setUptimeResourceIntervalDraft(String(data.intervalSec)); setUptimeIntervalDraft(String(data.probeIntervalSec)); setUptimeSpeedIntervalDraft(String(data.speedIntervalSec)); setUptimeRetentionDraft(String(data.retentionDays))
|
|
||||||
setDraftResourcesEnabled(data.resourcesEnabled); setDraftPingEnabled(data.pingEnabled); setDraftSpeedEnabled(data.speedEnabled)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
publish("internet", getInternetPathSettings(backendUrl, { signal }), (data, draft) => {
|
|
||||||
setInternetPathCollector(data)
|
|
||||||
if (draft) { setInternetPathIntervalDraft(String(data.intervalSec)); setDraftInternetPathEnabled(data.enabled) }
|
|
||||||
}),
|
|
||||||
publish("cert", getCertificateRenewSettings(backendUrl, { signal }), (data, draft) => {
|
|
||||||
if (draft) { setCertRenewIntervalDraft(String(data.intervalSec)); setRenewBeforeDaysDraft(String(data.renewBeforeDays)); setDraftCertRenewEnabled(data.enabled) }
|
|
||||||
}),
|
|
||||||
runsRequest, statusRequest,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
} catch (e) {
|
const loadErrors = collectSettledErrors(
|
||||||
if (current()) setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
[trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes],
|
||||||
} finally {
|
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"],
|
||||||
if (current()) setPageBusy(false)
|
)
|
||||||
}
|
if (loadErrors.length > 0) {
|
||||||
}, [backendUrl, isLive, runFilterJobKey])
|
setCollectorError(loadErrors.join("; "))
|
||||||
|
}
|
||||||
|
|
||||||
const writeSettings = useCallback(async (key: SettingsKey, patch: Record<string, number | boolean>) => {
|
const traffic = readSettled(trafficRes)
|
||||||
if (!isLive || !reader.current.active || !can(SETTINGS_PERMISSIONS[key]) || !settingsReads[key].known || settingsReads[key].error) throw new Error("Настройки не получены или недостаточно прав для записи")
|
if (traffic) {
|
||||||
const data = key === "traffic" ? await putTrafficCollectorSettings(backendUrl, patch)
|
setTrafficCollector(traffic)
|
||||||
: key === "rest" ? await putServersApiPingSettings(backendUrl, patch)
|
setTrafficIntervalDraft(String(traffic.intervalSec))
|
||||||
: key === "uptime" ? await putUptimeCollectorSettings(backendUrl, patch)
|
setTrafficRetentionDraft(String(traffic.retentionDays))
|
||||||
: key === "internet" ? await putInternetPathSettings(backendUrl, patch)
|
setDraftTrafficEnabled(!!traffic.enabled)
|
||||||
: await putCertificateRenewSettings(backendUrl, patch)
|
}
|
||||||
if (Object.entries(patch).some(([field, value]) => (data as Record<string, unknown>)[field] !== value)) throw new Error("Сервер подтвердил другие настройки. Обновите данные перед повторной попыткой")
|
|
||||||
if (!reader.current.active) return
|
|
||||||
if (key === "traffic") setTrafficCollector(data as Awaited<ReturnType<typeof getTrafficCollectorSettings>>)
|
|
||||||
if (key === "rest") setServersApiCollector(data as Awaited<ReturnType<typeof getServersApiPingSettings>>)
|
|
||||||
if (key === "uptime") setUptimeCollector(data as Awaited<ReturnType<typeof getUptimeCollectorSettings>>)
|
|
||||||
if (key === "internet") setInternetPathCollector(data as Awaited<ReturnType<typeof getInternetPathSettings>>)
|
|
||||||
setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
|
|
||||||
}, [backendUrl, isLive, settingsReads])
|
|
||||||
|
|
||||||
const persistSchedulerDrafts = async () => {
|
const serversApi = readSettled(serversApiRes)
|
||||||
if (mutationLock.current || !reader.current.active) return
|
if (serversApi) {
|
||||||
const drafts: Record<SettingsKey, Record<string, string>> = {
|
setServersApiCollector(serversApi)
|
||||||
traffic: { intervalSec: trafficIntervalDraft, retentionDays: trafficRetentionDraft }, rest: { intervalSec: serversApiIntervalDraft },
|
setDraftServersApiEnabled(!!serversApi.enabled)
|
||||||
uptime: { intervalSec: uptimeResourceIntervalDraft, probeIntervalSec: uptimeIntervalDraft, speedIntervalSec: uptimeSpeedIntervalDraft, retentionDays: uptimeRetentionDraft },
|
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
|
||||||
internet: { intervalSec: internetPathIntervalDraft }, cert: { intervalSec: certRenewIntervalDraft, renewBeforeDays: renewBeforeDaysDraft },
|
}
|
||||||
}
|
|
||||||
const plan: { key: SettingsKey; patch: Record<string, number> }[] = []
|
const uptime = readSettled(uptimeRes)
|
||||||
try {
|
if (uptime) {
|
||||||
for (const key of Object.keys(drafts) as SettingsKey[]) {
|
setUptimeCollector(uptime)
|
||||||
if (!dirtyFields.current[key].size) continue
|
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
|
||||||
if (!writable(key)) throw new Error("Настройки не получены или недостаточно прав: " + SETTINGS_LABELS[key])
|
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
|
||||||
const patch: Record<string, number> = {}
|
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
|
||||||
for (const field of dirtyFields.current[key]) {
|
setUptimeRetentionDraft(String(uptime.retentionDays))
|
||||||
const raw = drafts[key][field]; const value = Number(raw)
|
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
|
||||||
const min = field === "retentionDays" || field === "renewBeforeDays" ? 1 : key === "cert" ? 300 : key === "internet" ? 30 : key === "rest" || field === "speedIntervalSec" ? 10 : 5
|
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
|
||||||
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < min || (field === "renewBeforeDays" && value > 90)) throw new Error(SETTINGS_LABELS[key] + ": укажите целое значение от " + min + (field === "renewBeforeDays" ? " до 90" : ""))
|
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
|
||||||
patch[field] = value
|
if (uptime.scheduler?.jobs?.length) {
|
||||||
|
applySchedulerJobsToDrafts(uptime.scheduler.jobs, {
|
||||||
|
setDraftTrafficEnabled,
|
||||||
|
setDraftServersApiEnabled,
|
||||||
|
setDraftResourcesEnabled,
|
||||||
|
setDraftPingEnabled,
|
||||||
|
setDraftSpeedEnabled,
|
||||||
|
setDraftInternetPathEnabled,
|
||||||
|
setDraftCertRenewEnabled,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
plan.push({ key, patch })
|
|
||||||
}
|
}
|
||||||
if (!plan.length) return
|
|
||||||
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
|
const internetPath = readSettled(internetPathRes)
|
||||||
const successes: string[] = [], errors: string[] = []
|
if (internetPath) {
|
||||||
for (const { key, patch } of plan) {
|
setInternetPathCollector(internetPath)
|
||||||
if (!reader.current.active) break
|
setDraftInternetPathEnabled(!!internetPath.enabled)
|
||||||
try {
|
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
|
||||||
await writeSettings(key, patch)
|
|
||||||
if (!reader.current.active) return
|
|
||||||
for (const field of Object.keys(patch)) dirtyFields.current[key].delete(field)
|
|
||||||
if (!dirtyFields.current[key].size) dirtySettings.current.delete(key)
|
|
||||||
successes.push(SETTINGS_LABELS[key])
|
|
||||||
} catch (error) { if (reader.current.active) errors.push(SETTINGS_LABELS[key] + ": " + extractApiError(error)) }
|
|
||||||
}
|
}
|
||||||
if (!reader.current.active) return
|
|
||||||
setDirtyCount(Object.values(dirtyFields.current).reduce((count, fields) => count + fields.size, 0))
|
const certRenew = readSettled(certRenewRes)
|
||||||
if (successes.length) setSaveMessage("Сохранение подтверждено: " + successes.join(", "))
|
if (certRenew) {
|
||||||
if (errors.length) setCollectorError(errors.join("; "))
|
setDraftCertRenewEnabled(!!certRenew.enabled)
|
||||||
} catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
|
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
|
||||||
finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
|
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runsPayload = readSettled(runsRes)
|
||||||
|
if (runsPayload) {
|
||||||
|
setSchedulerRuns(runsPayload.runs ?? [])
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
||||||
|
} finally {
|
||||||
|
setPageBusy(false)
|
||||||
|
}
|
||||||
|
}, [apiFetch, isLive, runFilterJobKey])
|
||||||
|
|
||||||
|
const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => {
|
||||||
|
const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled
|
||||||
|
const serversApiEnabled = overrides.serversApiEnabled ?? draftServersApiEnabled
|
||||||
|
const resourcesEnabled = overrides.resourcesEnabled ?? draftResourcesEnabled
|
||||||
|
const pingEnabled = overrides.pingEnabled ?? draftPingEnabled
|
||||||
|
const speedEnabled = overrides.speedEnabled ?? draftSpeedEnabled
|
||||||
|
const internetPathEnabled = overrides.internetPathEnabled ?? draftInternetPathEnabled
|
||||||
|
const certRenewEnabled = overrides.certRenewEnabled ?? draftCertRenewEnabled
|
||||||
|
|
||||||
|
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
|
||||||
|
const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14)
|
||||||
|
const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300)
|
||||||
|
const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15)
|
||||||
|
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
|
||||||
|
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
|
||||||
|
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
|
||||||
|
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300)
|
||||||
|
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
|
||||||
|
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
|
||||||
|
|
||||||
|
const saveResults = await Promise.allSettled([
|
||||||
|
apiFetch("/api/traffic/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: trafficEnabled,
|
||||||
|
intervalSec: tInt,
|
||||||
|
retentionDays: tRet,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
apiFetch("/api/servers-api-ping/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: serversApiEnabled,
|
||||||
|
intervalSec: sApiInt,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
apiFetch("/api/uptime/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
resourcesEnabled,
|
||||||
|
pingEnabled,
|
||||||
|
speedEnabled,
|
||||||
|
intervalSec: uRes,
|
||||||
|
probeIntervalSec: uPing,
|
||||||
|
speedIntervalSec: uSpd,
|
||||||
|
retentionDays: uRet,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
apiFetch("/api/internet-path/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: internetPathEnabled,
|
||||||
|
intervalSec: ipInt,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
apiFetch("/api/certificates/renew-settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: certRenewEnabled,
|
||||||
|
intervalSec: certRenewInt,
|
||||||
|
renewBeforeDays,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const saveErrors = collectSettledErrors(
|
||||||
|
saveResults,
|
||||||
|
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"],
|
||||||
|
)
|
||||||
|
if (saveErrors.length > 0) {
|
||||||
|
throw new Error(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
apiFetch,
|
||||||
|
certRenewIntervalDraft,
|
||||||
|
draftCertRenewEnabled,
|
||||||
|
draftInternetPathEnabled,
|
||||||
|
draftPingEnabled,
|
||||||
|
draftResourcesEnabled,
|
||||||
|
draftServersApiEnabled,
|
||||||
|
draftSpeedEnabled,
|
||||||
|
draftTrafficEnabled,
|
||||||
|
internetPathIntervalDraft,
|
||||||
|
renewBeforeDaysDraft,
|
||||||
|
serversApiIntervalDraft,
|
||||||
|
trafficIntervalDraft,
|
||||||
|
trafficRetentionDraft,
|
||||||
|
uptimeIntervalDraft,
|
||||||
|
uptimeResourceIntervalDraft,
|
||||||
|
uptimeRetentionDraft,
|
||||||
|
uptimeSpeedIntervalDraft,
|
||||||
|
])
|
||||||
|
|
||||||
const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
|
const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
|
||||||
const targets = {
|
const overrides: SchedulerToggleOverrides = {}
|
||||||
traffic: ["traffic", "enabled", setDraftTrafficEnabled], servers_rest_ping: ["rest", "enabled", setDraftServersApiEnabled],
|
if (jobKey === "traffic") {
|
||||||
uptime_resources: ["uptime", "resourcesEnabled", setDraftResourcesEnabled], uptime_ping: ["uptime", "pingEnabled", setDraftPingEnabled], uptime_speed: ["uptime", "speedEnabled", setDraftSpeedEnabled],
|
setDraftTrafficEnabled(nextEnabled)
|
||||||
internet_path: ["internet", "enabled", setDraftInternetPathEnabled], certificates_renew: ["cert", "enabled", setDraftCertRenewEnabled],
|
overrides.trafficEnabled = nextEnabled
|
||||||
} as const
|
} else if (jobKey === "servers_rest_ping") {
|
||||||
if (!(jobKey in targets) || mutationLock.current || !reader.current.active) return
|
setDraftServersApiEnabled(nextEnabled)
|
||||||
const [key, field, accept] = targets[jobKey as keyof typeof targets]
|
overrides.serversApiEnabled = nextEnabled
|
||||||
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
|
} else if (jobKey === "uptime_resources") {
|
||||||
|
setDraftResourcesEnabled(nextEnabled)
|
||||||
|
overrides.resourcesEnabled = nextEnabled
|
||||||
|
} else if (jobKey === "uptime_ping") {
|
||||||
|
setDraftPingEnabled(nextEnabled)
|
||||||
|
overrides.pingEnabled = nextEnabled
|
||||||
|
} else if (jobKey === "uptime_speed") {
|
||||||
|
setDraftSpeedEnabled(nextEnabled)
|
||||||
|
overrides.speedEnabled = nextEnabled
|
||||||
|
} else if (jobKey === "certificates_renew") {
|
||||||
|
setDraftCertRenewEnabled(nextEnabled)
|
||||||
|
overrides.certRenewEnabled = nextEnabled
|
||||||
|
} else if (jobKey === "internet_path") {
|
||||||
|
setDraftInternetPathEnabled(nextEnabled)
|
||||||
|
overrides.internetPathEnabled = nextEnabled
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCollectorError(null)
|
||||||
|
setSchedulerSaveBusy(true)
|
||||||
try {
|
try {
|
||||||
await writeSettings(key, { [field]: nextEnabled })
|
await persistSchedulerDrafts(overrides)
|
||||||
if (!reader.current.active) return
|
await loadCollectors()
|
||||||
accept(nextEnabled); setSaveMessage("Сохранение подтверждено: " + SETTINGS_LABELS[key])
|
} catch (e) {
|
||||||
} catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
|
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||||
finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
|
} finally {
|
||||||
}, [writeSettings])
|
setSchedulerSaveBusy(false)
|
||||||
|
}
|
||||||
|
}, [loadCollectors, persistSchedulerDrafts])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return
|
if (!isLive) {
|
||||||
queueMicrotask(() => { void loadCollectors() })
|
setTrafficCollector(null)
|
||||||
|
setServersApiCollector(null)
|
||||||
|
setUptimeCollector(null)
|
||||||
|
setInternetPathCollector(null)
|
||||||
|
setSchedulerRuns([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void loadCollectors()
|
||||||
}, [isLive, loadCollectors])
|
}, [isLive, loadCollectors])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpenRunIds(new Set())
|
||||||
|
}, [runFilterJobKey])
|
||||||
|
|
||||||
const handleRefreshScheduler = async () => {
|
const handleRefreshScheduler = async () => {
|
||||||
if (!isLive || !reader.current.active || mutationLock.current || !can("mm:settings:admin")) return
|
if (!isLive) return
|
||||||
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
|
setCollectorError(null)
|
||||||
try {
|
try {
|
||||||
const data = await refreshScheduler(backendUrl)
|
await apiFetch("/api/scheduler/refresh", { method: "POST" })
|
||||||
if (!reader.current.active) return
|
await loadCollectors()
|
||||||
setSchedulerStatus(data.status); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString())
|
} catch (e) {
|
||||||
setSaveMessage("Перечитывание таймеров подтверждено сервером")
|
setCollectorError(e instanceof Error ? e.message : "Не удалось обновить планировщик")
|
||||||
} catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
|
}
|
||||||
finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRunNow = async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number]) => {
|
const schedulerGridRows = useMemo<SchedulerJobGridRow[]>(() => {
|
||||||
const job = schedulerJobsByKey[jobKey]
|
|
||||||
if (!isLive || !reader.current.active || mutationLock.current || !can("mm:settings:admin") || !job || job.running) return
|
|
||||||
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setRunNowJobKey(jobKey); setCollectorError(null); setSaveMessage(null)
|
|
||||||
try {
|
|
||||||
const data = await runSchedulerJobNow(backendUrl, jobKey)
|
|
||||||
if (!reader.current.active) return
|
|
||||||
if (data.status) { setSchedulerStatus(data.status); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) }
|
|
||||||
else setSchedulerStatusError(data.statusError ?? "Статус после запуска недоступен")
|
|
||||||
const skipped = "skipped" in data.result.result && data.result.result.skipped
|
|
||||||
const notDue = data.result.result.job === "backups" && !data.result.result.due
|
|
||||||
setSaveMessage((!data.ok ? "Запуск завершился с ошибками: " : skipped || notDue ? "Прогон пропущен: " : "Запуск подтверждён: ") + SCHEDULER_JOB_LABELS[jobKey])
|
|
||||||
const warnings = [data.result.error, data.result.eventError ? "Журнал событий: " + data.result.eventError : null, data.statusError ? "Статус: " + data.statusError : null].filter(Boolean)
|
|
||||||
if (warnings.length) setCollectorError(warnings.join("; "))
|
|
||||||
} catch (error) {
|
|
||||||
if (reader.current.active) setCollectorError(extractApiError(error) + ". Результат операции неизвестен; перед повторным запуском обновите журнал.")
|
|
||||||
} finally { mutationLock.current = false; if (reader.current.active) { setSchedulerSaveBusy(false); setRunNowJobKey(null) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
const schedulerGridRows: SchedulerJobGridRow[] = (() => {
|
|
||||||
return SCHEDULER_JOB_KEYS.map((jobKey) => {
|
return SCHEDULER_JOB_KEYS.map((jobKey) => {
|
||||||
const j = schedulerJobsByKey[jobKey]
|
const j = schedulerJobsByKey[jobKey]
|
||||||
const settingsKey: SettingsKey = jobKey === "traffic" ? "traffic" : jobKey === "servers_rest_ping" ? "rest" : jobKey.startsWith("uptime_") ? "uptime" : jobKey === "certificates_renew" ? "cert" : "internet"
|
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
|
||||||
const fixedSchedule = jobKey === "router_logs" || jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
|
|
||||||
const enabled = fixedSchedule
|
const enabled = fixedSchedule
|
||||||
? Boolean(j?.enabled ?? true)
|
? Boolean(j?.enabled ?? true)
|
||||||
: jobKey === "traffic"
|
: jobKey === "traffic"
|
||||||
@@ -823,18 +904,16 @@ function DataCollectionContent() {
|
|||||||
? 21600
|
? 21600
|
||||||
: 300
|
: 300
|
||||||
|
|
||||||
const settingsKnown = fixedSchedule ? Boolean(j) : settingsReads[settingsKey].known
|
|
||||||
return {
|
return {
|
||||||
id: jobKey,
|
id: jobKey,
|
||||||
settingsKnown,
|
|
||||||
jobKey,
|
jobKey,
|
||||||
label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey,
|
label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey,
|
||||||
description: SCHEDULER_JOB_DESCRIPTIONS[jobKey],
|
description: SCHEDULER_JOB_DESCRIPTIONS[jobKey],
|
||||||
fixedSchedule,
|
fixedSchedule,
|
||||||
enabled,
|
enabled,
|
||||||
intervalValue: settingsKnown ? intervalValue : "",
|
intervalValue,
|
||||||
intervalReadOnly: fixedSchedule,
|
intervalReadOnly: fixedSchedule,
|
||||||
intervalDisabled: !settingsKnown || schedulerSaveBusy || (!fixedSchedule && !writable(settingsKey)),
|
intervalDisabled: !enabled && !fixedSchedule,
|
||||||
defaultInterval,
|
defaultInterval,
|
||||||
job: j,
|
job: j,
|
||||||
onEnabledChange: fixedSchedule
|
onEnabledChange: fixedSchedule
|
||||||
@@ -842,32 +921,94 @@ function DataCollectionContent() {
|
|||||||
: (nextEnabled) => {
|
: (nextEnabled) => {
|
||||||
void handleJobEnabledChange(jobKey, nextEnabled)
|
void handleJobEnabledChange(jobKey, nextEnabled)
|
||||||
},
|
},
|
||||||
onIntervalChange: (value: string) => { markDirty(settingsKey, jobKey === "uptime_ping" ? "probeIntervalSec" : jobKey === "uptime_speed" ? "speedIntervalSec" : "intervalSec"); onIntervalChange(value) },
|
onIntervalChange,
|
||||||
onRunNow: async () => { await handleRunNow(jobKey) },
|
onRunNow: async () => {
|
||||||
runNowDisabled: schedulerSaveBusy || !can("mm:settings:admin") || !j || j.running,
|
setRunNowJobKey(jobKey)
|
||||||
|
setCollectorError(null)
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/scheduler/jobs/${encodeURIComponent(jobKey)}/run-now`, {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
await loadCollectors()
|
||||||
|
} catch (e) {
|
||||||
|
setCollectorError(e instanceof Error ? e.message : "Ошибка запуска")
|
||||||
|
} finally {
|
||||||
|
setRunNowJobKey(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
runNowLoading: runNowJobKey === jobKey,
|
runNowLoading: runNowJobKey === jobKey,
|
||||||
saveBusy: schedulerSaveBusy || !writable(settingsKey),
|
saveBusy: schedulerSaveBusy,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})()
|
}, [
|
||||||
|
apiFetch,
|
||||||
|
certRenewIntervalDraft,
|
||||||
|
draftCertRenewEnabled,
|
||||||
|
draftInternetPathEnabled,
|
||||||
|
draftPingEnabled,
|
||||||
|
draftResourcesEnabled,
|
||||||
|
draftServersApiEnabled,
|
||||||
|
draftSpeedEnabled,
|
||||||
|
draftTrafficEnabled,
|
||||||
|
handleJobEnabledChange,
|
||||||
|
internetPathIntervalDraft,
|
||||||
|
loadCollectors,
|
||||||
|
runNowJobKey,
|
||||||
|
schedulerJobsByKey,
|
||||||
|
schedulerSaveBusy,
|
||||||
|
serversApiIntervalDraft,
|
||||||
|
trafficIntervalDraft,
|
||||||
|
uptimeIntervalDraft,
|
||||||
|
uptimeResourceIntervalDraft,
|
||||||
|
uptimeSpeedIntervalDraft,
|
||||||
|
])
|
||||||
|
|
||||||
const enabledJobsCount = schedulerStatus?.jobs.filter(job => job.enabled).length ?? 0
|
const enabledJobsCount = useMemo(() => {
|
||||||
const schedulerJobCount = schedulerStatus?.jobs.length ?? 0
|
const jobs = uptimeCollector?.scheduler?.jobs
|
||||||
const runningJobsCount = schedulerStatus?.jobs.filter(job => job.running).length ?? 0
|
if (jobs?.length) return jobs.filter((job) => job.enabled).length
|
||||||
|
|
||||||
|
let n = draftTrafficEnabled ? 1 : 0
|
||||||
|
if (draftServersApiEnabled) n += 1
|
||||||
|
if (draftResourcesEnabled) n += 1
|
||||||
|
if (draftPingEnabled) n += 1
|
||||||
|
if (draftSpeedEnabled) n += 1
|
||||||
|
if (draftInternetPathEnabled) n += 1
|
||||||
|
if (draftCertRenewEnabled) n += 1
|
||||||
|
return n
|
||||||
|
}, [
|
||||||
|
draftCertRenewEnabled,
|
||||||
|
draftInternetPathEnabled,
|
||||||
|
draftPingEnabled,
|
||||||
|
draftResourcesEnabled,
|
||||||
|
draftServersApiEnabled,
|
||||||
|
draftSpeedEnabled,
|
||||||
|
draftTrafficEnabled,
|
||||||
|
uptimeCollector?.scheduler?.jobs,
|
||||||
|
])
|
||||||
|
|
||||||
|
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
|
||||||
|
|
||||||
|
const runningJobsCount = useMemo(
|
||||||
|
() => (uptimeCollector?.scheduler?.jobs ?? []).filter((j) => j.running).length,
|
||||||
|
[uptimeCollector?.scheduler?.jobs],
|
||||||
|
)
|
||||||
|
|
||||||
const errorRunsInView = useMemo(() => schedulerRuns.filter((r) => r.status === "error").length, [schedulerRuns])
|
const errorRunsInView = useMemo(() => schedulerRuns.filter((r) => r.status === "error").length, [schedulerRuns])
|
||||||
|
|
||||||
const stats = [
|
const stats = useMemo(
|
||||||
|
() => [
|
||||||
{
|
{
|
||||||
label: "Включено задач",
|
label: "Включено задач",
|
||||||
value: schedulerStatus ? `${enabledJobsCount} / ${schedulerJobCount}` : "—",
|
value: `${enabledJobsCount} / ${schedulerJobCount}`,
|
||||||
sub: schedulerStatusError ? (schedulerStatus ? "Последний подтверждённый статус" : "Статус не получен") : schedulerStatus ? "По сохранённым задачам планировщика" : "Ожидание статуса",
|
sub: uptimeCollector?.scheduler?.jobs?.length
|
||||||
|
? "По сохранённым задачам планировщика"
|
||||||
|
: "По переключателям на этой странице",
|
||||||
icon: <CalendarClockIcon className="size-4" />,
|
icon: <CalendarClockIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Сейчас выполняется",
|
label: "Сейчас выполняется",
|
||||||
value: schedulerStatus ? String(runningJobsCount) : "—",
|
value: String(runningJobsCount),
|
||||||
sub: schedulerStatusError ? "Статус требует обновления" : "Фоновые прогоны планировщика",
|
sub: "Фоновые прогоны планировщика",
|
||||||
icon: <LoaderCircleIcon className="size-4" />,
|
icon: <LoaderCircleIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -875,20 +1016,31 @@ function DataCollectionContent() {
|
|||||||
value: trafficCollector?.lastCollectedAt
|
value: trafficCollector?.lastCollectedAt
|
||||||
? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit" })
|
? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit" })
|
||||||
: "—",
|
: "—",
|
||||||
sub: settingsReads.traffic.error ? "Последний сбор требует обновления" : trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
||||||
icon: trafficCollector?.lastError ? (
|
icon: trafficCollector?.lastError ? (
|
||||||
<XCircleIcon className="size-4" />
|
<XCircleIcon className="size-4" />
|
||||||
) : trafficCollector?.lastCollectedAt ? (
|
) : (
|
||||||
<CheckCircleIcon className="size-4" />
|
<CheckCircleIcon className="size-4" />
|
||||||
) : (<CalendarClockIcon className="size-4" />),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Журнал (в списке)",
|
label: "Журнал (в списке)",
|
||||||
value: runsKnown ? String(schedulerRuns.length) : "—",
|
value: String(schedulerRuns.length),
|
||||||
sub: runsError ? (runsKnown ? "Показана последняя подтверждённая история" : "История не получена") : runsKnown ? (errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет") : "Ожидание истории",
|
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
|
||||||
icon: <DatabaseIcon className="size-4" />,
|
icon: <DatabaseIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
|
[
|
||||||
|
enabledJobsCount,
|
||||||
|
schedulerJobCount,
|
||||||
|
runningJobsCount,
|
||||||
|
trafficCollector?.lastCollectedAt,
|
||||||
|
trafficCollector?.lastDurationMs,
|
||||||
|
trafficCollector?.lastError,
|
||||||
|
schedulerRuns.length,
|
||||||
|
errorRunsInView,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -902,13 +1054,13 @@ function DataCollectionContent() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!isLive || pageBusy || schedulerSaveBusy}
|
disabled={!isLive || pageBusy}
|
||||||
onClick={() => { void loadCollectors() }}
|
onClick={() => { void loadCollectors() }}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className={cn("size-4", pageBusy && "animate-spin")} />
|
<RefreshCwIcon className={cn("size-4", pageBusy && "animate-spin")} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" disabled={!isLive || pageBusy || schedulerSaveBusy || !can("mm:settings:admin")} onClick={() => { void handleRefreshScheduler() }}>
|
<Button variant="outline" size="sm" disabled={!isLive || pageBusy} onClick={() => { void handleRefreshScheduler() }}>
|
||||||
Перечитать таймеры
|
Перечитать таймеры
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -938,39 +1090,23 @@ function DataCollectionContent() {
|
|||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLive && schedulerStatusError ? (
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertCircleIcon aria-hidden="true" />
|
|
||||||
<AlertTitle>{schedulerStatus ? "Статус планировщика не обновлён" : "Статус планировщика не получен"}</AlertTitle>
|
|
||||||
<AlertDescription>{schedulerStatusError}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
{isLive && schedulerStatusReceivedAt ? <p className="text-xs text-muted-foreground">Статус получен: {new Date(schedulerStatusReceivedAt).toLocaleString("ru-RU")}</p> : null}
|
|
||||||
{isLive && Object.entries(settingsReads).map(([key, value]) => (
|
|
||||||
<div key={key} className="text-xs text-muted-foreground" data-testid={"settings-" + key}>
|
|
||||||
<p role="status">{SETTINGS_LABELS[key as SettingsKey]} — {value.known ? "настройки получены" : "настройки не получены"}{value.receivedAt ? " · Получено: " + new Date(value.receivedAt).toLocaleString("ru-RU") : ""}</p>
|
|
||||||
{value.error ? <Alert variant="warning" className="mt-1"><AlertCircleIcon aria-hidden="true" /><AlertDescription>{value.error}{value.known ? ". Сохранены последние подтверждённые данные." : ""}</AlertDescription></Alert> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{isLive && saveMessage ? <p role="status" className="text-sm text-muted-foreground">{saveMessage}</p> : null}
|
|
||||||
{isLive && collectorError && (
|
{isLive && collectorError && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircleIcon />
|
<AlertCircleIcon />
|
||||||
<AlertTitle>Операция не завершена</AlertTitle>
|
<AlertTitle>Ошибка загрузки статуса коллекторов</AlertTitle>
|
||||||
<AlertDescription>{collectorError}</AlertDescription>
|
<AlertDescription>{collectorError}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLive && can("mm:settings:admin") && <LogsCollectorSettings key={backendUrl} base={backendUrl} onSaved={settings => setSchedulerStatus(previous => previous ? { ...previous, jobs: previous.jobs.map(job => job.jobKey === "router_logs" ? { ...job, enabled: settings.enabled, intervalSec: settings.intervalSec } : job) } : previous)} />}
|
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<>
|
<>
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка сбора данных"
|
aria-label="Сводка сбора данных"
|
||||||
items={stats.map((s, i) => ({
|
items={stats.map((s, i) => ({
|
||||||
id: `dc-${i}`,
|
id: `dc-${i}`,
|
||||||
label: <span className="whitespace-normal">{s.label}</span>,
|
label: s.label,
|
||||||
value: s.value,
|
value: s.value,
|
||||||
hint: <span className="text-xs text-muted-foreground whitespace-normal">{s.sub}</span>,
|
hint: s.sub,
|
||||||
icon: s.icon,
|
icon: s.icon,
|
||||||
iconClassName:
|
iconClassName:
|
||||||
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
|
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
|
||||||
@@ -979,7 +1115,7 @@ function DataCollectionContent() {
|
|||||||
? "text-warning"
|
? "text-warning"
|
||||||
: s.label === "Журнал (в списке)" && errorRunsInView
|
: s.label === "Журнал (в списке)" && errorRunsInView
|
||||||
? "text-destructive"
|
? "text-destructive"
|
||||||
: s.label === "Трафик — последний сбор" && trafficCollector?.lastCollectedAt
|
: s.label === "Трафик — последний сбор"
|
||||||
? "text-success"
|
? "text-success"
|
||||||
: "text-muted-foreground",
|
: "text-muted-foreground",
|
||||||
variant:
|
variant:
|
||||||
@@ -995,7 +1131,7 @@ function DataCollectionContent() {
|
|||||||
<div className="border-b border-border px-5 py-4">
|
<div className="border-b border-border px-5 py-4">
|
||||||
<p className="text-base font-medium">Планировщик сбора данных</p>
|
<p className="text-base font-medium">Планировщик сбора данных</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Интервалы и вкл/выкл по задачам. Переключатель сохраняет только свой флаг; кнопка ниже — изменённые интервалы и срок хранения. Ручной запуск использует сохранённые настройки, без отправки черновика.
|
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
||||||
@@ -1005,10 +1141,8 @@ function DataCollectionContent() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
||||||
<Input
|
<Input
|
||||||
value={settingsReads.traffic.known ? trafficRetentionDraft : ""}
|
value={trafficRetentionDraft}
|
||||||
aria-label="Хранение трафика"
|
onChange={(e) => setTrafficRetentionDraft(e.target.value)}
|
||||||
disabled={!writable("traffic") || schedulerSaveBusy}
|
|
||||||
onChange={(e) => { markDirty("traffic", "retentionDays"); setTrafficRetentionDraft(e.target.value) }}
|
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
/>
|
/>
|
||||||
@@ -1016,10 +1150,8 @@ function DataCollectionContent() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов uptime (дней)</p>
|
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов uptime (дней)</p>
|
||||||
<Input
|
<Input
|
||||||
value={settingsReads.uptime.known ? uptimeRetentionDraft : ""}
|
value={uptimeRetentionDraft}
|
||||||
aria-label="Хранение uptime"
|
onChange={(e) => setUptimeRetentionDraft(e.target.value)}
|
||||||
disabled={!writable("uptime") || schedulerSaveBusy}
|
|
||||||
onChange={(e) => { markDirty("uptime", "retentionDays"); setUptimeRetentionDraft(e.target.value) }}
|
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
/>
|
/>
|
||||||
@@ -1027,13 +1159,11 @@ function DataCollectionContent() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
|
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
|
||||||
<Input
|
<Input
|
||||||
value={settingsReads.cert.known ? renewBeforeDaysDraft : ""}
|
value={renewBeforeDaysDraft}
|
||||||
aria-label="Обновлять сертификаты за дней"
|
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
||||||
|
|
||||||
onChange={(e) => { markDirty("cert", "renewBeforeDays"); setRenewBeforeDaysDraft(e.target.value) }}
|
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
disabled={!writable("cert") || schedulerSaveBusy}
|
disabled={!draftCertRenewEnabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground mt-1">
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
|
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
|
||||||
@@ -1045,12 +1175,22 @@ function DataCollectionContent() {
|
|||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="Сохранить настройки планировщика"
|
disabled={schedulerSaveBusy}
|
||||||
disabled={schedulerSaveBusy || dirtyCount === 0}
|
onClick={async () => {
|
||||||
onClick={() => { void persistSchedulerDrafts() }}
|
setSchedulerSaveBusy(true)
|
||||||
|
setCollectorError(null)
|
||||||
|
try {
|
||||||
|
await persistSchedulerDrafts()
|
||||||
|
await loadCollectors()
|
||||||
|
} catch (e) {
|
||||||
|
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||||
|
} finally {
|
||||||
|
setSchedulerSaveBusy(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
|
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
|
||||||
Сохранить изменения
|
Сохранить настройки планировщика
|
||||||
</Button>
|
</Button>
|
||||||
<div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
|
<div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
|
||||||
<p>
|
<p>
|
||||||
@@ -1066,7 +1206,7 @@ function DataCollectionContent() {
|
|||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>
|
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>
|
||||||
{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : (settingsReads.traffic.known ? "Трафик: ошибки не записаны" : "Трафик: состояние неизвестно")}
|
{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : "Трафик: ошибок нет"}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
REST API (серверы):{" "}
|
REST API (серверы):{" "}
|
||||||
@@ -1083,7 +1223,7 @@ function DataCollectionContent() {
|
|||||||
<p className={cn(serversApiCollector?.lastError ? "text-destructive" : "")}>
|
<p className={cn(serversApiCollector?.lastError ? "text-destructive" : "")}>
|
||||||
{serversApiCollector?.lastError
|
{serversApiCollector?.lastError
|
||||||
? `REST API (серверы): ${serversApiCollector.lastError}`
|
? `REST API (серверы): ${serversApiCollector.lastError}`
|
||||||
: (settingsReads.rest.known ? "REST API (серверы): ошибки не записаны" : "REST API (серверы): состояние неизвестно")}
|
: "REST API (серверы): ошибок нет"}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Uptime (агрегат в настройках):{" "}
|
Uptime (агрегат в настройках):{" "}
|
||||||
@@ -1093,7 +1233,7 @@ function DataCollectionContent() {
|
|||||||
· {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}
|
· {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}
|
||||||
</p>
|
</p>
|
||||||
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>
|
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>
|
||||||
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : (settingsReads.uptime.known ? "Uptime: ошибки не записаны" : "Uptime: состояние неизвестно")}
|
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : "Uptime: ошибок нет"}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Internet Path snapshot:{" "}
|
Internet Path snapshot:{" "}
|
||||||
@@ -1105,7 +1245,7 @@ function DataCollectionContent() {
|
|||||||
<p className={cn(internetPathCollector?.lastError ? "text-destructive" : "")}>
|
<p className={cn(internetPathCollector?.lastError ? "text-destructive" : "")}>
|
||||||
{internetPathCollector?.lastError
|
{internetPathCollector?.lastError
|
||||||
? `Internet Path: ${internetPathCollector.lastError}`
|
? `Internet Path: ${internetPathCollector.lastError}`
|
||||||
: (settingsReads.internet.known ? "Internet Path: ошибки не записаны" : "Internet Path: состояние неизвестно")}
|
: "Internet Path: ошибок нет"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1115,7 +1255,7 @@ function DataCollectionContent() {
|
|||||||
|
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="Журнал прогонов"
|
title="Журнал прогонов"
|
||||||
description="Последние 80 запусков. Раскройте запись для результатов и объяснения ошибок."
|
description="Таблица `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
||||||
headerRight={
|
headerRight={
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
|
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
|
||||||
@@ -1125,8 +1265,7 @@ function DataCollectionContent() {
|
|||||||
id="run-filter"
|
id="run-filter"
|
||||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[180px]"
|
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[180px]"
|
||||||
value={runFilterJobKey}
|
value={runFilterJobKey}
|
||||||
disabled={schedulerSaveBusy}
|
onChange={(e) => setRunFilterJobKey(e.target.value)}
|
||||||
onChange={(e) => { setRunFilterJobKey(e.target.value); setOpenRunIds(new Set()); setSchedulerRuns([]); setRunsKnown(false); setRunsError(null); setRunsReceivedAt(null) }}
|
|
||||||
>
|
>
|
||||||
<option value="">Все</option>
|
<option value="">Все</option>
|
||||||
{SCHEDULER_JOB_KEYS.map((k) => (
|
{SCHEDULER_JOB_KEYS.map((k) => (
|
||||||
@@ -1139,19 +1278,7 @@ function DataCollectionContent() {
|
|||||||
}
|
}
|
||||||
contentClassName="px-0 pb-0"
|
contentClassName="px-0 pb-0"
|
||||||
>
|
>
|
||||||
{runsError ? (
|
{schedulerRuns.length === 0 ? (
|
||||||
<div className="mx-4 mb-3">
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertCircleIcon aria-hidden="true" />
|
|
||||||
<AlertTitle>{runsKnown ? "История не обновлена" : "История не получена"}</AlertTitle>
|
|
||||||
<AlertDescription>{runsError}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{runsReceivedAt ? <p className="px-4 pb-3 text-xs text-muted-foreground">История получена: {new Date(runsReceivedAt).toLocaleString("ru-RU")}</p> : null}
|
|
||||||
{!runsKnown ? (
|
|
||||||
!runsError ? <p role="status" className="px-4 py-10 text-center text-sm text-muted-foreground">Загрузка журнала…</p> : null
|
|
||||||
) : schedulerRuns.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-10 px-4">Пока нет прогонов</p>
|
<p className="text-sm text-muted-foreground text-center py-10 px-4">Пока нет прогонов</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
|
|||||||
+28
-13
@@ -8,22 +8,25 @@ import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
|
|||||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { domains as mockDomains } from "@/lib/data"
|
import { domains as mockDomains } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
||||||
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function DomainsPage() {
|
export default function DomainsPage() {
|
||||||
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
const { mode } = useDataSource()
|
||||||
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live"
|
const useEvoCatalog = mode === "live" && enabled
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (mode === "mock") return mockDomains
|
if (!useEvoCatalog) return mockDomains
|
||||||
if (loading && !snapshot) return []
|
if (loading && !snapshot) return []
|
||||||
return snapshot?.domains ?? []
|
return snapshot?.domains ?? []
|
||||||
}, [mode, loading, snapshot])
|
}, [useEvoCatalog, loading, snapshot])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search) return rows
|
if (!search) return rows
|
||||||
@@ -40,7 +43,8 @@ export default function DomainsPage() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
||||||
actions={mode === "live" ? null : <>
|
actions={
|
||||||
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
<UploadIcon className="size-4" />Импорт
|
<UploadIcon className="size-4" />Импорт
|
||||||
</Button>
|
</Button>
|
||||||
@@ -54,23 +58,34 @@ export default function DomainsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Домены из каталога для BGP-фильтрации
|
Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
{useEvoCatalog && (
|
||||||
|
<p className={cn(
|
||||||
|
"text-xs mt-2 flex items-center gap-1.5",
|
||||||
|
error ? "text-destructive" : "text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||||
|
{error
|
||||||
|
? `EvoBGP: ${error}`
|
||||||
|
: snapshot?.fetchedAt
|
||||||
|
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||||
|
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по домену…"
|
searchPlaceholder="Поиск по домену…"
|
||||||
countLabel={known ? `${filtered.length} доменов` : "Количество: —"}
|
countLabel={`${filtered.length} доменов`}
|
||||||
/>
|
/>
|
||||||
{known || loading ? <DomainsDataGrid
|
<DomainsDataGrid
|
||||||
domains={filtered}
|
domains={filtered}
|
||||||
isLoading={useEvoCatalog && loading && !snapshot}
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
pagination={useEvoCatalog}
|
pagination={useEvoCatalog}
|
||||||
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
/>
|
||||||
/> : <EvoBgpCatalogUnavailable />}
|
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+212
-448
File diff suppressed because it is too large.
Load diff
+218
-415
File diff suppressed because it is too large.
Load diff
+420
-333
File diff suppressed because it is too large.
Load diff
@@ -8,22 +8,25 @@ import { IpRangesDataGrid } from "@/components/data-grids/ip-ranges-data-grid"
|
|||||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { ipRanges as mockIpRanges } from "@/lib/data"
|
import { ipRanges as mockIpRanges } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
||||||
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function IpRangesPage() {
|
export default function IpRangesPage() {
|
||||||
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
const { mode } = useDataSource()
|
||||||
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live"
|
const useEvoCatalog = mode === "live" && enabled
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (mode === "mock") return mockIpRanges
|
if (!useEvoCatalog) return mockIpRanges
|
||||||
if (loading && !snapshot) return []
|
if (loading && !snapshot) return []
|
||||||
return snapshot?.ipRanges ?? []
|
return snapshot?.ipRanges ?? []
|
||||||
}, [mode, loading, snapshot])
|
}, [useEvoCatalog, loading, snapshot])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search) return rows
|
if (!search) return rows
|
||||||
@@ -41,7 +44,8 @@ export default function IpRangesPage() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
||||||
actions={mode === "live" ? null : <>
|
actions={
|
||||||
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
<UploadIcon className="size-4" />Импорт
|
<UploadIcon className="size-4" />Импорт
|
||||||
</Button>
|
</Button>
|
||||||
@@ -55,23 +59,34 @@ export default function IpRangesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
CIDR-блоки из каталога для сетевых фильтров
|
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
{useEvoCatalog && (
|
||||||
|
<p className={cn(
|
||||||
|
"text-xs mt-2 flex items-center gap-1.5",
|
||||||
|
error ? "text-destructive" : "text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||||
|
{error
|
||||||
|
? `EvoBGP: ${error}`
|
||||||
|
: snapshot?.fetchedAt
|
||||||
|
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||||
|
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||||
countLabel={known ? `${filtered.length} диапазонов` : "Количество: —"}
|
countLabel={`${filtered.length} диапазонов`}
|
||||||
/>
|
/>
|
||||||
{known || loading ? <IpRangesDataGrid
|
<IpRangesDataGrid
|
||||||
ipRanges={filtered}
|
ipRanges={filtered}
|
||||||
isLoading={useEvoCatalog && loading && !snapshot}
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
pagination={useEvoCatalog}
|
pagination={useEvoCatalog}
|
||||||
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
/>
|
||||||
/> : <EvoBgpCatalogUnavailable />}
|
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+204
-349
@@ -1,10 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { can } from "@/lib/auth"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
import { z } from "zod"
|
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { servers as mockServers } from "@/lib/data"
|
import { servers as mockServers } from "@/lib/data"
|
||||||
import type { Server } from "@/lib/data"
|
import type { Server } from "@/lib/data"
|
||||||
@@ -38,7 +34,6 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import {
|
import {
|
||||||
createIpsecUser,
|
createIpsecUser,
|
||||||
deleteIpsecCert,
|
deleteIpsecCert,
|
||||||
@@ -53,7 +48,6 @@ import {
|
|||||||
patchIpsecUser,
|
patchIpsecUser,
|
||||||
restoreIpsecRevision,
|
restoreIpsecRevision,
|
||||||
} from "@/shared/api/ipsec"
|
} from "@/shared/api/ipsec"
|
||||||
import type { IpsecExistingCertExportOptions } from "@/shared/api/ipsec"
|
|
||||||
import type {
|
import type {
|
||||||
IpsecCertBundle,
|
IpsecCertBundle,
|
||||||
IpsecCertInfoDto,
|
IpsecCertInfoDto,
|
||||||
@@ -67,7 +61,7 @@ import {
|
|||||||
type ServerTileItem,
|
type ServerTileItem,
|
||||||
} from "@/components/server-tile-rail"
|
} from "@/components/server-tile-rail"
|
||||||
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
|
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { findFreePoolIp } from "@/lib/ipsec-client"
|
import { findFreePoolIp } from "@/lib/ipsec-client"
|
||||||
import {
|
import {
|
||||||
@@ -145,14 +139,12 @@ interface BackendServer {
|
|||||||
country: string
|
country: string
|
||||||
type?: Server["type"]
|
type?: Server["type"]
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
status?: Server["status"] | null
|
status?: Server["status"]
|
||||||
latency?: number | null
|
latency?: number | null
|
||||||
asn?: string
|
asn?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type InventoryServer = Server & { monitoringUnknown?: boolean }
|
function mapBackendServer(s: BackendServer): Server {
|
||||||
|
|
||||||
function mapBackendServer(s: BackendServer): InventoryServer {
|
|
||||||
return {
|
return {
|
||||||
id: String(s.id),
|
id: String(s.id),
|
||||||
name: s.name || s.host,
|
name: s.name || s.host,
|
||||||
@@ -164,56 +156,28 @@ function mapBackendServer(s: BackendServer): InventoryServer {
|
|||||||
asn: s.asn ?? "",
|
asn: s.asn ?? "",
|
||||||
type: s.type ?? "exit-node",
|
type: s.type ?? "exit-node",
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
status: s.status ?? "offline",
|
status: s.status ?? "online",
|
||||||
monitoringUnknown: s.status == null,
|
|
||||||
latency: s.latency ?? null,
|
latency: s.latency ?? null,
|
||||||
sessions: 0,
|
sessions: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ipsecInventorySchema = z.array(z.object({
|
export default function IpsecPage() {
|
||||||
id: z.number().int().positive(), name: z.string(), host: z.string(), enabled: z.boolean(),
|
|
||||||
country: z.string().optional().default("UN"),
|
|
||||||
type: z.enum(["jump-host", "exit-node", "home-router"]).optional(),
|
|
||||||
status: z.enum(["online", "offline", "degraded"]).nullable().optional(),
|
|
||||||
latency: z.number().nullable().optional(), asn: z.string().optional(),
|
|
||||||
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length)
|
|
||||||
|
|
||||||
function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWorkspaceTab }: { selectedServerId: string; setSelectedServerId: (id: string) => void; workspaceTab: IpsecWorkspaceTab; setWorkspaceTab: (tab: IpsecWorkspaceTab) => void }) {
|
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const [summaries, setSummaries] = useState<IpsecServerSummaryDto[]>([])
|
const [summaries, setSummaries] = useState<IpsecServerSummaryDto[]>([])
|
||||||
const [clients, setClients] = useState<IpsecClientDto[]>([])
|
const [clients, setClients] = useState<IpsecClientDto[]>([])
|
||||||
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
const [failures, setFailures] = useState<IpsecFailure[]>([])
|
const [failures, setFailures] = useState<IpsecFailure[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [dataError, setDataError] = useState<string | null>(null)
|
|
||||||
const [inventoryError, setInventoryError] = useState<string | null>(null)
|
|
||||||
const [hasLoaded, setHasLoaded] = useState(false)
|
|
||||||
const [readReceipt, setReadReceipt] = useState<Record<string, string>>({})
|
|
||||||
const [responseReceipt, setResponseReceipt] = useState<string | null>(null)
|
|
||||||
const [writeReceipt, setWriteReceipt] = useState<{ serverId: string; at: string } | null>(null)
|
|
||||||
const mounted = useRef(true)
|
|
||||||
const operationLock = useRef(false)
|
|
||||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
|
||||||
const readEpoch = useRef(0)
|
|
||||||
useEffect(() => () => { readEpoch.current += 1 }, [])
|
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [certBusy, setCertBusy] = useState(false)
|
const [certBusy, setCertBusy] = useState(false)
|
||||||
const [certError, setCertError] = useState<string | null>(null)
|
|
||||||
const certEpoch = useRef(0)
|
|
||||||
const certPending = useRef(false)
|
|
||||||
const certFailedAttempts = useRef(new Set<string>())
|
|
||||||
const certActive = useRef<{ targetKey: string; controller: AbortController } | null>(null)
|
|
||||||
const [certAttemptFailed, setCertAttemptFailed] = useState(false)
|
|
||||||
const [certRole, setCertRole] = useState<IpsecCertInfoDto["role"]>("client")
|
|
||||||
const [certSession, setCertSession] = useState(0)
|
|
||||||
useEffect(() => () => { certEpoch.current += 1; certActive.current?.controller.abort() }, [])
|
|
||||||
|
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
|
const [workspaceTab, setWorkspaceTab] = useState<IpsecWorkspaceTab>("clients")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [initOpen, setInitOpen] = useState(false)
|
const [initOpen, setInitOpen] = useState(false)
|
||||||
const [initTargetId, setInitTargetId] = useState<string | undefined>()
|
|
||||||
const [userOpen, setUserOpen] = useState(false)
|
const [userOpen, setUserOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<IpsecClientDto | null>(null)
|
const [editing, setEditing] = useState<IpsecClientDto | null>(null)
|
||||||
const [certOpen, setCertOpen] = useState(false)
|
const [certOpen, setCertOpen] = useState(false)
|
||||||
@@ -223,130 +187,95 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
const [peerOpen, setPeerOpen] = useState(false)
|
const [peerOpen, setPeerOpen] = useState(false)
|
||||||
const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null)
|
const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null)
|
||||||
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
||||||
const [removeClientCertificate, setRemoveClientCertificate] = useState(true)
|
|
||||||
const deleteLock = useRef(false)
|
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
|
||||||
const [forceAllowed, setForceAllowed] = useState(false)
|
|
||||||
const [historyOpen, setHistoryOpen] = useState(false)
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
const [historyLoading, setHistoryLoading] = useState(false)
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
|
||||||
const historyEpoch = useRef(0)
|
|
||||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
|
|
||||||
const loadLive = useCallback(async (afterOperation = false) => {
|
const loadLive = useCallback(async () => {
|
||||||
if (!isLive || !mounted.current || (operationLock.current && !afterOperation)) return
|
if (!isLive) return
|
||||||
const epoch = ++readEpoch.current
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const [ipsecResult, inventoryResult] = await Promise.allSettled([
|
try {
|
||||||
listIpsec(backendUrl), requestJson<unknown>(backendUrl, "/api/servers"),
|
const [ipsec, servers] = await Promise.all([
|
||||||
])
|
listIpsec(backendUrl),
|
||||||
if (epoch !== readEpoch.current) return
|
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
||||||
if (inventoryResult.status === "fulfilled") {
|
])
|
||||||
const parsed = ipsecInventorySchema.safeParse(inventoryResult.value)
|
setSummaries(ipsec.servers)
|
||||||
if (parsed.success) {
|
setClients(ipsec.clients)
|
||||||
setLiveServers(parsed.data.filter(s => s.enabled).map(mapBackendServer))
|
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
|
||||||
setInventoryError(null)
|
|
||||||
} else setInventoryError("Некорректный список устройств")
|
|
||||||
} else setInventoryError(inventoryResult.reason instanceof Error ? inventoryResult.reason.message : "Не удалось загрузить устройства")
|
|
||||||
if (ipsecResult.status === "fulfilled") {
|
|
||||||
const ipsec = ipsecResult.value
|
|
||||||
const failedIds = new Set((ipsec.failures ?? []).map(f => f.serverId))
|
|
||||||
setSummaries(previous => [...ipsec.servers, ...previous.filter(s => failedIds.has(s.serverId))])
|
|
||||||
setClients(previous => [...ipsec.clients, ...previous.filter(c => failedIds.has(c.serverId))])
|
|
||||||
setFailures(ipsec.failures ?? [])
|
setFailures(ipsec.failures ?? [])
|
||||||
const at = new Date().toISOString()
|
if (ipsec.failures?.length) {
|
||||||
setResponseReceipt(at)
|
toast.warning(
|
||||||
const successfulIds = new Set([...ipsec.servers.map(s => s.serverId), ...ipsec.clients.map(c => c.serverId)])
|
`Не удалось опросить: ${ipsec.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
|
||||||
setReadReceipt(previous => ({ ...previous, ...Object.fromEntries([...successfulIds].map(id => [id, at])) }))
|
)
|
||||||
setHasLoaded(true)
|
}
|
||||||
setDataError(null)
|
} catch (e) {
|
||||||
} else {
|
toast.error(e instanceof Error ? e.message : "Ошибка загрузки IPsec")
|
||||||
setDataError(ipsecResult.reason instanceof Error ? ipsecResult.reason.message : "Ошибка загрузки IPsec")
|
setSummaries([])
|
||||||
|
setClients([])
|
||||||
|
setFailures([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
setLoading(false)
|
|
||||||
}, [isLive, backendUrl])
|
}, [isLive, backendUrl])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLive) queueMicrotask(() => { void loadLive() })
|
if (!isLive) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setSummaries([])
|
||||||
|
setClients([])
|
||||||
|
setLiveServers([])
|
||||||
|
setFailures([])
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadLive()
|
||||||
|
})
|
||||||
}, [isLive, loadLive])
|
}, [isLive, loadLive])
|
||||||
|
|
||||||
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||||
const displaySummaries = isLive ? summaries : MOCK_IPSEC_SERVERS
|
const displaySummaries = isLive ? summaries : MOCK_IPSEC_SERVERS
|
||||||
const displayClients = isLive ? clients : MOCK_IPSEC_CLIENTS
|
const displayClients = isLive ? clients : MOCK_IPSEC_CLIENTS
|
||||||
|
|
||||||
const effectiveServerId = selectedServerId
|
const effectiveServerId =
|
||||||
const missingSelectedServer = selectedServerId !== ALL_SERVERS_ID && hasLoaded && !displayServers.some(s => s.id === selectedServerId)
|
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||||||
|
? selectedServerId
|
||||||
|
: ALL_SERVERS_ID
|
||||||
|
|
||||||
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
|
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
|
||||||
|
|
||||||
useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive])
|
|
||||||
|
|
||||||
const loadRevisions = useCallback(async () => {
|
const loadRevisions = useCallback(async () => {
|
||||||
if (!isLive || !historyServerId || !mounted.current) return
|
if (!isLive || !historyServerId) return
|
||||||
const epoch = ++historyEpoch.current
|
|
||||||
setHistoryLoading(true)
|
setHistoryLoading(true)
|
||||||
setHistoryError(null)
|
|
||||||
try {
|
try {
|
||||||
const res = await requestJson<unknown>(
|
const res = await requestJson<{ revisions: ConfigRevisionDto[] }>(
|
||||||
backendUrl,
|
backendUrl,
|
||||||
`/api/ipsec/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
`/api/ipsec/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
||||||
)
|
)
|
||||||
if (epoch !== historyEpoch.current) return
|
setRevisions(res.revisions)
|
||||||
setRevisions(parseConfigRevisions(res, historyServerId, "ipsec"))
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (epoch !== historyEpoch.current) return
|
toast.error("Не удалось загрузить историю", { description: String(err) })
|
||||||
setHistoryError(err instanceof Error ? err.message : "Не удалось загрузить историю")
|
setRevisions([])
|
||||||
} finally {
|
} finally {
|
||||||
if (epoch === historyEpoch.current) setHistoryLoading(false)
|
setHistoryLoading(false)
|
||||||
}
|
}
|
||||||
}, [isLive, historyServerId, backendUrl])
|
}, [isLive, historyServerId, backendUrl])
|
||||||
|
|
||||||
const restoreRevision = async (id: string) => {
|
const restoreRevision = useCallback(async (id: string) => {
|
||||||
if (!can("mm:network:write")) throw new Error("Нет прав на изменение конфигурации")
|
|
||||||
if (!isLive || !historyServerId) return
|
if (!isLive || !historyServerId) return
|
||||||
beginOperation(historyServerId)
|
|
||||||
setHistoryRestoring(true)
|
setHistoryRestoring(true)
|
||||||
try {
|
try {
|
||||||
const res = await restoreIpsecRevision(backendUrl, id, historyServerId)
|
await restoreIpsecRevision(backendUrl, id, historyServerId)
|
||||||
if (!mounted.current) return
|
|
||||||
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
|
|
||||||
confirmOperation(historyServerId)
|
|
||||||
toast.success("Версия применена на роутер")
|
toast.success("Версия применена на роутер")
|
||||||
await loadLive(true)
|
await loadLive()
|
||||||
await loadRevisions()
|
await loadRevisions()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err
|
toast.error("Не удалось откатить", { description: String(err) })
|
||||||
} finally {
|
} finally {
|
||||||
endOperation()
|
setHistoryRestoring(false)
|
||||||
if (mounted.current) setHistoryRestoring(false)
|
|
||||||
}
|
}
|
||||||
}
|
}, [isLive, historyServerId, backendUrl, loadLive, loadRevisions])
|
||||||
|
|
||||||
const failedServerIds = new Set(failures.map(f => f.serverId))
|
|
||||||
const scopeUnknown = missingSelectedServer || isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId)))
|
|
||||||
|
|
||||||
const canManage = (serverId: string) => isLive && can("mm:network:write") && hasLoaded && !dataError && !inventoryError &&
|
|
||||||
!loading && !busy && !historyRestoring && !failedServerIds.has(serverId) && liveServers.some(s => s.id === serverId)
|
|
||||||
const assertDeviceReady = (serverId: string) => {
|
|
||||||
if (operationLock.current || !canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec")
|
|
||||||
}
|
|
||||||
const confirmOperation = (serverId: string) => setWriteReceipt({ serverId, at: new Date().toISOString() })
|
|
||||||
const formatReceipt = (at: string) => new Date(at).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
|
||||||
const scopeReceipt = effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId]
|
|
||||||
const scopeWriteReceipt = writeReceipt && (effectiveServerId === ALL_SERVERS_ID || writeReceipt.serverId === effectiveServerId) ? writeReceipt : null
|
|
||||||
const beginOperation = (serverId: string) => {
|
|
||||||
assertDeviceReady(serverId)
|
|
||||||
operationLock.current = true
|
|
||||||
readEpoch.current += 1
|
|
||||||
setLoading(false)
|
|
||||||
setBusy(true)
|
|
||||||
}
|
|
||||||
const endOperation = () => {
|
|
||||||
operationLock.current = false
|
|
||||||
if (mounted.current) setBusy(false)
|
|
||||||
}
|
|
||||||
const hasReadyDevice = !missingSelectedServer && displayServers.some(s => (effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId) && canManage(s.id))
|
|
||||||
|
|
||||||
const scopedClients = useMemo(() => {
|
const scopedClients = useMemo(() => {
|
||||||
if (effectiveServerId === ALL_SERVERS_ID) return displayClients
|
if (effectiveServerId === ALL_SERVERS_ID) return displayClients
|
||||||
@@ -372,10 +301,13 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
|
|
||||||
const serversWithIpsec = scopedSummaries.filter((s) => s.peers.length > 0).length
|
const serversWithIpsec = scopedSummaries.filter((s) => s.peers.length > 0).length
|
||||||
const clientsOnline = scopedClients.filter((c) => c.online).length
|
const clientsOnline = scopedClients.filter((c) => c.online).length
|
||||||
const clientsAmbiguous = scopedClients.filter(c => c.sessionAmbiguous).length
|
|
||||||
const caOk = scopedSummaries.some((s) => s.caCert)
|
const caOk = scopedSummaries.some((s) => s.caCert)
|
||||||
const compactServer = effectiveServerId !== ALL_SERVERS_ID
|
const compactServer = effectiveServerId !== ALL_SERVERS_ID
|
||||||
const sheetServerId = compactServer ? effectiveServerId : undefined
|
const sheetServerId = compactServer ? effectiveServerId : undefined
|
||||||
|
const certNames = useMemo(
|
||||||
|
() => Array.from(new Set(scopedSummaries.flatMap((s) => (s.certs ?? []).map((c) => c.name)))).sort(),
|
||||||
|
[scopedSummaries],
|
||||||
|
)
|
||||||
const peerOptions = useMemo(
|
const peerOptions = useMemo(
|
||||||
() => scopedSummaries.flatMap((s) => s.peers.map((p) => ({ serverId: s.serverId, name: p.name, managed: p.managed }))),
|
() => scopedSummaries.flatMap((s) => s.peers.map((p) => ({ serverId: s.serverId, name: p.name, managed: p.managed }))),
|
||||||
[scopedSummaries],
|
[scopedSummaries],
|
||||||
@@ -406,17 +338,16 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
host: s.host,
|
host: s.host,
|
||||||
site: s.site,
|
site: s.site,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
status: isLive && (s as InventoryServer).monitoringUnknown ? undefined : s.status,
|
status: s.status,
|
||||||
type: s.type,
|
type: s.type,
|
||||||
count: counts.get(s.id) ?? 0,
|
count: counts.get(s.id) ?? 0,
|
||||||
meta: isLive && (s as InventoryServer).monitoringUnknown ? `${counts.get(s.id) ?? 0} · Статус неизвестен` : undefined,
|
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
||||||
}))
|
}))
|
||||||
}, [displayServers, displayClients, isLive])
|
}, [displayServers, displayClients])
|
||||||
|
|
||||||
const handleInit = async (form: IpsecInitFormState) => {
|
const handleInit = async (form: IpsecInitFormState) => {
|
||||||
beginOperation(form.serverId)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await initIpsecServer(backendUrl, {
|
await initIpsecServer(backendUrl, {
|
||||||
serverId: form.serverId,
|
serverId: form.serverId,
|
||||||
@@ -428,18 +359,20 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
clientDaysValid: 1825,
|
clientDaysValid: 1825,
|
||||||
createNatRule: form.createNatRule,
|
createNatRule: form.createNatRule,
|
||||||
})
|
})
|
||||||
if (!mounted.current) return
|
|
||||||
confirmOperation(form.serverId)
|
|
||||||
toast.success("IKEv2-сервер инициализирован")
|
toast.success("IKEv2-сервер инициализирован")
|
||||||
setInitOpen(false)
|
setInitOpen(false)
|
||||||
await loadLive(true)
|
await loadLive()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка инициализации", {
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
endOperation()
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateUser = async (form: IpsecUserFormState) => {
|
const handleCreateUser = async (form: IpsecUserFormState) => {
|
||||||
beginOperation(form.serverId)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
const res = await createIpsecUser(backendUrl, {
|
const res = await createIpsecUser(backendUrl, {
|
||||||
serverId: form.serverId,
|
serverId: form.serverId,
|
||||||
@@ -451,153 +384,155 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : undefined,
|
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : undefined,
|
||||||
passphrase: form.passphrase.trim() || undefined,
|
passphrase: form.passphrase.trim() || undefined,
|
||||||
})
|
})
|
||||||
if (!mounted.current) return
|
|
||||||
confirmOperation(form.serverId)
|
|
||||||
toast.success(`Клиент «${form.name.trim()}» создан`)
|
toast.success(`Клиент «${form.name.trim()}» создан`)
|
||||||
setUserOpen(false)
|
setUserOpen(false)
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
await loadLive(true)
|
await loadLive()
|
||||||
if (!mounted.current) return
|
|
||||||
if (res.bundle) {
|
if (res.bundle) {
|
||||||
certEpoch.current += 1
|
|
||||||
certPending.current = false
|
|
||||||
setCertBusy(false)
|
|
||||||
setCertError(null)
|
|
||||||
setCertBundle(res.bundle)
|
setCertBundle(res.bundle)
|
||||||
setCertClient(res.client ?? null)
|
setCertClient(res.client ?? null)
|
||||||
setCertByName(null)
|
setCertByName(null)
|
||||||
setCertRole("client")
|
|
||||||
setCertAttemptFailed(false)
|
|
||||||
setCertSession(session => session + 1)
|
|
||||||
setCertOpen(true)
|
setCertOpen(true)
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка создания клиента", {
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
endOperation()
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditUser = async (form: IpsecUserFormState) => {
|
const handleEditUser = async (form: IpsecUserFormState) => {
|
||||||
if (!editing) return
|
if (!editing) return
|
||||||
beginOperation(editing.serverId)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await patchIpsecUser(backendUrl, editing.serverId, editing.rosId, {
|
await patchIpsecUser(backendUrl, editing.serverId, editing.rosId, {
|
||||||
name: form.name.trim() !== editing.name ? form.name.trim() : undefined,
|
name: form.name.trim() !== editing.name ? form.name.trim() : undefined,
|
||||||
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : null,
|
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : null,
|
||||||
psk: form.authMethod === "pre-shared-key" && form.psk.trim() ? form.psk : undefined,
|
psk: form.authMethod === "pre-shared-key" && form.psk.trim() ? form.psk : undefined,
|
||||||
})
|
})
|
||||||
if (!mounted.current) return
|
|
||||||
confirmOperation(editing.serverId)
|
|
||||||
toast.success("Клиент обновлён")
|
toast.success("Клиент обновлён")
|
||||||
setUserOpen(false)
|
setUserOpen(false)
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
await loadLive(true)
|
await loadLive()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка обновления", { description: e instanceof Error ? e.message : String(e) })
|
||||||
} finally {
|
} finally {
|
||||||
endOperation()
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadCert = async (serverId: string, targetKey: string, request: (signal: AbortSignal) => Promise<IpsecCertBundle>) => {
|
const openCert = async (client: IpsecClientDto) => {
|
||||||
if (certPending.current) return
|
const byName = client.kind === "cert" && Boolean(client.certName)
|
||||||
certPending.current = true
|
|
||||||
const controller = new AbortController()
|
|
||||||
certActive.current = { targetKey, controller }
|
|
||||||
const epoch = ++certEpoch.current
|
|
||||||
setCertBusy(true)
|
|
||||||
setCertError(null)
|
|
||||||
try {
|
|
||||||
assertDeviceReady(serverId)
|
|
||||||
const bundle = await request(controller.signal)
|
|
||||||
if (epoch === certEpoch.current) {
|
|
||||||
setCertBundle(bundle)
|
|
||||||
certFailedAttempts.current.delete(targetKey)
|
|
||||||
setCertAttemptFailed(false)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
certFailedAttempts.current.add(targetKey)
|
|
||||||
if (epoch === certEpoch.current) {
|
|
||||||
setCertAttemptFailed(true)
|
|
||||||
setCertError(error instanceof Error ? error.message : "Ошибка экспорта сертификата")
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (epoch === certEpoch.current) { certPending.current = false; certActive.current = null; setCertBusy(false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCert = (client: IpsecClientDto) => {
|
|
||||||
if (certPending.current) return
|
|
||||||
const byName = !client.rosId.startsWith("identity:") && client.kind === "cert" && Boolean(client.certName)
|
|
||||||
const targetKey = JSON.stringify([client.serverId, byName ? "certificate" : "client", byName ? client.certName : client.rosId])
|
|
||||||
certEpoch.current += 1
|
|
||||||
certPending.current = false
|
|
||||||
setCertClient(byName ? null : client)
|
setCertClient(byName ? null : client)
|
||||||
setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null)
|
setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null)
|
||||||
setCertBundle(null)
|
setCertBundle(null)
|
||||||
setCertError(null)
|
|
||||||
setCertAttemptFailed(certFailedAttempts.current.has(targetKey))
|
|
||||||
setCertRole(summaries.find(summary => summary.serverId === client.serverId)?.certs?.find(cert => cert.name === client.certName)?.role ?? "client")
|
|
||||||
setCertSession(session => session + 1)
|
|
||||||
setCertOpen(true)
|
setCertOpen(true)
|
||||||
|
setCertBusy(true)
|
||||||
|
try {
|
||||||
|
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
const bundle = byName
|
||||||
|
? await exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
|
||||||
|
: await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase)
|
||||||
|
setCertBundle(bundle)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка экспорта сертификата", {
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
})
|
||||||
|
setCertOpen(false)
|
||||||
|
} finally {
|
||||||
|
setCertBusy(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCertByName = (serverId: string, cert: IpsecCertInfoDto) => {
|
/** Экспорт .p12 существующего клиентского серта по имени (client1/anakondra и т.п.). */
|
||||||
if (certPending.current) return
|
const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => {
|
||||||
certEpoch.current += 1
|
|
||||||
certPending.current = false
|
|
||||||
setCertByName({ serverId, name: cert.name })
|
setCertByName({ serverId, name: cert.name })
|
||||||
setCertClient(null)
|
setCertClient(null)
|
||||||
setCertBundle(null)
|
setCertBundle(null)
|
||||||
setCertError(null)
|
|
||||||
setCertAttemptFailed(certFailedAttempts.current.has(JSON.stringify([serverId, "certificate", cert.name])))
|
|
||||||
setCertRole(cert.role ?? "other")
|
|
||||||
setCertSession(session => session + 1)
|
|
||||||
setCertOpen(true)
|
setCertOpen(true)
|
||||||
|
setCertBusy(true)
|
||||||
|
try {
|
||||||
|
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
const bundle = await exportIpsecCertByName(backendUrl, serverId, cert.name, passphrase)
|
||||||
|
setCertBundle(bundle)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка экспорта сертификата", {
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
})
|
||||||
|
setCertOpen(false)
|
||||||
|
} finally {
|
||||||
|
setCertBusy(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reexportCert = async (passphrase: string, options: IpsecExistingCertExportOptions) => {
|
const reexportCert = async (passphrase: string) => {
|
||||||
if (!certClient && !certByName) return
|
if (!certClient && !certByName) return
|
||||||
if (options.privateKeyExport !== true || (certRole === "ca" && !options.caPrivateKeyExport)) return
|
setCertBusy(true)
|
||||||
const serverId = certByName?.serverId ?? certClient!.serverId
|
try {
|
||||||
const targetKey = JSON.stringify([serverId, certByName ? "certificate" : "client", certByName?.name ?? certClient!.rosId])
|
const bundle = certByName
|
||||||
await loadCert(serverId, targetKey, signal => certByName
|
? await exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
|
||||||
? exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase, options, { signal })
|
: await exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase)
|
||||||
: exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase, options, { signal }))
|
setCertBundle(bundle)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) })
|
||||||
|
} finally {
|
||||||
|
setCertBusy(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletingCertificateIdentity = pendingDelete?.kind === "user" && pendingDelete.client.kind === "cert" && pendingDelete.client.rosId.startsWith("identity:")
|
const confirmDelete = async () => {
|
||||||
const deletingSharedCertificate = deletingCertificateIdentity && pendingDelete?.kind === "user" && Boolean(pendingDelete.client.certName) && displayClients.some(c => c.serverId === pendingDelete.client.serverId && c.certName === pendingDelete.client.certName && c.rosId !== pendingDelete.client.rosId)
|
if (!pendingDelete) return
|
||||||
const shouldRemoveClientCertificate = !deletingCertificateIdentity || (removeClientCertificate && !deletingSharedCertificate)
|
|
||||||
|
|
||||||
const confirmDelete = async (force = false) => {
|
|
||||||
if (!pendingDelete || deleteLock.current || (force && !forceAllowed)) return
|
|
||||||
const target = pendingDelete
|
const target = pendingDelete
|
||||||
const serverId = target.kind === "user" ? target.client.serverId : target.kind === "server" ? target.summary.serverId : target.serverId
|
setBusy(true)
|
||||||
if (operationLock.current || !canManage(serverId)) { setDeleteError("Повторите опрос IPsec перед удалением"); return }
|
|
||||||
beginOperation(serverId)
|
|
||||||
deleteLock.current = true
|
|
||||||
setDeleteError(null)
|
|
||||||
try {
|
try {
|
||||||
if (target.kind === "user") {
|
if (target.kind === "user") {
|
||||||
await deleteIpsecUser(backendUrl, target.client.serverId, target.client.rosId, { removeCertificate: shouldRemoveClientCertificate })
|
await deleteIpsecUser(backendUrl, target.client.serverId, target.client.rosId)
|
||||||
|
toast.success(`Клиент «${target.client.name}» удалён`)
|
||||||
} else if (target.kind === "server") {
|
} else if (target.kind === "server") {
|
||||||
await deleteIpsecServer(backendUrl, target.summary.serverId)
|
await deleteIpsecServer(backendUrl, target.summary.serverId)
|
||||||
|
toast.success(`IKEv2-сервер на ${target.summary.serverName} удалён`)
|
||||||
} else if (target.kind === "peer") {
|
} else if (target.kind === "peer") {
|
||||||
await deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId, { force })
|
await deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId)
|
||||||
|
toast.success(`Peer «${target.peer.name}» удалён`)
|
||||||
} else {
|
} else {
|
||||||
await deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force })
|
await deleteIpsecCert(backendUrl, target.serverId, target.cert.name)
|
||||||
|
toast.success(`Сертификат «${target.cert.name}» удалён`)
|
||||||
|
}
|
||||||
|
await loadLive()
|
||||||
|
} catch (e) {
|
||||||
|
const forceDelete = async (fn: () => Promise<unknown>) => {
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
toast.success("Удалено")
|
||||||
|
await loadLive()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Не удалось удалить", { description: err instanceof Error ? err.message : String(err) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e instanceof ApiClientError && e.status === 409 && target.kind === "peer") {
|
||||||
|
toast.error("На peer ссылаются identity", {
|
||||||
|
description: e.message,
|
||||||
|
action: {
|
||||||
|
label: "Удалить принудительно",
|
||||||
|
onClick: () => { void forceDelete(() => deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId, { force: true })) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (e instanceof ApiClientError && e.status === 409 && target.kind === "cert") {
|
||||||
|
toast.error("Сертификат используется", {
|
||||||
|
description: e.message,
|
||||||
|
action: {
|
||||||
|
label: "Удалить принудительно",
|
||||||
|
onClick: () => { void forceDelete(() => deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force: true })) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast.error("Ошибка удаления", { description: e instanceof Error ? e.message : String(e) })
|
||||||
}
|
}
|
||||||
if (!mounted.current) return
|
|
||||||
confirmOperation(serverId)
|
|
||||||
toast.success("Удалено")
|
|
||||||
setPendingDelete(null)
|
|
||||||
setForceAllowed(false)
|
|
||||||
await loadLive(true)
|
|
||||||
} catch (error) {
|
|
||||||
if (!mounted.current) return
|
|
||||||
setDeleteError(error instanceof Error ? error.message : "Не удалось удалить")
|
|
||||||
setForceAllowed(error instanceof ApiClientError && error.status === 409 && (target.kind === "peer" || target.kind === "cert"))
|
|
||||||
} finally {
|
} finally {
|
||||||
deleteLock.current = false
|
setBusy(false)
|
||||||
endOperation()
|
setPendingDelete(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,24 +543,25 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
|
|
||||||
const handleEditPeer = async (form: IpsecPeerFormState) => {
|
const handleEditPeer = async (form: IpsecPeerFormState) => {
|
||||||
if (!editingPeer) return
|
if (!editingPeer) return
|
||||||
beginOperation(editingPeer.serverId)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await patchIpsecPeer(backendUrl, editingPeer.serverId, editingPeer.peer.rosId, {
|
await patchIpsecPeer(backendUrl, editingPeer.serverId, editingPeer.peer.rosId, {
|
||||||
name: form.name.trim() !== editingPeer.peer.name ? form.name.trim() : undefined,
|
name: form.name.trim() !== editingPeer.peer.name ? form.name.trim() : undefined,
|
||||||
address: form.address.trim() || undefined,
|
address: form.address.trim() || undefined,
|
||||||
exchangeMode: form.exchangeMode.trim() || undefined,
|
exchangeMode: form.exchangeMode.trim() || undefined,
|
||||||
passive: form.passive,
|
passive: form.passive,
|
||||||
|
certificate: form.certificate.trim() || undefined,
|
||||||
profile: form.profile.trim() || undefined,
|
profile: form.profile.trim() || undefined,
|
||||||
disabled: form.disabled,
|
disabled: form.disabled,
|
||||||
})
|
})
|
||||||
if (!mounted.current) return
|
|
||||||
confirmOperation(editingPeer.serverId)
|
|
||||||
toast.success("Peer обновлён")
|
toast.success("Peer обновлён")
|
||||||
setPeerOpen(false)
|
setPeerOpen(false)
|
||||||
setEditingPeer(null)
|
setEditingPeer(null)
|
||||||
await loadLive(true)
|
await loadLive()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка обновления peer", { description: e instanceof Error ? e.message : String(e) })
|
||||||
} finally {
|
} finally {
|
||||||
endOperation()
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,9 +581,7 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
case "server":
|
case "server":
|
||||||
return "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)."
|
return "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)."
|
||||||
case "user":
|
case "user":
|
||||||
if (pendingDelete.client.kind === "psk") return "Удалит выбранную PSK identity и неиспользуемый персональный mode-config приложения. Сертификаты останутся на роутере."
|
return "Удалит identity, персональный mode-config и клиентский сертификат на роутере."
|
||||||
if (deletingCertificateIdentity && !shouldRemoveClientCertificate) return "Удалит выбранную identity и неиспользуемый персональный mode-config приложения. Сертификат останется на роутере; другие identity сохранятся."
|
|
||||||
return "Удалит identity, неиспользуемый персональный mode-config приложения и прямо привязанный клиентский сертификат на роутере."
|
|
||||||
case "peer":
|
case "peer":
|
||||||
return "Удалит peer на роутере. Если на него ссылаются identity — потребуется принудительное удаление."
|
return "Удалит peer на роутере. Если на него ссылаются identity — потребуется принудительное удаление."
|
||||||
case "cert":
|
case "cert":
|
||||||
@@ -659,13 +593,11 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
<>
|
<>
|
||||||
<ServerRailLayout
|
<ServerRailLayout
|
||||||
items={railItems}
|
items={railItems}
|
||||||
preserveSelection
|
|
||||||
selectedId={effectiveServerId}
|
selectedId={effectiveServerId}
|
||||||
onSelect={setSelectedServerId}
|
onSelect={setSelectedServerId}
|
||||||
showAll
|
showAll
|
||||||
allCount={displayClients.length}
|
allCount={displayClients.length}
|
||||||
loading={isLive && loading && displayServers.length === 0}
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
banner={isLive && !can("mm:network:write") ? <p role="status" className="border-b px-4 py-2 text-sm text-muted-foreground">Режим просмотра. Изменение конфигурации недоступно.</p> : undefined}
|
|
||||||
header={
|
header={
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Управление" }, { label: "IPsec / IKEv2" }]}
|
crumbs={[{ label: "Управление" }, { label: "IPsec / IKEv2" }]}
|
||||||
@@ -676,7 +608,7 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={loading || busy || historyRestoring}
|
disabled={loading}
|
||||||
onClick={() => void loadLive()}
|
onClick={() => void loadLive()}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
@@ -687,7 +619,7 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={loading || busy || historyRestoring || !historyServerId}
|
disabled={loading || !historyServerId}
|
||||||
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат"}
|
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHistoryOpen(true)
|
setHistoryOpen(true)
|
||||||
@@ -699,7 +631,7 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isLive ? (
|
{isLive ? (
|
||||||
<Button size="sm" disabled={!hasReadyDevice} onClick={() => setUserOpen(true)}>
|
<Button size="sm" onClick={() => setUserOpen(true)}>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
Новый клиент
|
Новый клиент
|
||||||
</Button>
|
</Button>
|
||||||
@@ -710,73 +642,46 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{missingSelectedServer && <Alert variant="destructive" role="alert"><AlertTitle>Устройство из ссылки не найдено</AlertTitle><AlertDescription>Выберите доступный сервер. Данные других устройств не подставляются в этот срез.</AlertDescription></Alert>}
|
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка IPsec"
|
aria-label="Сводка IPsec"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
id: "servers",
|
id: "servers",
|
||||||
label: "Серверов IKEv2",
|
label: "Серверов IKEv2",
|
||||||
value: scopeUnknown ? "—" : serversWithIpsec,
|
value: serversWithIpsec,
|
||||||
icon: <ShieldCheckIcon className="size-4" />,
|
icon: <ShieldCheckIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "clients",
|
id: "clients",
|
||||||
label: "Клиентов",
|
label: "Клиентов",
|
||||||
value: scopeUnknown ? "—" : scopedClients.length,
|
value: scopedClients.length,
|
||||||
icon: <UsersIcon className="size-4" />,
|
icon: <UsersIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "online",
|
id: "online",
|
||||||
label: "Онлайн",
|
label: "Онлайн",
|
||||||
value: scopeUnknown || clientsAmbiguous > 0 ? "—" : clientsOnline,
|
value: clientsOnline,
|
||||||
icon: <ActivityIcon className="size-4" />,
|
icon: <ActivityIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ca",
|
id: "ca",
|
||||||
label: "CA",
|
label: "CA",
|
||||||
value: scopeUnknown ? "неизвестно" : caOk ? "ок" : "нет",
|
value: caOk ? "ок" : "—",
|
||||||
icon: <KeyRoundIcon className="size-4" />,
|
icon: <KeyRoundIcon className="size-4" />,
|
||||||
iconClassName: "text-primary",
|
iconClassName: "text-primary",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLive && <p className="text-xs text-muted-foreground">Онлайн означает установленную IKE-сессию. Передачу данных проверяйте Ping.</p>}
|
|
||||||
|
|
||||||
{isLive && <div role="group" aria-label="Свежесть IPsec" className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<span data-source-time="read">{effectiveServerId === ALL_SERVERS_ID ? "Последний ответ IPsec получен в приложении: " : "Устройство опрошено, ответ получен в приложении: "}
|
|
||||||
{scopeReceipt ? <time dateTime={scopeReceipt} title="Клиентское время получения ответа; не время измерения RouterOS">{formatReceipt(scopeReceipt)}</time> : "неизвестно"}
|
|
||||||
</span>
|
|
||||||
{scopeWriteReceipt && <span data-source-time="write">Изменение подтверждено API: <time dateTime={scopeWriteReceipt.at} title={`Устройство ${scopeWriteReceipt.serverId}; подтверждение API не заменяет опрос`}>{formatReceipt(scopeWriteReceipt.at)}</time></span>}
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
{clientsAmbiguous > 0 && <Alert>
|
|
||||||
<AlertCircleIcon />
|
|
||||||
<AlertTitle>Статус части клиентов не определён</AlertTitle>
|
|
||||||
<AlertDescription>У {clientsAmbiguous} клиентов совпадает идентификатор сессии. RouterOS не сообщает, какому из них принадлежит подключение. Проверьте Remote ID.</AlertDescription>
|
|
||||||
</Alert>}
|
|
||||||
|
|
||||||
{isLive && (dataError || inventoryError) ? (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircleIcon />
|
|
||||||
<AlertTitle>{dataError ? "Не удалось загрузить IPsec" : "Не удалось загрузить устройства"}</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{dataError || inventoryError}. {hasLoaded ? "Показаны последние полученные данные; их актуальность не подтверждена." : "Данные пока неизвестны."}
|
|
||||||
<Button size="sm" variant="outline" disabled={loading || busy || historyRestoring} onClick={() => void loadLive()}>Повторить загрузку IPsec</Button>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{failures.length > 0 ? (
|
{failures.length > 0 ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircleIcon />
|
<AlertCircleIcon />
|
||||||
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
|
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; ")}. Показаны последние данные недоступных устройств; актуальность не подтверждена.
|
{failures.map((f) => f.serverName ?? f.serverId).join(", ")}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -819,37 +724,33 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
searchPlaceholder="Поиск по имени / CN / IP…"
|
searchPlaceholder="Поиск по имени / CN / IP…"
|
||||||
actions={
|
actions={
|
||||||
isLive ? (
|
isLive ? (
|
||||||
<Button size="sm" disabled={!hasReadyDevice} onClick={() => { setEditing(null); setUserOpen(true) }}>
|
<Button size="sm" onClick={() => { setEditing(null); setUserOpen(true) }}>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
Клиент
|
Клиент
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">{loading ? "Загрузка клиентов IPsec…" : "Клиенты IPsec пока неизвестны"}</p> : <IpsecUsersGrid
|
<IpsecUsersGrid
|
||||||
clients={filteredClients}
|
clients={filteredClients}
|
||||||
canManage={canManage}
|
|
||||||
emptyTitle={scopeUnknown ? "Клиенты IPsec пока неизвестны" : undefined}
|
|
||||||
emptyDescription={scopeUnknown ? "Повторите опрос устройств, чтобы подтвердить состояние" : undefined}
|
|
||||||
compactServer={compactServer}
|
compactServer={compactServer}
|
||||||
onDownloadCert={isLive ? (row) => void openCert(row) : undefined}
|
onDownloadCert={isLive ? (row) => void openCert(row) : undefined}
|
||||||
onEdit={isLive ? (row) => { setEditing(row); setUserOpen(true) } : undefined}
|
onEdit={isLive ? (row) => { setEditing(row); setUserOpen(true) } : undefined}
|
||||||
onDelete={isLive ? (row) => { setRemoveClientCertificate(true); setPendingDelete({ kind: "user", client: row }) } : undefined}
|
onDelete={isLive ? (row) => setPendingDelete({ kind: "user", client: row }) : undefined}
|
||||||
/>}
|
/>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="server" className="mt-0 outline-none">
|
<TabsContent value="server" className="mt-0 outline-none">
|
||||||
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">Состояние серверов IPsec пока неизвестно</p> : <IpsecServerGrid
|
<IpsecServerGrid
|
||||||
servers={scopedSummaries}
|
servers={scopedSummaries}
|
||||||
canManage={canManage}
|
onInit={isLive ? (s) => { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined}
|
||||||
onInit={isLive ? (s) => { setInitTargetId(s.serverId); setSelectedServerId(s.serverId); setInitOpen(true) } : undefined}
|
|
||||||
onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined}
|
onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined}
|
||||||
onEditPeer={isLive ? openEditPeer : undefined}
|
onEditPeer={isLive ? openEditPeer : undefined}
|
||||||
onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined}
|
onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined}
|
||||||
onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined}
|
onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined}
|
||||||
onExportCert={isLive ? (serverId, cert) => void openCertByName(serverId, cert) : undefined}
|
onExportCert={isLive ? (serverId, cert) => void openCertByName(serverId, cert) : undefined}
|
||||||
/>}
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="cli" className="mt-0 outline-none">
|
<TabsContent value="cli" className="mt-0 outline-none">
|
||||||
@@ -875,10 +776,12 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
lines: [
|
lines: [
|
||||||
"/ip ipsec peer add \\",
|
"/ip ipsec peer add \\",
|
||||||
" name=ipsec-vpn address=0.0.0.0/0 \\",
|
" name=ipsec-vpn address=0.0.0.0/0 \\",
|
||||||
" exchange-mode=ike2 passive=yes profile=ipsec-vpn",
|
" exchange-mode=ike2 passive=yes \\",
|
||||||
|
" certificate=ipsec-server send-cert=always",
|
||||||
"/ip ipsec identity add \\",
|
"/ip ipsec identity add \\",
|
||||||
" peer=ipsec-vpn auth-method=digital-signature \\",
|
" peer=ipsec-vpn auth-method=rsa-key \\",
|
||||||
" certificate=ipsec-server \\",
|
" certificate=ipsec-server \\",
|
||||||
|
" match-by=certificate \\",
|
||||||
" generate-policy=port-strict \\",
|
" generate-policy=port-strict \\",
|
||||||
" mode-config=ipsec-vpn",
|
" mode-config=ipsec-vpn",
|
||||||
],
|
],
|
||||||
@@ -920,7 +823,7 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
<IpsecUserSheet
|
<IpsecUserSheet
|
||||||
open={userOpen}
|
open={userOpen}
|
||||||
onOpenChange={(v) => { setUserOpen(v); if (!v) setEditing(null) }}
|
onOpenChange={(v) => { setUserOpen(v); if (!v) setEditing(null) }}
|
||||||
servers={serverOptions.filter(s => canManage(s.id) || busy)}
|
servers={serverOptions}
|
||||||
peers={peerOptions}
|
peers={peerOptions}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
defaultServerId={sheetServerId}
|
defaultServerId={sheetServerId}
|
||||||
@@ -934,42 +837,28 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
onOpenChange={(v) => { setPeerOpen(v); if (!v) setEditingPeer(null) }}
|
onOpenChange={(v) => { setPeerOpen(v); if (!v) setEditingPeer(null) }}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
editing={editingPeer?.peer ?? null}
|
editing={editingPeer?.peer ?? null}
|
||||||
|
certificates={certNames}
|
||||||
onSubmit={handleEditPeer}
|
onSubmit={handleEditPeer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<IpsecInitSheet
|
<IpsecInitSheet
|
||||||
open={initOpen}
|
open={initOpen}
|
||||||
onOpenChange={setInitOpen}
|
onOpenChange={setInitOpen}
|
||||||
servers={serverOptions.filter(s => canManage(s.id) || busy)}
|
servers={serverOptions}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
defaultServerId={initTargetId ?? sheetServerId}
|
defaultServerId={sheetServerId}
|
||||||
onSubmit={handleInit}
|
onSubmit={handleInit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<IpsecCertSheet
|
<IpsecCertSheet
|
||||||
key={certSession}
|
|
||||||
open={certOpen}
|
open={certOpen}
|
||||||
onOpenChange={(v) => {
|
onOpenChange={(v) => { setCertOpen(v); if (!v) setCertByName(null) }}
|
||||||
if (!v) {
|
|
||||||
if (certActive.current) {
|
|
||||||
certFailedAttempts.current.add(certActive.current.targetKey)
|
|
||||||
certActive.current.controller.abort()
|
|
||||||
certActive.current = null
|
|
||||||
}
|
|
||||||
certEpoch.current += 1; certPending.current = false; setCertBusy(false); setCertBundle(null); setCertClient(null); setCertByName(null)
|
|
||||||
}
|
|
||||||
setCertOpen(v)
|
|
||||||
}}
|
|
||||||
error={certError}
|
|
||||||
bundle={certBundle}
|
bundle={certBundle}
|
||||||
busy={certBusy}
|
busy={certBusy}
|
||||||
certificateRole={certRole}
|
|
||||||
targetName={certByName?.name ?? certClient?.certName ?? certClient?.name}
|
|
||||||
previousAttemptFailed={certAttemptFailed}
|
|
||||||
onReexport={reexportCert}
|
onReexport={reexportCert}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AlertDialog open={pendingDelete != null} onOpenChange={(v) => { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null); setForceAllowed(false) } }}>
|
<AlertDialog open={pendingDelete != null} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
|
||||||
<AlertDialogContent size="default">
|
<AlertDialogContent size="default">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||||
@@ -977,21 +866,9 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
</AlertDialogMedia>
|
</AlertDialogMedia>
|
||||||
<AlertDialogTitle>{deleteDialogTitle}</AlertDialogTitle>
|
<AlertDialogTitle>{deleteDialogTitle}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>{deleteDialogDescription}</AlertDialogDescription>
|
<AlertDialogDescription>{deleteDialogDescription}</AlertDialogDescription>
|
||||||
{deletingCertificateIdentity && (
|
|
||||||
<div className="space-y-2 text-left text-sm">
|
|
||||||
<label htmlFor="ipsec-delete-certificate" className="flex items-center gap-2">
|
|
||||||
<Checkbox id="ipsec-delete-certificate" checked={shouldRemoveClientCertificate} disabled={busy || deletingSharedCertificate} onCheckedChange={setRemoveClientCertificate} />
|
|
||||||
Удалить также клиентский сертификат
|
|
||||||
</label>
|
|
||||||
{deletingSharedCertificate && <p className="text-muted-foreground">Сертификат используется другими identity и будет сохранён.</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание результата удаления…</p>}
|
|
||||||
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={busy}>Отмена</AlertDialogCancel>
|
<AlertDialogCancel disabled={busy}>Отмена</AlertDialogCancel>
|
||||||
{forceAllowed && <Button variant="destructive" disabled={busy} onClick={() => void confirmDelete(true)}>Удалить принудительно</Button>}
|
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -1006,36 +883,14 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
|
|||||||
|
|
||||||
<ConfigHistorySheet
|
<ConfigHistorySheet
|
||||||
open={historyOpen}
|
open={historyOpen}
|
||||||
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
|
onOpenChange={setHistoryOpen}
|
||||||
loadError={historyError}
|
|
||||||
onRetry={() => void loadRevisions()}
|
|
||||||
revisions={revisions}
|
revisions={revisions}
|
||||||
loading={historyLoading}
|
loading={historyLoading}
|
||||||
restoring={historyRestoring}
|
restoring={historyRestoring}
|
||||||
restoreDisabled={!can("mm:network:write") || !historyServerId || !canManage(historyServerId)}
|
onRestore={(id) => void restoreRevision(id)}
|
||||||
onRestore={restoreRevision}
|
|
||||||
title="История конфигураций IPsec"
|
title="История конфигураций IPsec"
|
||||||
itemLabel="конфигурация"
|
itemLabel="конфигурация"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function IpsecRoute() {
|
|
||||||
const { mode, backendUrl } = useDataSource()
|
|
||||||
const params = useSearchParams()
|
|
||||||
const selectedServerId = params.get("server") ?? ALL_SERVERS_ID
|
|
||||||
const rawTab = params.get("tab")
|
|
||||||
const workspaceTab: IpsecWorkspaceTab = rawTab === "server" || rawTab === "cli" ? rawTab : "clients"
|
|
||||||
const navigate = (key: string, value: string, defaultValue: string) => {
|
|
||||||
const next = new URLSearchParams(params.toString())
|
|
||||||
if (value === defaultValue) next.delete(key)
|
|
||||||
else next.set(key, value)
|
|
||||||
window.history.pushState(null, "", "/ipsec" + (next.size ? "?" + next.toString() : ""))
|
|
||||||
}
|
|
||||||
return <IpsecPage key={JSON.stringify([mode, backendUrl])} selectedServerId={selectedServerId} setSelectedServerId={id => navigate("server", id, ALL_SERVERS_ID)} workspaceTab={workspaceTab} setWorkspaceTab={tab => navigate("tab", tab, "clients")} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function IpsecSource() {
|
|
||||||
return <Suspense fallback={<div role="status">Загрузка IPsec…</div>}><IpsecRoute /></Suspense>
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ export default function MainLayout({ children }: { children: ReactNode }) {
|
|||||||
К содержимому
|
К содержимому
|
||||||
</a>
|
</a>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset tabIndex={-1} id="main-content" className="h-svh overflow-hidden">
|
<SidebarInset id="main-content" className="h-svh overflow-hidden">
|
||||||
{children}
|
{children}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
import { LogsWorkspace } from "@/components/logs/logs-workspace"
|
|
||||||
export default function LogsPage() { return <LogsWorkspace /> }
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { Suspense } from "react"
|
|
||||||
import { McpWorkspace } from "@/components/mcp/mcp-workspace"
|
|
||||||
|
|
||||||
export default function McpPage() {
|
|
||||||
return <Suspense fallback={<p role="status" className="p-6">Загрузка MCP…</p>}><McpWorkspace /></Suspense>
|
|
||||||
}
|
|
||||||
+565
-542
File diff suppressed because it is too large.
Load diff
+391
-397
File diff suppressed because it is too large.
Load diff
+329
-271
@@ -1,10 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { toast } from "sonner"
|
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
||||||
import { Suspense, useEffect, useRef, useState, useMemo, useCallback } from "react"
|
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { readProbeSource } from "@/lib/probe-source"
|
|
||||||
import { readProbeUrl, patchProbeUrl } from "@/lib/probe-url"
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
@@ -15,21 +11,21 @@ import {
|
|||||||
type SchedRule,
|
type SchedRule,
|
||||||
type SchedType,
|
type SchedType,
|
||||||
} from "@/components/data-grids/probes-schedule-data-grid"
|
} from "@/components/data-grids/probes-schedule-data-grid"
|
||||||
import {ProbesSpeedSchedule} from "@/components/probes-speed-schedule"
|
import {
|
||||||
|
ProbesSpeedProbesDataGrid,
|
||||||
|
type SpeedProbeApiRow,
|
||||||
|
} from "@/components/data-grids/probes-speed-probes-data-grid"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
||||||
import { filtersGreResponseSchema } from "@/lib/filters-response"
|
|
||||||
import { can } from "@/lib/auth"
|
|
||||||
import { readProbeOutput, probeServersSchema } from "@/lib/probe-response"
|
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import {
|
import {
|
||||||
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
|
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
|
||||||
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
|
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
|
||||||
ZapIcon, CheckIcon, TerminalIcon,
|
ZapIcon, ClockIcon, CheckIcon, TerminalIcon,
|
||||||
LoaderCircleIcon,
|
LoaderCircleIcon, AlertCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
@@ -37,7 +33,7 @@ import { requestJson } from "@/shared/api/http-client"
|
|||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu"
|
type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu"
|
||||||
type RunStatus = "running" | "done" | "error" | "cancelled"
|
type RunStatus = "running" | "done" | "error"
|
||||||
type TraceProto = "icmp" | "udp" | "tcp"
|
type TraceProto = "icmp" | "udp" | "tcp"
|
||||||
type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR"
|
type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR"
|
||||||
|
|
||||||
@@ -70,12 +66,12 @@ const TOOL_META: Record<DiagTool, {
|
|||||||
color: string
|
color: string
|
||||||
description: string
|
description: string
|
||||||
}> = {
|
}> = {
|
||||||
ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-info", description: "Проверка связи и RTT" },
|
ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-sky-500", description: "Проверка связи и RTT" },
|
||||||
traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-info", description: "Трассировка маршрута" },
|
traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-violet-500", description: "Трассировка маршрута" },
|
||||||
bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-success", description: "Пропускная способность" },
|
bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-emerald-500", description: "Пропускная способность" },
|
||||||
dns: { label: "DNS", ros: "DNS приложения", Icon: SearchIcon, color: "text-warning", description: "DNS на хосте приложения" },
|
dns: { label: "DNS", ros: "/resolve", Icon: SearchIcon, color: "text-amber-500", description: "Разрешение DNS-имён" },
|
||||||
route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-info", description: "Поиск активного маршрута" },
|
route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-blue-500", description: "Поиск активного маршрута" },
|
||||||
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-warning", description: "Определение MTU пути" },
|
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-orange-500", description: "Определение MTU пути" },
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeApiFetch(backendUrl: string) {
|
function makeApiFetch(backendUrl: string) {
|
||||||
@@ -84,25 +80,31 @@ function makeApiFetch(backendUrl: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyProbeText(text:string){
|
|
||||||
try{await navigator.clipboard.writeText(text);toast.success("Скопировано.")}
|
|
||||||
catch{toast.error("Не удалось скопировать. Разрешите доступ к буферу обмена или выделите текст вручную.")}
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferProbeLineKind(line: string): OutputLine["kind"] {
|
function inferProbeLineKind(line: string): OutputLine["kind"] {
|
||||||
const l = line.toLowerCase()
|
const l = line.toLowerCase()
|
||||||
if (l.includes("error:") || (l.includes("timeout") && l.includes("seq="))) return "err"
|
if (l.includes("error:") || (l.includes("timeout") && l.includes("seq="))) return "err"
|
||||||
if (l.includes("✓")) return "ok"
|
if (l.includes("sent=") || l.includes("received=") || l.includes("packet-loss") || l.includes("✓")) return "ok"
|
||||||
if (l.startsWith(";;") || l.trim() === "") return "dim"
|
if (l.startsWith(";;") || l.trim() === "") return "dim"
|
||||||
if (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header"
|
if (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header"
|
||||||
return "normal"
|
return "normal"
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseProbeOutput(text: string): OutputLine[] {
|
function parseProbeOutput(text: string): OutputLine[] {
|
||||||
const lines = text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) }))
|
return text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) }))
|
||||||
return lines.length > 1000 ? [{ text: "Показаны последние 1000 строк; начало вывода сокращено.", kind: "dim" }, ...lines.slice(-1000)] : lines
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BackendServerRow {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
site: string
|
||||||
|
country: string
|
||||||
|
asn: string
|
||||||
|
type: Server["type"]
|
||||||
|
enabled: boolean
|
||||||
|
status?: Server["status"]
|
||||||
|
latency?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
// SpeedProbeApiRow imported from probes-speed-probes-data-grid
|
// SpeedProbeApiRow imported from probes-speed-probes-data-grid
|
||||||
|
|
||||||
@@ -319,7 +321,7 @@ function buildCommand(
|
|||||||
case "bandwidth":
|
case "bandwidth":
|
||||||
return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both`
|
return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both`
|
||||||
case "dns":
|
case "dns":
|
||||||
return "DNS приложения: " + target + " · " + (opts.dnsType ?? "A") + " · резолвер хоста backend"
|
return `/resolve ${target} type=${opts.dnsType ?? "A"} server=1.1.1.1`
|
||||||
case "route":
|
case "route":
|
||||||
return `/ip route lookup ip=${target}`
|
return `/ip route lookup ip=${target}`
|
||||||
case "mtu":
|
case "mtu":
|
||||||
@@ -329,11 +331,11 @@ function buildCommand(
|
|||||||
|
|
||||||
// ─── small UI components ──────────────────────────────────────────────────────
|
// ─── small UI components ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function NativeSelect({ value, onChange, children, className, ariaLabel }: {
|
function NativeSelect({ value, onChange, children, className }: {
|
||||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string; ariaLabel?: string
|
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<select aria-label={ariaLabel} value={value} onChange={e => onChange(e.target.value)}
|
<select value={value} onChange={e => onChange(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-sm text-foreground",
|
"h-8 min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-sm text-foreground",
|
||||||
"transition-colors outline-none",
|
"transition-colors outline-none",
|
||||||
@@ -345,8 +347,7 @@ function NativeSelect({ value, onChange, children, className, ariaLabel }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function OptionLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
|
function OptionLabel({ children }: { children: React.ReactNode }) {
|
||||||
if (htmlFor) return <label htmlFor={htmlFor} className="block text-xs font-medium text-muted-foreground mb-1">{children}</label>
|
|
||||||
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,9 +355,9 @@ function SegBtn<T extends string | number>({ value, current, onClick, children }
|
|||||||
value: T; current: T; onClick: (v: T) => void; children: React.ReactNode
|
value: T; current: T; onClick: (v: T) => void; children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button type="button" aria-pressed={value === current} onClick={() => onClick(value)}
|
<button onClick={() => onClick(value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
"px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors",
|
||||||
value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50",
|
value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50",
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
@@ -367,18 +368,107 @@ function SegBtn<T extends string | number>({ value, current, onClick, children }
|
|||||||
// ─── terminal output ──────────────────────────────────────────────────────────
|
// ─── terminal output ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TerminalOutput({ test }: { test: DiagTest }) {
|
function TerminalOutput({ test }: { test: DiagTest }) {
|
||||||
const ref=useRef<HTMLDivElement>(null),follow=useRef(true)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const visible=test.lines.slice(0,test.totalLines)
|
const visible = test.lines.slice(0, test.totalLines)
|
||||||
useEffect(()=>{if(ref.current&&follow.current)ref.current.scrollTop=ref.current.scrollHeight},[visible.length])
|
|
||||||
return <div ref={ref} role="region" aria-label={"Вывод "+TOOL_META[test.tool].label+" · "+test.srcServerName} tabIndex={0}
|
useEffect(() => {
|
||||||
onScroll={()=>{const el=ref.current;if(el)follow.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}}
|
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight
|
||||||
className="h-full min-h-0 overflow-auto rounded-lg bg-muted/30 border px-3 py-3 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
}, [visible.length])
|
||||||
<div className="mb-2 text-muted-foreground [overflow-wrap:anywhere]">{test.source==="demo"?"Демо":test.tool==="dns"?"DNS приложения":test.srcServerName} · {test.command}</div>
|
|
||||||
{visible.map((line,i)=><div key={i} className={cn("whitespace-pre-wrap [overflow-wrap:anywhere] leading-5",line.kind==="ok"&&"text-success",line.kind==="err"&&"text-destructive",line.kind==="dim"&&"text-muted-foreground",line.kind==="header"&&"text-foreground font-semibold",line.kind==="cmd"&&"text-warning",line.kind==="normal"&&"text-foreground")}>{line.text||" "}</div>)}
|
return (
|
||||||
</div>
|
<div ref={ref}
|
||||||
|
className="h-72 overflow-y-auto rounded-lg bg-zinc-950 dark:bg-zinc-900 border border-zinc-800 px-4 py-3 font-mono text-xs leading-relaxed">
|
||||||
|
{/* command line */}
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="text-zinc-500">[{test.srcServerName}]</span>
|
||||||
|
<span className="text-emerald-400">$</span>
|
||||||
|
<span className="text-zinc-300">{test.command}</span>
|
||||||
|
{test.status === "running" && (
|
||||||
|
<span className="inline-block size-1.5 rounded-full bg-emerald-400 animate-pulse ml-1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* output */}
|
||||||
|
{visible.map((line, i) => (
|
||||||
|
<div key={i} className={cn(
|
||||||
|
"whitespace-pre leading-5",
|
||||||
|
line.kind === "ok" && "text-emerald-400",
|
||||||
|
line.kind === "err" && "text-red-400",
|
||||||
|
line.kind === "dim" && "text-zinc-500",
|
||||||
|
line.kind === "header" && "text-zinc-400 font-semibold",
|
||||||
|
line.kind === "cmd" && "text-amber-400",
|
||||||
|
line.kind === "normal" && "text-zinc-300",
|
||||||
|
)}>
|
||||||
|
{line.text || " "}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{test.status === "running" && visible.length < test.lines.length && (
|
||||||
|
<div className="text-zinc-600 animate-pulse">…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── uptime speed probes (live) ───────────────────────────────────────────────
|
// ─── uptime speed probes (live) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ScheduleSpeedProbesLive({
|
||||||
|
apiFetch,
|
||||||
|
serversForName,
|
||||||
|
}: {
|
||||||
|
apiFetch: ReturnType<typeof makeApiFetch>
|
||||||
|
serversForName: Server[]
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState<SpeedProbeApiRow[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setErr(null)
|
||||||
|
void apiFetch<{ probes: SpeedProbeApiRow[] }>("/api/uptime/speed-probes")
|
||||||
|
.then(d => {
|
||||||
|
if (!cancelled) setRows(d.probes ?? [])
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (!cancelled) setErr(e instanceof Error ? e.message : String(e))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [apiFetch])
|
||||||
|
|
||||||
|
const name = (id: string) => serversForName.find(s => s.id === id)?.name ?? id
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
|
||||||
|
<LoaderCircleIcon className="size-5 animate-spin" />
|
||||||
|
Загрузка расписания из БД…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (err) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
|
<AlertCircleIcon className="size-4 shrink-0" />
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<DataPageCard>
|
||||||
|
<ProbesSpeedProbesDataGrid rows={rows} serverName={name} />
|
||||||
|
</DataPageCard>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── schedule tab ─────────────────────────────────────────────────────────────
|
// ─── schedule tab ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const INIT_RULES: SchedRule[] = [
|
const INIT_RULES: SchedRule[] = [
|
||||||
@@ -402,12 +492,21 @@ function ScheduleTab({
|
|||||||
}) {
|
}) {
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
|
const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
|
||||||
const [selectedTunnel, setAddTun] = useState("")
|
const [addTun, setAddTun] = useState("")
|
||||||
const [addType, setAddType] = useState<SchedType>("ping")
|
const [addType, setAddType] = useState<SchedType>("ping")
|
||||||
const [addMin, setAddMin] = useState(10)
|
const [addMin, setAddMin] = useState(10)
|
||||||
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
||||||
|
|
||||||
const addTun = addTunnels.some(t=>t.id===selectedTunnel) ? selectedTunnel : addTunnels[0]?.id ?? ""
|
useEffect(() => {
|
||||||
|
if (defaultSrc) setAddSrc(defaultSrc)
|
||||||
|
}, [defaultSrc])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const list = tunnelsForServer(addSrc)
|
||||||
|
if (list.length && !list.some(t => t.id === addTun)) {
|
||||||
|
setAddTun(list[0]?.id ?? "")
|
||||||
|
}
|
||||||
|
}, [addSrc, addTun, tunnelsForServer])
|
||||||
|
|
||||||
const tunnelName = (srcId: string, tunnelId: string) =>
|
const tunnelName = (srcId: string, tunnelId: string) =>
|
||||||
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
|
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
|
||||||
@@ -429,21 +528,21 @@ function ScheduleTab({
|
|||||||
<Frame dense className="w-full overflow-hidden">
|
<Frame dense className="w-full overflow-hidden">
|
||||||
<FramePanel className="p-0 overflow-hidden">
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
|
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
|
||||||
<PlusIcon aria-hidden="true" className="size-4 text-muted-foreground" />Новое правило
|
<PlusIcon className="size-4 text-muted-foreground" />Новое правило
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-4 flex flex-wrap items-end gap-3">
|
<div className="px-4 py-4 flex flex-wrap items-end gap-3">
|
||||||
<div><OptionLabel>Сервер</OptionLabel>
|
<div><OptionLabel>Сервер</OptionLabel>
|
||||||
<NativeSelect ariaLabel="Устройство расписания" value={addSrc} onChange={value=>{setAddSrc(value);setAddTun("")}} className="min-w-[160px]">
|
<NativeSelect value={addSrc} onChange={setAddSrc} className="min-w-[160px]">
|
||||||
{serverOptions.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
{serverOptions.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</div>
|
</div>
|
||||||
<div><OptionLabel>GRE-туннель</OptionLabel>
|
<div><OptionLabel>GRE-туннель</OptionLabel>
|
||||||
<NativeSelect ariaLabel="Туннель расписания" value={addTun} onChange={setAddTun} className="min-w-[150px]">
|
<NativeSelect value={addTun} onChange={setAddTun} className="min-w-[150px]">
|
||||||
{addTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
{addTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</div>
|
</div>
|
||||||
<div><OptionLabel>Тип</OptionLabel>
|
<div><OptionLabel>Тип</OptionLabel>
|
||||||
<NativeSelect ariaLabel="Тип теста расписания" value={addType} onChange={v => setAddType(v as SchedType)}>
|
<NativeSelect value={addType} onChange={v => setAddType(v as SchedType)}>
|
||||||
<option value="ping">Ping</option>
|
<option value="ping">Ping</option>
|
||||||
<option value="bandwidth">BW-тест</option>
|
<option value="bandwidth">BW-тест</option>
|
||||||
<option value="both">Ping + BW</option>
|
<option value="both">Ping + BW</option>
|
||||||
@@ -467,7 +566,7 @@ function ScheduleTab({
|
|||||||
</Frame>
|
</Frame>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
|
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
|
||||||
<PlusIcon aria-hidden="true" className="size-4" />Добавить правило
|
<PlusIcon className="size-4" />Добавить правило
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -477,40 +576,16 @@ function ScheduleTab({
|
|||||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ProbesPage() {
|
export default function ProbesPage() {
|
||||||
return <Suspense fallback={<p role="status">Загрузка диагностики…</p>}><ProbesRoute /></Suspense>
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProbesRoute() {
|
|
||||||
const params = useSearchParams()
|
|
||||||
const url = readProbeUrl(new URLSearchParams(params.toString()))
|
|
||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
|
||||||
if (url.error) return <div role="alert" aria-label="Ссылка диагностики" className="p-4 space-y-2"><p>{url.error}</p><Button type="button" variant="outline" onClick={() => window.history.replaceState(null, "", patchProbeUrl(new URLSearchParams(params.toString()), null) + window.location.hash)}>Сбросить контекст диагностики</Button></div>
|
|
||||||
if (!prefsHydrated) return <p role="status">Загрузка настроек диагностики…</p>
|
|
||||||
return <ProbesContent key={JSON.stringify([mode, backendUrl])} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProbesContent() {
|
|
||||||
const params = useSearchParams()
|
|
||||||
const { server } = readProbeUrl(new URLSearchParams(params.toString()))
|
|
||||||
const srcId = server ?? ""
|
|
||||||
const selectServer = (id: string) => window.history.pushState(null, "", patchProbeUrl(new URLSearchParams(params.toString()), id || null) + window.location.hash)
|
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const canRead = can("mm:servers:read")
|
|
||||||
const canRun = can("mm:servers:write")
|
|
||||||
const canReadGre = can("mm:filters:read")
|
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
const [liveLoad, setLiveLoad] = useState<"idle" | "loading" | "error">(() => (isLive ? "loading" : "idle"))
|
const [liveLoad, setLiveLoad] = useState<"idle" | "loading" | "error">(() => (isLive ? "loading" : "idle"))
|
||||||
const [catalogError, setCatalogError] = useState<string | null>(null)
|
|
||||||
const [catalogRevision, setCatalogRevision] = useState(0)
|
|
||||||
const [catalogReadAt, setCatalogReadAt] = useState<number | null>(null)
|
|
||||||
const [greState, setGreState] = useState<{ id: string; state: "loading" | "ready" | "error"; error?: string } | null>(null)
|
|
||||||
const [greRevision, setGreRevision] = useState(0)
|
|
||||||
const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({})
|
const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({})
|
||||||
|
|
||||||
const [tool, setTool] = useState<DiagTool>("ping")
|
const [tool, setTool] = useState<DiagTool>("ping")
|
||||||
|
const [srcId, setSrcId] = useState(servers[0]?.id ?? "srv1")
|
||||||
const [target, setTarget] = useState("8.8.8.8")
|
const [target, setTarget] = useState("8.8.8.8")
|
||||||
const [pingCount, setPingCount] = useState(5)
|
const [pingCount, setPingCount] = useState(5)
|
||||||
const [pingSize, setPingSize] = useState(64)
|
const [pingSize, setPingSize] = useState(64)
|
||||||
@@ -519,40 +594,44 @@ function ProbesContent() {
|
|||||||
const [traceUseDns, setTraceUseDns] = useState(false)
|
const [traceUseDns, setTraceUseDns] = useState(false)
|
||||||
const [traceHops] = useState(30)
|
const [traceHops] = useState(30)
|
||||||
const [dnsType, setDnsType] = useState<DnsType>("A")
|
const [dnsType, setDnsType] = useState<DnsType>("A")
|
||||||
const [bwChoice, setBwChoice] = useState<{ serverId: string; id: string } | null>(null)
|
const [bwTunId, setBwTunId] = useState("")
|
||||||
const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp")
|
const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp")
|
||||||
const [bwDuration, setBwDuration] = useState(10)
|
const [bwDuration, setBwDuration] = useState(10)
|
||||||
const [runBusy, setRunBusy] = useState(false)
|
const [runBusy, setRunBusy] = useState(false)
|
||||||
/** В live: IPv4 для src-address (DNS A-запись к host API); undefined = грузим, null = не удалось */
|
/** В live: IPv4 для src-address (DNS A-запись к host API); undefined = грузим, null = не удалось */
|
||||||
const [source, setSource] = useState<{ key: string; ipv4?: string | null; error?: string; loading: boolean } | null>(null)
|
const [rosSrcV4, setRosSrcV4] = useState<string | null | undefined>(undefined)
|
||||||
const [sourceRevision, setSourceRevision] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive || !canRead) return
|
if (!isLive) {
|
||||||
let cancelled = false
|
queueMicrotask(() => setLiveLoad("idle"))
|
||||||
const ctrl = new AbortController()
|
return
|
||||||
queueMicrotask(() => { if (!cancelled) { setLiveLoad("loading"); setCatalogError(null) } })
|
}
|
||||||
void apiFetch<unknown>("/api/servers", { signal: ctrl.signal })
|
setLiveLoad("loading")
|
||||||
.then(raw => {
|
void apiFetch<BackendServerRow[]>("/api/servers")
|
||||||
const result = probeServersSchema.safeParse(raw)
|
.then(rows => {
|
||||||
if (!result.success) throw new Error("Некорректный ответ API: каталог устройств не подтверждён.")
|
const mapped: Server[] = rows.map(s => ({
|
||||||
if (cancelled) return
|
id: String(s.id),
|
||||||
setLiveServers(result.data.map(s => ({
|
name: s.name || s.host,
|
||||||
id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—",
|
host: s.host,
|
||||||
site: s.site ?? "", country: s.country ?? "UN", asn: s.asn ?? "", type: s.type,
|
model: "—",
|
||||||
enabled: s.enabled, status: s.status ?? "unknown",
|
os: "—",
|
||||||
latency: s.latency != null ? Math.round(s.latency) : null, sessions: 0,
|
site: s.site,
|
||||||
})))
|
country: s.country || "UN",
|
||||||
setCatalogReadAt(Date.now())
|
asn: s.asn,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
status: (s.status ?? "offline") as Server["status"],
|
||||||
|
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||||
|
sessions: 0,
|
||||||
|
}))
|
||||||
|
setLiveServers(mapped)
|
||||||
setLiveLoad("idle")
|
setLiveLoad("idle")
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(() => {
|
||||||
if (cancelled) return
|
setLiveServers([])
|
||||||
setCatalogError(error instanceof Error ? error.message : "Не удалось прочитать устройства.")
|
|
||||||
setLiveLoad("error")
|
setLiveLoad("error")
|
||||||
})
|
})
|
||||||
return () => { cancelled = true; ctrl.abort() }
|
}, [isLive, apiFetch])
|
||||||
}, [isLive, canRead, apiFetch, catalogRevision])
|
|
||||||
|
|
||||||
const allServers = useMemo(() => {
|
const allServers = useMemo(() => {
|
||||||
if (!isLive) return servers
|
if (!isLive) return servers
|
||||||
@@ -573,71 +652,72 @@ function ProbesContent() {
|
|||||||
}))
|
}))
|
||||||
), [allServers])
|
), [allServers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) {
|
||||||
|
setRosSrcV4(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const n = Number.parseInt(srcId, 10)
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
setRosSrcV4(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
setRosSrcV4(undefined)
|
||||||
|
void apiFetch<{ ipv4: string | null }>(`/api/servers/${n}/ros-src-address`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setRosSrcV4(r.ipv4)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setRosSrcV4(null)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [isLive, srcId, apiFetch])
|
||||||
|
|
||||||
|
const ensureGre = useCallback(async (serverId: string) => {
|
||||||
|
if (!isLive) return
|
||||||
|
try {
|
||||||
|
const d = await apiFetch<{ tunnels: GreTunnel[] }>(
|
||||||
|
`/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`,
|
||||||
|
)
|
||||||
|
setGreByServer(prev => ({ ...prev, [serverId]: d.tunnels }))
|
||||||
|
} catch {
|
||||||
|
setGreByServer(prev => ({ ...prev, [serverId]: [] }))
|
||||||
|
}
|
||||||
|
}, [isLive, apiFetch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive || !canReadGre || liveLoad !== "idle" || !allServers.some(s => s.id === srcId && s.enabled)) return
|
if (!isLive || !srcId) return
|
||||||
let cancelled = false
|
void ensureGre(srcId)
|
||||||
const ctrl = new AbortController()
|
}, [isLive, srcId, ensureGre])
|
||||||
queueMicrotask(() => { if (!cancelled) setGreState({ id: srcId, state: "loading" }) })
|
|
||||||
void apiFetch<unknown>("/api/filters/gre-tunnels?serverId=" + encodeURIComponent(srcId), { signal: ctrl.signal })
|
|
||||||
.then(raw => {
|
|
||||||
const result = filtersGreResponseSchema.safeParse(raw)
|
|
||||||
if (!result.success || result.data.tunnels.some(tunnel => tunnel.serverId !== srcId)) throw new Error("Некорректный ответ API: GRE выбранного устройства не подтверждены.")
|
|
||||||
if (cancelled) return
|
|
||||||
setGreByServer(prev => ({ ...prev, [srcId]: result.data.tunnels }))
|
|
||||||
setGreState({ id: srcId, state: "ready" })
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
if (cancelled) return
|
|
||||||
setGreState({ id: srcId, state: "error", error: error instanceof Error ? error.message : "Не удалось прочитать GRE." })
|
|
||||||
})
|
|
||||||
return () => { cancelled = true; ctrl.abort() }
|
|
||||||
}, [isLive, canReadGre, liveLoad, allServers, srcId, apiFetch, greRevision])
|
|
||||||
|
|
||||||
const tunnelsForSrc = useMemo(() => {
|
const tunnelsForSrc = useMemo(() => {
|
||||||
if (isLive) return greByServer[srcId] ?? []
|
if (isLive) return greByServer[srcId] ?? []
|
||||||
return greTunnels.filter(t => t.serverId === srcId)
|
return greTunnels.filter(t => t.serverId === srcId)
|
||||||
}, [isLive, greByServer, srcId])
|
}, [isLive, greByServer, srcId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const first = allServers[0]?.id
|
||||||
|
if (!first) return
|
||||||
|
if (!allServers.some(s => s.id === srcId)) setSrcId(first)
|
||||||
|
}, [allServers, srcId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const list = isLive ? (greByServer[srcId] ?? []) : greTunnels.filter(t => t.serverId === srcId)
|
||||||
|
if (list.length && !list.some(t => t.id === bwTunId)) setBwTunId(list[0]!.id)
|
||||||
|
}, [isLive, greByServer, srcId, bwTunId])
|
||||||
|
|
||||||
// ── run state ──
|
// ── run state ──
|
||||||
const [tests, setTests] = useState<DiagTest[]>([])
|
const [tests, setTests] = useState<DiagTest[]>([])
|
||||||
const [tab, setTab] = useState<"history" | "schedule">("history")
|
const [tab, setTab] = useState<"history" | "schedule">("history")
|
||||||
const [rules, setRules] = useState<SchedRule[]>(INIT_RULES)
|
const [rules, setRules] = useState<SchedRule[]>(INIT_RULES)
|
||||||
const nextId = useRef(1)
|
const nextId = useRef(1)
|
||||||
const targetRef = useRef<HTMLInputElement>(null)
|
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const runButtonRef = useRef<HTMLButtonElement>(null)
|
|
||||||
/** Живой POST /probes/run — отмена через fetch AbortSignal */
|
/** Живой POST /probes/run — отмена через fetch AbortSignal */
|
||||||
const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null)
|
const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null)
|
||||||
|
|
||||||
const runLock = useRef(false)
|
|
||||||
const alive = useRef(true)
|
|
||||||
useEffect(() => {
|
|
||||||
alive.current = true
|
|
||||||
return () => { alive.current = false; liveProbeRunRef.current?.ctrl.abort() }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const bwTunnels = tunnelsForSrc
|
const bwTunnels = tunnelsForSrc
|
||||||
const bwTunId = bwChoice?.serverId === srcId ? bwChoice.id : (bwTunnels[0]?.id ?? "")
|
|
||||||
const setBwTunId = (id: string) => setBwChoice({ serverId: srcId, id })
|
|
||||||
const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId])
|
const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId])
|
||||||
const sourceKey = JSON.stringify([srcId, srcServer?.host])
|
|
||||||
const sourceRequired = tool === "ping" || tool === "traceroute" || tool === "mtu"
|
|
||||||
const sourceCurrent = source?.key === sourceKey ? source : null
|
|
||||||
const rosSrcV4 = sourceCurrent?.ipv4
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLive || !canRead || liveLoad !== "idle" || !srcServer?.enabled || !sourceRequired) return
|
|
||||||
const host = srcServer.host, id = srcServer.id
|
|
||||||
if (!/^[1-9][0-9]*$/.test(id) || !Number.isSafeInteger(Number(id))) return
|
|
||||||
let cancelled = false
|
|
||||||
const ctrl = new AbortController()
|
|
||||||
queueMicrotask(() => { if (!cancelled) setSource({ key: sourceKey, loading: true }) })
|
|
||||||
void apiFetch<unknown>("/api/servers/" + id + "/ros-src-address", { signal: ctrl.signal })
|
|
||||||
.then(raw => { const ipv4 = readProbeSource(raw, host); if (!cancelled) setSource({ key: sourceKey, loading: false, ipv4 }) })
|
|
||||||
.catch(error => { if (!cancelled) setSource({ key: sourceKey, loading: false, error: error instanceof Error ? error.message : "Источник preview неизвестен." }) })
|
|
||||||
return () => { cancelled = true; ctrl.abort() }
|
|
||||||
}, [isLive, canRead, liveLoad, srcServer, sourceRequired, sourceKey, apiFetch, sourceRevision])
|
|
||||||
const greReady = !isLive || (greState?.id === srcId && greState.state === "ready")
|
|
||||||
const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId])
|
const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId])
|
||||||
|
|
||||||
// current command preview
|
// current command preview
|
||||||
@@ -646,21 +726,37 @@ function ProbesContent() {
|
|||||||
bwTarget: bwTun?.remoteAddress, bwProto, bwDuration,
|
bwTarget: bwTun?.remoteAddress, bwProto, bwDuration,
|
||||||
}, isLive ? rosSrcV4 : undefined), [allServers, tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, traceUseDns, dnsType, bwTun, bwProto, bwDuration, isLive, rosSrcV4])
|
}, isLive ? rosSrcV4 : undefined), [allServers, tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, traceUseDns, dnsType, bwTun, bwProto, bwDuration, isLive, rosSrcV4])
|
||||||
|
|
||||||
|
// ── progressive reveal tick ──
|
||||||
|
useEffect(() => {
|
||||||
|
tickRef.current = setInterval(() => {
|
||||||
|
setTests(prev => {
|
||||||
|
const hasRunning = prev.some(t => t.status === "running")
|
||||||
|
if (!hasRunning) return prev
|
||||||
|
return prev.map(t => {
|
||||||
|
if (t.source === "live") return t
|
||||||
|
if (t.status !== "running") return t
|
||||||
|
const speed = t.tool === "dns" || t.tool === "route" ? t.lines.length : 1
|
||||||
|
const nextTotal = Math.min(t.totalLines + speed, t.lines.length)
|
||||||
|
const done = nextTotal >= t.lines.length
|
||||||
|
return { ...t, totalLines: nextTotal, status: done ? "done" : "running" }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, 400)
|
||||||
|
return () => { if (tickRef.current) clearInterval(tickRef.current) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
// ── run test ──
|
// ── run test ──
|
||||||
const runTest = useCallback(async () => {
|
const runTest = useCallback(async () => {
|
||||||
const srv = allServers.find(s => s.id === srcId)
|
const srv = allServers.find(s => s.id === srcId)
|
||||||
if (!canRun || (tool === "bandwidth" && (!bwTun?.enabled || !bwTun.remoteAddress.trim() || !greReady || (isLive && !canReadGre))) || !srv || !srv.enabled || runLock.current || (isLive && liveLoad !== "idle")) return
|
if (!srv) return
|
||||||
const id = String(nextId.current++)
|
const id = String(nextId.current++)
|
||||||
const numericServerId = Number.parseInt(srcId, 10)
|
const numericServerId = Number.parseInt(srcId, 10)
|
||||||
|
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
if (!Number.isFinite(numericServerId)) return
|
if (!Number.isFinite(numericServerId)) return
|
||||||
runLock.current = true
|
|
||||||
setRunBusy(true)
|
setRunBusy(true)
|
||||||
const abortCtrl = new AbortController()
|
const abortCtrl = new AbortController()
|
||||||
liveProbeRunRef.current = { testId: id, ctrl: abortCtrl }
|
liveProbeRunRef.current = { testId: id, ctrl: abortCtrl }
|
||||||
let timedOut = false
|
|
||||||
const timeout = setTimeout(() => { timedOut = true; abortCtrl.abort() }, 60_000)
|
|
||||||
setTests(p => [{
|
setTests(p => [{
|
||||||
id,
|
id,
|
||||||
tool,
|
tool,
|
||||||
@@ -670,7 +766,7 @@ function ProbesContent() {
|
|||||||
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
|
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
|
||||||
command: cmdPreview,
|
command: cmdPreview,
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
lines: [{ text: tool === "dns" ? "Выполняется DNS-запрос приложения…" : "Выполняется запрос к MikroTik через API…", kind: "dim" }],
|
lines: [{ text: "Выполняется запрос к MikroTik через API…", kind: "dim" }],
|
||||||
totalLines: 1,
|
totalLines: 1,
|
||||||
source: "live",
|
source: "live",
|
||||||
}, ...p.slice(0, 9)])
|
}, ...p.slice(0, 9)])
|
||||||
@@ -697,34 +793,32 @@ function ProbesContent() {
|
|||||||
bwProto,
|
bwProto,
|
||||||
bwDuration,
|
bwDuration,
|
||||||
}
|
}
|
||||||
const res = await apiFetch<unknown>(`/api/servers/${numericServerId}/probes/run`, {
|
const res = await apiFetch<{ output: string }>(`/api/servers/${numericServerId}/probes/run`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal: abortCtrl.signal,
|
signal: abortCtrl.signal,
|
||||||
})
|
})
|
||||||
if (!alive.current || abortCtrl.signal.aborted) return
|
const lines = parseProbeOutput(res.output ?? "")
|
||||||
const output = readProbeOutput(res)
|
const looksErr = (res.output ?? "").trim().toLowerCase().startsWith("error:")
|
||||||
const lines = parseProbeOutput(output || "Запрос выполнен: API вернул пустой вывод.")
|
|
||||||
setTests(p => p.map(t => (t.id === id
|
setTests(p => p.map(t => (t.id === id
|
||||||
? {
|
? {
|
||||||
...t,
|
...t,
|
||||||
lines,
|
lines,
|
||||||
totalLines: lines.length,
|
totalLines: lines.length,
|
||||||
status: "done",
|
status: looksErr ? "error" : "done",
|
||||||
}
|
}
|
||||||
: t)))
|
: t)))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!alive.current) return
|
const aborted =
|
||||||
const aborted = abortCtrl.signal.aborted ||
|
|
||||||
(typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError")
|
(typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError")
|
||||||
|| (e instanceof Error && e.name === "AbortError")
|
|| (e instanceof Error && e.name === "AbortError")
|
||||||
if (aborted) {
|
if (aborted) {
|
||||||
setTests(p => p.map(t => (t.id === id
|
setTests(p => p.map(t => (t.id === id
|
||||||
? {
|
? {
|
||||||
...t,
|
...t,
|
||||||
lines: [{ text: timedOut ? "Время ожидания истекло. Результат на устройстве не подтверждён; повтор выполняется вручную." : "Ожидание отменено. Остановка на устройстве не подтверждена.", kind: timedOut ? "err" : "dim" }],
|
lines: [{ text: "Запрос отменён (стоп или закрытие запроса).", kind: "dim" }],
|
||||||
totalLines: 1,
|
totalLines: 1,
|
||||||
status: timedOut ? "error" : "cancelled",
|
status: "done",
|
||||||
}
|
}
|
||||||
: t)))
|
: t)))
|
||||||
} else {
|
} else {
|
||||||
@@ -739,13 +833,8 @@ function ProbesContent() {
|
|||||||
: t)))
|
: t)))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout)
|
|
||||||
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null
|
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null
|
||||||
runLock.current = false
|
setRunBusy(false)
|
||||||
if (alive.current) {
|
|
||||||
setRunBusy(false)
|
|
||||||
requestAnimationFrame(() => { if (alive.current && (document.activeElement === runButtonRef.current || document.activeElement === document.body)) (targetRef.current ?? runButtonRef.current)?.focus() })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -778,10 +867,6 @@ function ProbesContent() {
|
|||||||
setTab("history")
|
setTab("history")
|
||||||
}, [
|
}, [
|
||||||
isLive,
|
isLive,
|
||||||
liveLoad,
|
|
||||||
canRun,
|
|
||||||
canReadGre,
|
|
||||||
greReady,
|
|
||||||
allServers,
|
allServers,
|
||||||
srcId,
|
srcId,
|
||||||
tool,
|
tool,
|
||||||
@@ -794,6 +879,7 @@ function ProbesContent() {
|
|||||||
traceHops,
|
traceHops,
|
||||||
dnsType,
|
dnsType,
|
||||||
bwTun,
|
bwTun,
|
||||||
|
bwTunId,
|
||||||
bwProto,
|
bwProto,
|
||||||
bwDuration,
|
bwDuration,
|
||||||
cmdPreview,
|
cmdPreview,
|
||||||
@@ -801,27 +887,21 @@ function ProbesContent() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const stopTest = (id: string) => {
|
const stopTest = (id: string) => {
|
||||||
if (liveProbeRunRef.current?.testId === id) { liveProbeRunRef.current.ctrl.abort(); return }
|
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort()
|
||||||
setTests(p => p.map(t => (t.id === id && t.status === "running"
|
setTests(p => p.map(t => (t.id === id && t.status === "running"
|
||||||
? { ...t, status: "cancelled" as const, totalLines: t.lines.length }
|
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
||||||
: t)))
|
: t)))
|
||||||
}
|
}
|
||||||
const clearTest = (id: string) => {
|
const clearTest = (id: string) => setTests(p => p.filter(t => t.id !== id))
|
||||||
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort()
|
|
||||||
setTests(p => p.filter(t => t.id !== id))
|
|
||||||
runButtonRef.current?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const running = tests.filter(t => t.status === "running")
|
const running = tests.filter(t => t.status === "running")
|
||||||
|
|
||||||
if (!canRead) return <p role="status">Для просмотра устройств требуется mm:servers:read.</p>
|
if (isLive && liveLoad === "loading") {
|
||||||
|
|
||||||
if (isLive && liveLoad === "loading" && catalogReadAt === null) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]} />
|
<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]} />
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||||
<LoaderCircleIcon aria-hidden="true" className="size-8 motion-safe:animate-spin opacity-50" />
|
<LoaderCircleIcon className="size-8 animate-spin opacity-50" />
|
||||||
<p className="text-sm">Загрузка серверов из бекенда…</p>
|
<p className="text-sm">Загрузка серверов из бекенда…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -832,8 +912,7 @@ function ProbesContent() {
|
|||||||
<ServerRailLayout
|
<ServerRailLayout
|
||||||
items={probeRailItems}
|
items={probeRailItems}
|
||||||
selectedId={srcId}
|
selectedId={srcId}
|
||||||
onSelect={selectServer}
|
onSelect={setSrcId}
|
||||||
preserveSelection
|
|
||||||
showAll={false}
|
showAll={false}
|
||||||
loading={isLive && liveLoad === "loading" && allServers.length === 0}
|
loading={isLive && liveLoad === "loading" && allServers.length === 0}
|
||||||
header={
|
header={
|
||||||
@@ -842,12 +921,14 @@ function ProbesContent() {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<ServerRailMobileButton />
|
<ServerRailMobileButton />
|
||||||
{isLive && <Button type="button" size="sm" variant="outline" aria-label="Обновить устройства диагностики" disabled={liveLoad === "loading"} onClick={() => setCatalogRevision(n => n + 1)}>Обновить</Button>}
|
|
||||||
{running.length > 0
|
{running.length > 0
|
||||||
? <Button variant="outline" size="sm" onClick={() => {
|
? <Button variant="outline" size="sm" onClick={() => {
|
||||||
running.forEach(test => stopTest(test.id))
|
liveProbeRunRef.current?.ctrl.abort()
|
||||||
|
setTests(p => p.map(t => (t.status === "running"
|
||||||
|
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
||||||
|
: t)))
|
||||||
}}>
|
}}>
|
||||||
<SquareIcon aria-hidden="true" className="size-4" />Остановить все
|
<SquareIcon className="size-4" />Остановить все
|
||||||
</Button>
|
</Button>
|
||||||
: null}
|
: null}
|
||||||
</>
|
</>
|
||||||
@@ -857,61 +938,35 @@ function ProbesContent() {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
{!server && <p role="status">Выберите устройство для диагностики. Выбор сохраняется в URL.</p>}
|
|
||||||
{server && (!isLive || catalogReadAt !== null) && !srcServer && <div role="alert" aria-label="Устройство диагностики"><p>Указанное устройство отсутствует в текущем каталоге. Другое устройство автоматически не выбирается.</p><Button type="button" variant="outline" onClick={() => selectServer("")}>Сбросить выбор устройства</Button></div>}
|
|
||||||
{srcServer && !srcServer.enabled && <p role="status">Выбранное устройство выключено. Диагностика недоступна.</p>}
|
|
||||||
{isLive && liveLoad === "error" && (
|
{isLive && liveLoad === "error" && (
|
||||||
<div role="alert" aria-label="Каталог диагностики" className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-xs text-destructive flex items-center gap-2">
|
||||||
<p>{catalogReadAt === null ? "Устройства пока неизвестны." : "Не удалось обновить устройства. Последний список сохранён; запуск приостановлен."}</p>
|
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||||
<p>{catalogError}</p>
|
Бекенд недоступен — переключитесь в режим «Демо» в настройках источника данных или проверьте URL API.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isLive && catalogReadAt !== null && <p role="status" className="text-xs text-muted-foreground">Каталог получен: {new Date(catalogReadAt).toLocaleTimeString("ru-RU")}{liveLoad === "loading" ? " · обновляется" : ""}</p>}
|
|
||||||
{isLive && liveLoad === "idle" && allServers.length === 0 && <p role="status">В каталоге нет устройств. Добавьте устройство в разделе «Серверы».</p>}
|
|
||||||
|
|
||||||
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демо: запросы не отправляются; результаты синтетические.</p>}
|
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<details className="text-xs text-muted-foreground"><summary className="cursor-pointer">Где выполняются тесты</summary><p className="pt-2">
|
<p className="text-xs text-muted-foreground">
|
||||||
Сетевые тесты выполняются на выбранном устройстве. DNS использует резолвер приложения. Traceroute ограничен примерно минутой; сокращение числа хопов указывается в ответе. Стоп отменяет ожидание, но не подтверждает остановку на устройстве.
|
Режим «Живые»: 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></details>
|
</p>
|
||||||
)}
|
|
||||||
|
|
||||||
{!canRun && <p role="status" className="text-sm text-muted-foreground">Для запуска диагностики требуется mm:servers:write.</p>}
|
|
||||||
{isLive && tool === "bandwidth" && !canReadGre && <p role="status" className="text-sm text-muted-foreground">Для выбора GRE требуется mm:filters:read.</p>}
|
|
||||||
|
|
||||||
{isLive && tool === "bandwidth" && canReadGre && (
|
|
||||||
<div className="text-sm">
|
|
||||||
{greState?.id === srcId && greState.state === "error" ? <div role="alert" aria-label="GRE диагностики"><p className="text-destructive">GRE недоступны. Последний список сохранён; BW-тест приостановлен. {greState.error}</p><Button type="button" size="sm" variant="outline" onClick={() => setGreRevision(n => n + 1)}>Повторить GRE</Button></div>
|
|
||||||
: !greReady ? <p role="status">GRE выбранного устройства загружаются…</p>
|
|
||||||
: !bwTunnels.length ? <p role="status">У выбранного устройства нет GRE-туннелей.</p> : !bwTun?.enabled || !bwTun.remoteAddress.trim() ? <p role="status">Выберите включённый GRE с адресом назначения.</p> : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLive && srcServer?.enabled && sourceRequired && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{sourceCurrent?.error ? <div role="alert" aria-label="Источник preview"><p>{sourceCurrent.error}</p><Button type="button" size="sm" variant="outline" onClick={() => setSourceRevision(n => n + 1)}>Повторить определение IPv4</Button></div>
|
|
||||||
: !sourceCurrent || sourceCurrent.loading ? <p role="status">Определяем IPv4 для preview…</p>
|
|
||||||
: <p role="status">{sourceCurrent.ipv4 === null ? "IPv4 для preview не определён." : "IPv4 для preview: " + sourceCurrent.ipv4}</p>}
|
|
||||||
<p>Адрес справочный: при запуске определяется заново.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── tool selector + config ── */}
|
{/* ── tool selector + config ── */}
|
||||||
<OpsPanel contentClassName="pt-4 pb-4 px-3 md:px-4 flex min-w-0 flex-col gap-4">
|
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4">
|
||||||
|
|
||||||
{/* tool chips */}
|
{/* tool chips */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => {
|
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => {
|
||||||
const active = tool === t
|
const active = tool === t
|
||||||
return (
|
return (
|
||||||
<button key={t} type="button" aria-pressed={active} title={m.description} onClick={() => setTool(t)}
|
<button key={t} onClick={() => setTool(t)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors",
|
||||||
active
|
active
|
||||||
? "border-foreground bg-foreground text-background"
|
? "border-foreground bg-foreground text-background"
|
||||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||||
)}>
|
)}>
|
||||||
<m.Icon aria-hidden="true" className={cn("size-3.5", active ? "" : m.color)} />
|
<m.Icon className={cn("size-3.5", active ? "" : m.color)} />
|
||||||
{m.label}
|
{m.label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -924,12 +979,11 @@ function ProbesContent() {
|
|||||||
{/* target — all tools except bandwidth */}
|
{/* target — all tools except bandwidth */}
|
||||||
{tool !== "bandwidth" && (
|
{tool !== "bandwidth" && (
|
||||||
<div className="flex-1 min-w-[140px]">
|
<div className="flex-1 min-w-[140px]">
|
||||||
<OptionLabel htmlFor="probe-target">
|
<OptionLabel>
|
||||||
{tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель (IP или домен)"}
|
{tool === "dns" ? "Домен" : tool === "route" ? "Destination IP" : "Цель (IP или домен)"}
|
||||||
</OptionLabel>
|
</OptionLabel>
|
||||||
<Input id="probe-target" ref={targetRef} maxLength={1024} value={target} onChange={e => setTarget(e.target.value)}
|
<Input value={target} onChange={e => setTarget(e.target.value)}
|
||||||
placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"}
|
placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"}
|
||||||
aria-label={tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель диагностики"}
|
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
onKeyDown={e => e.key === "Enter" && runTest()}
|
onKeyDown={e => e.key === "Enter" && runTest()}
|
||||||
/>
|
/>
|
||||||
@@ -940,8 +994,7 @@ function ProbesContent() {
|
|||||||
{tool === "bandwidth" && (
|
{tool === "bandwidth" && (
|
||||||
<div>
|
<div>
|
||||||
<OptionLabel>GRE-туннель (цель)</OptionLabel>
|
<OptionLabel>GRE-туннель (цель)</OptionLabel>
|
||||||
<NativeSelect ariaLabel="GRE-туннель для BW-теста" value={bwTunnels.some(t => t.id === bwTunId) ? bwTunId : ""} onChange={setBwTunId} className="min-w-[180px]">
|
<NativeSelect value={bwTunId} onChange={setBwTunId} className="min-w-[180px]">
|
||||||
{!bwTunnels.some(t => t.id === bwTunId) && <option value="" disabled>Выберите GRE-туннель</option>}
|
|
||||||
{bwTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
{bwTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</div>
|
</div>
|
||||||
@@ -951,14 +1004,14 @@ function ProbesContent() {
|
|||||||
{tool === "ping" && (
|
{tool === "ping" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<OptionLabel>Количество пакетов</OptionLabel>
|
<OptionLabel>Кол-во</OptionLabel>
|
||||||
<div className="flex flex-wrap rounded-lg border border-input min-h-8">
|
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||||
{[5, 10, 25, 100].map(n => <SegBtn key={n} value={n} current={pingCount} onClick={setPingCount}>{n}</SegBtn>)}
|
{[5, 10, 25, 100].map(n => <SegBtn key={n} value={n} current={pingCount} onClick={setPingCount}>{n}</SegBtn>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<OptionLabel>Размер (байт)</OptionLabel>
|
<OptionLabel>Размер (байт)</OptionLabel>
|
||||||
<div className="flex flex-wrap rounded-lg border border-input overflow-hidden min-h-8">
|
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||||
{[64, 128, 512, 1472].map(n => <SegBtn key={n} value={n} current={pingSize} onClick={setPingSize}>{n}</SegBtn>)}
|
{[64, 128, 512, 1472].map(n => <SegBtn key={n} value={n} current={pingSize} onClick={setPingSize}>{n}</SegBtn>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -980,7 +1033,7 @@ function ProbesContent() {
|
|||||||
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<FormToggle ariaLabel="Разрешать имена хопов на устройстве" checked={traceUseDns} onChange={setTraceUseDns} />
|
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -989,7 +1042,7 @@ function ProbesContent() {
|
|||||||
<div>
|
<div>
|
||||||
<OptionLabel>Тип записи</OptionLabel>
|
<OptionLabel>Тип записи</OptionLabel>
|
||||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||||
{(["A", "AAAA", "MX", "NS", "TXT", "CNAME", "PTR"] as DnsType[]).map(t => <SegBtn key={t} value={t} current={dnsType} onClick={setDnsType}>{t}</SegBtn>)}
|
{(["A", "AAAA", "MX", "NS", "TXT", "PTR"] as DnsType[]).map(t => <SegBtn key={t} value={t} current={dnsType} onClick={setDnsType}>{t}</SegBtn>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1011,25 +1064,25 @@ function ProbesContent() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button ref={runButtonRef} type="button" aria-busy={runBusy}
|
<Button
|
||||||
onClick={() => void runTest()}
|
onClick={() => void runTest()}
|
||||||
disabled={!canRun || (tool === "bandwidth" && (!bwTun?.enabled || !bwTun.remoteAddress.trim() || !greReady || (isLive && !canReadGre))) || runBusy || !srcServer?.enabled || (isLive && liveLoad !== "idle") || (tool === "bandwidth" && !bwTunnels.length)}
|
disabled={runBusy || !srcServer || (tool === "bandwidth" && !bwTunnels.length)}
|
||||||
className="h-8 shrink-0 gap-1.5 self-end"
|
className="h-8 shrink-0 gap-1.5 self-end"
|
||||||
>
|
>
|
||||||
{runBusy ? <LoaderCircleIcon aria-hidden="true" className="size-3.5 motion-safe:animate-spin" /> : <PlayIcon aria-hidden="true" className="size-3.5" />}
|
{runBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : <PlayIcon className="size-3.5" />}
|
||||||
Запустить
|
Запустить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RouterOS command preview */}
|
{/* RouterOS command preview */}
|
||||||
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-muted/30 px-3 py-2 border">
|
<div className="flex items-center gap-2 rounded-lg bg-zinc-950 dark:bg-zinc-900 px-3 py-2 border border-zinc-800">
|
||||||
<TerminalIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
|
<TerminalIcon className="size-3 text-zinc-500 shrink-0" />
|
||||||
<span className="hidden md:inline text-muted-foreground text-xs font-mono truncate max-w-24">[{tool === "dns" ? (isLive ? "backend" : "демо") : srcServer?.name}]</span>
|
<span className="text-zinc-500 text-xs font-mono shrink-0">[{srcServer?.name}]</span>
|
||||||
<span className="hidden md:inline text-success text-xs font-mono shrink-0">$</span>
|
<span className="text-emerald-400 text-xs font-mono shrink-0">$</span>
|
||||||
<span className="min-w-0 flex-1 text-foreground text-xs font-mono [overflow-wrap:anywhere]">{cmdPreview}</span>
|
<span className="text-zinc-300 text-xs font-mono truncate">{cmdPreview}</span>
|
||||||
<button type="button" aria-label="Скопировать параметры диагностики" onClick={() => void copyProbeText(cmdPreview)}
|
<button onClick={() => navigator.clipboard.writeText(cmdPreview)}
|
||||||
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors ml-auto">
|
className="shrink-0 text-zinc-600 hover:text-zinc-300 transition-colors ml-auto">
|
||||||
<CopyIcon aria-hidden="true" className="size-3" />
|
<CopyIcon className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
@@ -1041,7 +1094,7 @@ function ProbesContent() {
|
|||||||
["history", "История тестов"],
|
["history", "История тестов"],
|
||||||
["schedule", "Расписание"],
|
["schedule", "Расписание"],
|
||||||
] as const).map(([t, label]) => (
|
] as const).map(([t, label]) => (
|
||||||
<button key={t} type="button" aria-pressed={tab === t} onClick={() => setTab(t)}
|
<button key={t} onClick={() => setTab(t)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
|
"px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
|
||||||
tab === t
|
tab === t
|
||||||
@@ -1063,7 +1116,7 @@ function ProbesContent() {
|
|||||||
{tab === "history" && (
|
{tab === "history" && (
|
||||||
tests.length === 0 ? (
|
tests.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
|
||||||
<TerminalIcon aria-hidden="true" className="size-8 opacity-20" />
|
<TerminalIcon className="size-8 opacity-20" />
|
||||||
<p className="text-sm">Запустите тест — результаты появятся здесь</p>
|
<p className="text-sm">Запустите тест — результаты появятся здесь</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1071,46 +1124,52 @@ function ProbesContent() {
|
|||||||
{tests.map(test => {
|
{tests.map(test => {
|
||||||
const { Icon, color, label } = TOOL_META[test.tool]
|
const { Icon, color, label } = TOOL_META[test.tool]
|
||||||
return (
|
return (
|
||||||
<Frame key={test.id} dense aria-busy={test.status === "running"} className="w-full h-[60dvh] min-h-0 flex flex-col overflow-hidden">
|
<Frame key={test.id} dense className="w-full overflow-hidden">
|
||||||
<FramePanel className="p-0 flex-1 min-h-0 flex flex-col overflow-hidden">
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
{/* header */}
|
{/* header */}
|
||||||
<div className="shrink-0 flex flex-wrap items-center justify-between gap-2 px-3 py-2.5 border-b bg-muted/20">
|
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20">
|
||||||
<div className="flex flex-wrap items-center gap-2 min-w-0 w-full md:w-auto">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Icon aria-hidden="true" className={cn("size-4 shrink-0", color)} />
|
<Icon className={cn("size-4 shrink-0", color)} />
|
||||||
<span className="text-sm font-medium">{label}</span>
|
<span className="text-sm font-medium">{label}</span>
|
||||||
<span className="min-w-0 max-w-[70%] text-xs font-mono text-muted-foreground truncate" title={test.target}>{test.target}</span>
|
<span className="text-xs font-mono text-muted-foreground truncate">{test.target}</span>
|
||||||
<span className="w-full md:w-auto max-w-full text-xs text-muted-foreground truncate" title={test.tool === "dns" ? "DNS приложения" : test.srcServerName}>{test.tool === "dns" ? "DNS приложения" : "← " + test.srcServerName}</span>
|
<span className="text-[10px] text-muted-foreground">← {test.srcServerName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{test.status === "running" && (
|
{test.status === "running" && (
|
||||||
<>
|
<>
|
||||||
<span role="status" className="flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
<span className="size-1.5 rounded-full bg-info motion-safe:animate-pulse" />
|
<span className="size-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
ожидаем ответ
|
запущен
|
||||||
</span>
|
</span>
|
||||||
<Button size="sm" variant="outline" type="button" className="h-11 md:h-7 px-2 text-xs"
|
<Button size="sm" variant="outline" className="h-6 px-2 text-xs"
|
||||||
onClick={() => stopTest(test.id)}>
|
onClick={() => stopTest(test.id)}>
|
||||||
<SquareIcon aria-hidden="true" className="size-3" />Стоп
|
<SquareIcon className="size-3" />Стоп
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{test.status === "done" && <span role="status" className="text-xs text-muted-foreground">{test.source === "live" ? "ответ API" : "демо"}</span>}
|
{test.status === "done" && test.source !== "live" && (
|
||||||
{test.status === "cancelled" && <span role="status" className="text-[11px] text-muted-foreground">ожидание отменено</span>}
|
<span className="text-[11px] text-muted-foreground">
|
||||||
{test.status === "error" && (
|
{((test.lines.length * 0.4)).toFixed(1)}с
|
||||||
<span role="alert" className="text-xs text-destructive">ошибка</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button type="button" aria-label={"Скопировать показанный вывод · " + label + " · " + test.id} onClick={() => void copyProbeText(test.lines.map(l => l.text).join("\n"))}
|
{test.status === "done" && test.source === "live" && (
|
||||||
className="size-11 md:size-8 flex items-center justify-center rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:text-foreground hover:bg-muted transition-colors">
|
<span className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">live</span>
|
||||||
<CopyIcon aria-hidden="true" className="size-3.5" />
|
)}
|
||||||
|
{test.status === "error" && (
|
||||||
|
<span className="text-[11px] text-destructive">ошибка</span>
|
||||||
|
)}
|
||||||
|
<button onClick={() => navigator.clipboard.writeText(test.lines.map(l => l.text).join("\n"))}
|
||||||
|
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-colors">
|
||||||
|
<CopyIcon className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" aria-label={"Удалить результат · " + label + " · " + test.id} onClick={() => clearTest(test.id)}
|
<button onClick={() => clearTest(test.id)}
|
||||||
className="size-11 md:size-8 flex items-center justify-center rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:text-destructive hover:bg-destructive/10 transition-colors">
|
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors">
|
||||||
<Trash2Icon aria-hidden="true" className="size-3.5" />
|
<Trash2Icon className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* terminal output */}
|
{/* terminal output */}
|
||||||
<div className="flex-1 min-h-0 p-3">
|
<div className="p-3">
|
||||||
<TerminalOutput test={test} />
|
<TerminalOutput test={test} />
|
||||||
</div>
|
</div>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
@@ -1124,10 +1183,9 @@ function ProbesContent() {
|
|||||||
{/* schedule */}
|
{/* schedule */}
|
||||||
{tab === "schedule" && (
|
{tab === "schedule" && (
|
||||||
isLive
|
isLive
|
||||||
? <ProbesSpeedSchedule base={backendUrl} serversForName={allServers} />
|
? <ScheduleSpeedProbesLive apiFetch={apiFetch} serversForName={allServers} />
|
||||||
: (
|
: (
|
||||||
<ScheduleTab
|
<ScheduleTab
|
||||||
key={srcId}
|
|
||||||
rules={rules}
|
rules={rules}
|
||||||
setRules={setRules}
|
setRules={setRules}
|
||||||
serverOptions={allServers}
|
serverOptions={allServers}
|
||||||
|
|||||||
@@ -1,49 +1,51 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useMemo, useState, Suspense } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { readRecursiveUrl, patchRecursiveUrl, type RecursiveUrlPatch, type RecursiveUrlState } from "@/lib/recursive-route-url-state"
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import {
|
import {
|
||||||
RecursiveRoutesDataGrid,
|
RecursiveRoutesDataGrid,
|
||||||
type RecursiveRouteGroup,
|
type RecursiveRouteGroup,
|
||||||
|
inferCountry,
|
||||||
} from "@/components/data-grids/recursive-routes-data-grid"
|
} from "@/components/data-grids/recursive-routes-data-grid"
|
||||||
import { RecursiveGatewayChoices, type GatewayOption } from "@/components/recursive-gateway-choices"
|
|
||||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { can } from "@/lib/auth"
|
|
||||||
import { recursiveReadSchema, recursiveReceiptSchema, recursiveGatewaysSchema, recursiveServersSchema, type RecursiveRouteRow } from "@/lib/recursive-route-response"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { servers as mockServers, type Server } from "@/lib/data"
|
import { servers as mockServers, type Server } from "@/lib/data"
|
||||||
import { PlusIcon, TrashIcon, SearchIcon, XIcon, CheckIcon, AlertCircleIcon, RefreshCwIcon, HistoryIcon, AlertTriangleIcon } from "lucide-react"
|
import { PlusIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon, RefreshCwIcon, HistoryIcon, AlertTriangleIcon } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
|
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
interface BackendServer {
|
interface BackendServer {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
host: string
|
host: string
|
||||||
type?: "jump-host" | "exit-node" | "home-router"
|
type: "jump-host" | "exit-node" | "home-router"
|
||||||
site: string
|
site: string
|
||||||
country: string
|
country: string
|
||||||
asn: string
|
asn: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
status: "online" | "offline" | "degraded" | null
|
status: "online" | "offline" | null
|
||||||
latency: number | null
|
latency: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GatewayOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
ip: string
|
||||||
|
status: "up" | "down"
|
||||||
|
}
|
||||||
|
|
||||||
const COUNTRY_OPTIONS = [
|
const COUNTRY_OPTIONS = [
|
||||||
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
||||||
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
||||||
@@ -59,7 +61,21 @@ const COUNTRY_OPTIONS = [
|
|||||||
{ code: "NO", label: "Норвегия" },
|
{ code: "NO", label: "Норвегия" },
|
||||||
]
|
]
|
||||||
|
|
||||||
type RouteGroup = RecursiveRouteGroup
|
interface RecursiveRouteRow {
|
||||||
|
id: string
|
||||||
|
dstAddress: string
|
||||||
|
gateway: string
|
||||||
|
distance: number
|
||||||
|
scope: number | null
|
||||||
|
targetScope: number | null
|
||||||
|
routingTable: string
|
||||||
|
checkGateway: string
|
||||||
|
comment: string
|
||||||
|
disabled: boolean
|
||||||
|
country: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteGroup extends RecursiveRouteGroup {}
|
||||||
|
|
||||||
function makeApiFetch(backendUrl: string) {
|
function makeApiFetch(backendUrl: string) {
|
||||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -67,7 +83,7 @@ function makeApiFetch(backendUrl: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapBackendServer(s: BackendServer): Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] } {
|
function mapBackendServer(s: BackendServer): Server {
|
||||||
return {
|
return {
|
||||||
id: String(s.id),
|
id: String(s.id),
|
||||||
name: s.name || s.host,
|
name: s.name || s.host,
|
||||||
@@ -79,7 +95,7 @@ function mapBackendServer(s: BackendServer): Omit<Server, "status" | "type"> & {
|
|||||||
asn: s.asn,
|
asn: s.asn,
|
||||||
type: s.type,
|
type: s.type,
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
status: s.status,
|
status: (s.status ?? "offline") as Server["status"],
|
||||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||||
sessions: 0,
|
sessions: 0,
|
||||||
}
|
}
|
||||||
@@ -94,7 +110,6 @@ interface RouteForm {
|
|||||||
|
|
||||||
interface RouteEndpointForm {
|
interface RouteEndpointForm {
|
||||||
id: string
|
id: string
|
||||||
disabled: boolean
|
|
||||||
gateway: string
|
gateway: string
|
||||||
distance: number
|
distance: number
|
||||||
scope: number | null
|
scope: number | null
|
||||||
@@ -105,7 +120,6 @@ interface RouteEndpointForm {
|
|||||||
|
|
||||||
const newEndpoint = (): RouteEndpointForm => ({
|
const newEndpoint = (): RouteEndpointForm => ({
|
||||||
id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
disabled: false,
|
|
||||||
gateway: "",
|
gateway: "",
|
||||||
distance: 1,
|
distance: 1,
|
||||||
scope: null,
|
scope: null,
|
||||||
@@ -133,7 +147,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
|
|||||||
<label className="text-sm font-medium">Страна</label>
|
<label className="text-sm font-medium">Страна</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input aria-label="Код страны" placeholder="Поиск или код (RU, DE…)" value={query}
|
<Input placeholder="Поиск или код (RU, DE…)" value={query}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
const v = e.target.value.toUpperCase().slice(0, 3)
|
const v = e.target.value.toUpperCase().slice(0, 3)
|
||||||
setQuery(v)
|
setQuery(v)
|
||||||
@@ -156,7 +170,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-1.5">
|
<div className="grid grid-cols-5 gap-1.5 max-h-40 overflow-y-auto pr-0.5">
|
||||||
{visible.map(c => (
|
{visible.map(c => (
|
||||||
<button key={c.code} type="button"
|
<button key={c.code} type="button"
|
||||||
onClick={() => { onChange(c.code); setQuery("") }}
|
onClick={() => { onChange(c.code); setQuery("") }}
|
||||||
@@ -177,129 +191,133 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RouteSheet({
|
function RouteSheet({
|
||||||
open, mode, initial, onSave, onClose, gateways, busy, saveError, returnFocus,
|
open, mode, initial, onSave, onClose, gateways,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
mode: "create" | "edit"
|
mode: "create" | "edit"
|
||||||
initial: RouteForm
|
initial: RouteForm
|
||||||
onSave: (v: RouteForm) => Promise<boolean>
|
onSave: (v: RouteForm) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
gateways: GatewayOption[]
|
gateways: GatewayOption[]
|
||||||
busy: boolean
|
|
||||||
saveError: string | null
|
|
||||||
returnFocus: () => HTMLElement | null
|
|
||||||
}) {
|
}) {
|
||||||
const [form, setForm] = useState<RouteForm>(initial)
|
const [form, setForm] = useState<RouteForm>(initial)
|
||||||
const [endpointPage, setEndpointPage] = useState(0)
|
|
||||||
const lastEndpointPage = Math.max(0, Math.ceil(form.endpoints.length / 10) - 1)
|
|
||||||
if (endpointPage > lastEndpointPage) setEndpointPage(lastEndpointPage)
|
|
||||||
const currentEndpointPage = Math.min(endpointPage, lastEndpointPage)
|
|
||||||
const endpointOffset = currentEndpointPage * 10
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => { setForm(initial); setError(null); setEndpointPage(0) }, [initial, open])
|
useEffect(() => { setForm(initial); setError(null) }, [initial, open])
|
||||||
const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
|
const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
|
||||||
const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) =>
|
const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) =>
|
||||||
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
|
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
|
||||||
|
|
||||||
const saveLock = useRef(false)
|
function handleSave() {
|
||||||
async function handleSave() {
|
|
||||||
if (busy || saveLock.current) return
|
|
||||||
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
|
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
|
||||||
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
|
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
|
||||||
const emptyGateway = form.endpoints.findIndex(ep => !ep.gateway.trim())
|
if (form.endpoints.some(ep => !ep.gateway.trim())) { setError("У каждой конечной точки должен быть Gateway"); return }
|
||||||
if (emptyGateway >= 0) { setEndpointPage(Math.floor(emptyGateway / 10)); setError(`У каждой конечной точки должен быть Gateway (Endpoint ${emptyGateway + 1})`); return }
|
|
||||||
if (!form.routingTable.trim()) { setError("Укажите таблицу маршрутизации"); return }
|
|
||||||
const invalidEndpoint = form.endpoints.findIndex(ep => [ep.distance, ep.scope, ep.targetScope].some(value => value !== null && (!Number.isInteger(value) || value < 0 || value > 255)))
|
|
||||||
if (invalidEndpoint >= 0) { setEndpointPage(Math.floor(invalidEndpoint / 10)); setError(`Distance, Scope и T.Scope должны быть целыми числами от 0 до 255 (Endpoint ${invalidEndpoint + 1})`); return }
|
|
||||||
setError(null)
|
setError(null)
|
||||||
saveLock.current = true
|
onSave(form)
|
||||||
try { await onSave(form) } finally { saveLock.current = false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={v => { if (!v && !busy && !saveLock.current) onClose() }}>
|
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||||||
<SheetContent side="right" finalFocus={returnFocus} className="data-[side=right]:w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
|
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
|
||||||
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
|
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
|
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
|
||||||
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
|
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="ghost" size="icon-sm" disabled={busy} aria-label="Закрыть форму маршрута" onClick={onClose} className="shrink-0 mt-0.5"><XIcon aria-hidden="true" className="size-4" /></Button>
|
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5"><XIcon className="size-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<fieldset disabled={busy} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Основные</SectionTitle>
|
<SectionTitle>Основные</SectionTitle>
|
||||||
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
|
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
|
||||||
<Input aria-label="Адрес назначения" className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
|
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Конечные точки</SectionTitle>
|
<SectionTitle>Конечные точки</SectionTitle>
|
||||||
{form.endpoints.length > 10 && <div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Input key={currentEndpointPage} type="number" min={1} max={form.endpoints.length} defaultValue={endpointOffset + 1} aria-label="Перейти к endpoint" aria-describedby="recursive-endpoint-jump-hint" className="w-24 min-h-11" onKeyDown={event => {
|
|
||||||
if (event.key !== "Enter") return
|
|
||||||
event.preventDefault()
|
|
||||||
const number = Number(event.currentTarget.value)
|
|
||||||
if (Number.isInteger(number) && number >= 1 && number <= form.endpoints.length) { setEndpointPage(Math.floor((number - 1) / 10)); setError(null) }
|
|
||||||
else setError("Укажите номер endpoint от 1 до " + form.endpoints.length)
|
|
||||||
}} />
|
|
||||||
<span id="recursive-endpoint-jump-hint" className="text-xs text-muted-foreground">Номер endpoint → Enter</span>
|
|
||||||
<span role="status" className="text-xs text-muted-foreground">Endpoints {endpointOffset + 1}–{Math.min(endpointOffset + 10, form.endpoints.length)} из {form.endpoints.length}</span>
|
|
||||||
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущие endpoints формы" disabled={currentEndpointPage === 0} onClick={() => setEndpointPage(currentEndpointPage - 1)}>Назад</Button>
|
|
||||||
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующие endpoints формы" disabled={currentEndpointPage === lastEndpointPage} onClick={() => setEndpointPage(currentEndpointPage + 1)}>Далее</Button>
|
|
||||||
</div>}
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{form.endpoints.slice(endpointOffset, endpointOffset + 10).map((ep, idx) => (
|
{form.endpoints.map((ep, idx) => (
|
||||||
<div key={ep.id} data-recursive-endpoint-form={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
<div key={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
Endpoint {endpointOffset + idx + 1}{ep.disabled ? " · отключён" : ""}
|
Endpoint {idx + 1}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
|
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
|
||||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive transition-colors"
|
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||||
aria-label={`Удалить конечную точку ${endpointOffset + idx + 1}`}
|
|
||||||
disabled={form.endpoints.length <= 1}
|
disabled={form.endpoints.length <= 1}
|
||||||
>
|
>
|
||||||
<TrashIcon aria-hidden="true" className="size-3.5" />
|
<TrashIcon className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
|
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
|
||||||
|
|
||||||
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
|
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
|
||||||
<Input aria-label="Шлюз" className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
|
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<FormField label="Distance (приоритет)">
|
<FormField label="Distance (приоритет)">
|
||||||
<Input aria-label="Distance (приоритет)" type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value))} />
|
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Check Gateway">
|
<FormField label="Check Gateway">
|
||||||
<Input aria-label="Check Gateway" className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<FormField label="Scope">
|
<FormField label="Scope">
|
||||||
<Input aria-label="Scope" type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
|
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="T.Scope">
|
<FormField label="T.Scope">
|
||||||
<Input aria-label="T.Scope" type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
|
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RecursiveGatewayChoices gateways={gateways} selectedValue={ep.gateway} endpointNumber={endpointOffset + idx + 1} onChange={value => setEp(ep.id, "gateway", value)} />
|
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
|
||||||
|
{gateways.map((g) => {
|
||||||
|
const value = `${g.ip}%${g.name}`
|
||||||
|
const selected = ep.gateway === value
|
||||||
|
const country = inferCountry(g.name)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${ep.id}-${g.id}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEp(ep.id, "gateway", value)}
|
||||||
|
className={cn(
|
||||||
|
"text-left rounded-lg border px-3 py-2.5 transition-all",
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
||||||
|
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn("size-1.5 rounded-full shrink-0", g.status === "up" ? "bg-emerald-500" : "bg-red-500")} />
|
||||||
|
{country && <Flag code={country} className="shrink-0" />}
|
||||||
|
<span className="font-mono text-xs font-semibold flex-1 truncate">{g.name}</span>
|
||||||
|
{selected && <CheckIcon className="size-3.5 text-primary shrink-0" />}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono">
|
||||||
|
<span className="text-muted-foreground/50 break-all">{g.ip}</span>
|
||||||
|
<span className="text-muted-foreground/30">→</span>
|
||||||
|
<span className={cn("min-w-0 break-all whitespace-normal leading-tight", selected ? "text-primary font-medium" : "")}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => { setEndpointPage(Math.floor(form.endpoints.length / 10)); setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] })) }}>
|
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}>
|
||||||
<PlusIcon aria-hidden="true" className="size-3.5" />Добавить endpoint
|
<PlusIcon className="size-3.5" />Добавить endpoint
|
||||||
</Button>
|
</Button>
|
||||||
{gateways.length === 0 && (
|
{gateways.length === 0 && (
|
||||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||||
@@ -311,18 +329,16 @@ function RouteSheet({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Параметры</SectionTitle>
|
<SectionTitle>Параметры</SectionTitle>
|
||||||
<FormField label="Routing Table"><Input aria-label="Таблица маршрутизации" className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
|
<FormField label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
|
||||||
<FormField label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input aria-label="Комментарий" className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
{saveError && <p role="alert" className="text-sm text-destructive">{saveError}</p>}
|
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
|
||||||
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения роутера…</p>}
|
</div>
|
||||||
{error && <div role="alert" className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon aria-hidden="true" className="size-4 shrink-0" />{error}</div>}
|
|
||||||
</fieldset>
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
|
||||||
<Button type="button" variant="outline" disabled={busy} onClick={onClose} className="h-8 flex-none">Отмена</Button>
|
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
|
||||||
<Button type="button" disabled={busy} onClick={() => void handleSave()} className="h-8 flex-none"><CheckIcon aria-hidden="true" className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
|
<Button onClick={handleSave} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
@@ -331,81 +347,42 @@ function RouteSheet({
|
|||||||
|
|
||||||
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||||||
return (
|
return (
|
||||||
<Badge size="xs" className="shrink-0 text-foreground"
|
<span className={cn(
|
||||||
variant={type === "home-router" ? "success-light" : type === "jump-host" ? "primary-light" : "info-light"}>
|
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||||
|
type === "home-router" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20" :
|
||||||
|
type === "jump-host"
|
||||||
|
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||||
|
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||||
|
)}>
|
||||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||||
</Badge>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecursiveRoutesPage() {
|
export default function RecursiveRoutesPage() {
|
||||||
return <Suspense fallback={<p role="status" className="p-6 text-sm text-muted-foreground">Загрузка маршрутов…</p>}><RecursiveRoutesRoute /></Suspense>
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecursiveRoutesRoute() {
|
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const params = useSearchParams(), context = readRecursiveUrl(new URLSearchParams(params.toString()))
|
|
||||||
const sourceKey = JSON.stringify([mode, backendUrl])
|
|
||||||
const pendingSources = useRef(new Set<string>())
|
|
||||||
const [pending, setPending] = useState<string[]>([])
|
|
||||||
const [readVersions, setReadVersions] = useState<Record<string, number>>({})
|
|
||||||
const beginWrite = useCallback(() => {
|
|
||||||
if (pendingSources.current.has(sourceKey)) return false
|
|
||||||
pendingSources.current.add(sourceKey); setPending([...pendingSources.current]); return true
|
|
||||||
}, [sourceKey])
|
|
||||||
const endWrite = useCallback(() => {
|
|
||||||
pendingSources.current.delete(sourceKey); setPending([...pendingSources.current])
|
|
||||||
setReadVersions(versions => ({ ...versions, [sourceKey]: (versions[sourceKey] ?? 0) + 1 }))
|
|
||||||
}, [sourceKey])
|
|
||||||
const patchUrl = useCallback((patch: RecursiveUrlPatch, replace = false) => {
|
|
||||||
const url = new URL(window.location.href); url.search = patchRecursiveUrl(url.searchParams, patch).toString()
|
|
||||||
const target = url.pathname + url.search + url.hash
|
|
||||||
if (replace) window.history.replaceState(null, "", target); else window.history.pushState(null, "", target)
|
|
||||||
}, [])
|
|
||||||
return <RecursiveRoutesContent key={JSON.stringify([mode, backendUrl, context.serverId, context.error])} mode={mode} backendUrl={backendUrl} context={context} patchUrl={patchUrl} beginWrite={beginWrite} endWrite={endWrite} sharedBusy={pending.includes(sourceKey)} readVersion={readVersions[sourceKey] ?? 0} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrite, endWrite, sharedBusy, readVersion }: {
|
|
||||||
beginWrite: () => boolean; endWrite: () => void; sharedBusy: boolean; readVersion: number
|
|
||||||
mode: string; backendUrl: string; context: RecursiveUrlState; patchUrl: (patch: RecursiveUrlPatch, replace?: boolean) => void
|
|
||||||
}) {
|
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [servers, setServers] = useState<Array<Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] }>>([])
|
const [servers, setServers] = useState<Server[]>([])
|
||||||
const [selectedServerId, setSelectedServerId] = useState<string>(context.serverId ?? "")
|
const [selectedServerId, setSelectedServerId] = useState<string>("")
|
||||||
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||||||
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
|
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
|
||||||
const search = context.q
|
const [search, setSearch] = useState("")
|
||||||
const setSearch = (q: string) => patchUrl({ q }, true)
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||||
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
|
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
|
||||||
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
|
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
|
||||||
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
||||||
const expandedGroupKey = context.route && context.table ? JSON.stringify([context.route.toLowerCase(), context.table]) : null
|
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
|
||||||
const [opError, setOpError] = useState<string | null>(null)
|
const [opError, setOpError] = useState<string | null>(null)
|
||||||
const [liveStale, setLiveStale] = useState(true)
|
const [liveStale, setLiveStale] = useState(false)
|
||||||
const [routeOwner, setRouteOwner] = useState("")
|
|
||||||
const [readAt, setReadAt] = useState<string | null>(null)
|
|
||||||
const [writeAt, setWriteAt] = useState<string | null>(null)
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
|
||||||
const [gatewayError, setGatewayError] = useState<string | null>(null)
|
|
||||||
const historyEpoch = useRef(0)
|
|
||||||
const alive = useRef(false)
|
|
||||||
const readEpoch = useRef(0), gatewayEpoch = useRef(0), writeLock = useRef(false)
|
|
||||||
useEffect(() => {
|
|
||||||
alive.current = true
|
|
||||||
return () => { alive.current = false }
|
|
||||||
}, [])
|
|
||||||
const [historyOpen, setHistoryOpen] = useState(false)
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
const [historyLoading, setHistoryLoading] = useState(false)
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
|
||||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
|
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
|
||||||
const [liveServerListReady, setLiveServerListReady] = useState(false)
|
const [liveServerListReady, setLiveServerListReady] = useState(false)
|
||||||
const [inventoryTick, setInventoryTick] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
@@ -416,175 +393,138 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
|
|||||||
setRows([])
|
setRows([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setLiveServerListReady(false)
|
setLiveServerListReady(false)
|
||||||
let active = true
|
apiFetch<BackendServer[]>("/api/servers")
|
||||||
apiFetch<unknown>("/api/servers")
|
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!active) return
|
const mapped = data.map(mapBackendServer)
|
||||||
const parsed = recursiveServersSchema.safeParse(data)
|
|
||||||
if (!parsed.success) throw new Error("Некорректный список устройств в ответе API")
|
|
||||||
const mapped = parsed.data.map(s => mapBackendServer({ ...s, type: s.type, site: s.site ?? "", asn: s.asn ?? "", status: s.status ?? null, latency: s.latency ?? null }))
|
|
||||||
setServers(mapped)
|
setServers(mapped)
|
||||||
setSelectedServerId(context.serverId ?? mapped[0]?.id ?? "")
|
setSelectedServerId(prev => (mapped.some(s => s.id === prev) ? prev : (mapped[0]?.id ?? "")))
|
||||||
if (!context.serverId && !context.error && mapped[0]) patchUrl({ serverId: mapped[0].id }, true)
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(() => {
|
||||||
if (!active) return
|
|
||||||
setOpError(error instanceof Error ? error.message : "Не удалось загрузить устройства")
|
|
||||||
setServers([])
|
setServers([])
|
||||||
setSelectedServerId("")
|
setSelectedServerId("")
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (active) setLiveServerListReady(true)
|
setLiveServerListReady(true)
|
||||||
})
|
})
|
||||||
return () => { active = false }
|
}, [isLive, apiFetch])
|
||||||
}, [isLive, apiFetch, inventoryTick, context.serverId, context.error, patchUrl])
|
|
||||||
|
|
||||||
const loadRoutes = useCallback(async () => {
|
const loadRoutes = useCallback(async () => {
|
||||||
if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId) || writeLock.current) return
|
if (!isLive || !liveServerListReady || !selectedServerId) return
|
||||||
const epoch = ++readEpoch.current
|
setOpError(null)
|
||||||
setOpError(null); setBusy("load"); setLiveStale(true)
|
setBusy("load")
|
||||||
try {
|
try {
|
||||||
const parsed = recursiveReadSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
|
const res = await apiFetch<{ routes: RecursiveRouteRow[]; stale?: boolean }>(
|
||||||
if (!parsed.success) throw new Error("Некорректный снимок маршрутов в ответе API")
|
`/api/recursive-routes?serverId=${selectedServerId}`,
|
||||||
const res = parsed.data
|
)
|
||||||
if (String(res.serverId) !== selectedServerId) throw new Error("Маршруты принадлежат другому устройству")
|
setRows(res.routes)
|
||||||
if (!alive.current || epoch !== readEpoch.current) return
|
setLiveStale(Boolean(res.stale))
|
||||||
setRows(res.routes); setRouteOwner(selectedServerId); setLiveStale(res.stale)
|
} catch (e) {
|
||||||
if (res.live) setReadAt(new Date().toISOString())
|
setLiveStale(true)
|
||||||
else setOpError(res.error ?? "Роутер недоступен")
|
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
|
||||||
} catch (error) {
|
|
||||||
if (!alive.current || epoch !== readEpoch.current) return
|
|
||||||
setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось загрузить маршруты")
|
|
||||||
} finally {
|
} finally {
|
||||||
if (alive.current && epoch === readEpoch.current) setBusy(null)
|
setBusy(null)
|
||||||
}
|
}
|
||||||
}, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
|
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
|
||||||
|
|
||||||
const loadGateways = useCallback(async () => {
|
const loadGateways = useCallback(async () => {
|
||||||
if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId)) return
|
if (!isLive || !liveServerListReady || !selectedServerId) return
|
||||||
const epoch = ++gatewayEpoch.current
|
|
||||||
try {
|
try {
|
||||||
const parsed = recursiveGatewaysSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/gateways/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
|
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`)
|
||||||
if (!parsed.success) throw new Error("Некорректный список шлюзов в ответе API")
|
setGatewayOptions(res.gateways)
|
||||||
const res = parsed.data
|
} catch {
|
||||||
if (String(res.serverId) !== selectedServerId) throw new Error("Шлюзы принадлежат другому устройству")
|
setGatewayOptions([])
|
||||||
if (!alive.current || epoch !== gatewayEpoch.current) return
|
|
||||||
setGatewayOptions(res.gateways); setGatewayError(null)
|
|
||||||
} catch (error) {
|
|
||||||
if (!alive.current || epoch !== gatewayEpoch.current) return
|
|
||||||
setGatewayOptions([]); setGatewayError(error instanceof Error ? error.message : "Не удалось загрузить шлюзы")
|
|
||||||
}
|
}
|
||||||
}, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
|
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
void loadRoutes(); void loadGateways()
|
void loadRoutes()
|
||||||
const reads = readEpoch, gateways = gatewayEpoch
|
}, [loadRoutes])
|
||||||
return () => { reads.current++; gateways.current++ }
|
|
||||||
}, [loadRoutes, loadGateways, readVersion])
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
void loadGateways()
|
||||||
|
}, [loadGateways])
|
||||||
|
|
||||||
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
|
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
|
||||||
if (!isLive || !selectedServerId || liveStale || routeOwner !== selectedServerId || busy !== null || sharedBusy || writeLock.current || !can("mm:network:write")) return false
|
if (!isLive || !selectedServerId || liveStale) return
|
||||||
if (!beginWrite()) return false
|
const prev = rows
|
||||||
writeLock.current = true; readEpoch.current++
|
setRows(next)
|
||||||
setOpError(null); setSaveError(null); setBusy("apply")
|
setOpError(null)
|
||||||
|
setBusy("apply")
|
||||||
try {
|
try {
|
||||||
const raw = await apiFetch<unknown>("/api/recursive-routes", { method: "PUT", body: JSON.stringify({ serverId: selectedServerId, routes: next }) })
|
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>("/api/recursive-routes", {
|
||||||
const parsed = recursiveReceiptSchema.safeParse(raw)
|
method: "PUT",
|
||||||
if (!parsed.success || String(parsed.data.serverId) !== selectedServerId || parsed.data.pushedRoutes !== next.length) throw new Error("Сервер не подтвердил применение маршрутов")
|
body: JSON.stringify({ serverId: selectedServerId, routes: next }),
|
||||||
if (!alive.current) return false
|
})
|
||||||
setRows(parsed.data.routes); setLiveStale(false); setWriteAt(new Date().toISOString())
|
setRows(res.routes ?? next)
|
||||||
|
setLiveStale(false)
|
||||||
toast.success("Маршруты применены на роутер")
|
toast.success("Маршруты применены на роутер")
|
||||||
return true
|
} catch (e) {
|
||||||
} catch (error) {
|
setRows(prev)
|
||||||
if (!alive.current) return false
|
const msg = e instanceof Error ? e.message : "Не удалось применить маршруты на роутер"
|
||||||
const message = error instanceof Error ? error.message : "Не удалось применить маршруты на роутер"
|
setOpError(msg)
|
||||||
setOpError(message); setSaveError(message); setLiveStale(true); toast.error(message)
|
toast.error(msg)
|
||||||
return false
|
|
||||||
} finally {
|
} finally {
|
||||||
writeLock.current = false
|
setBusy(null)
|
||||||
endWrite()
|
|
||||||
if (alive.current) setBusy(null)
|
|
||||||
}
|
}
|
||||||
}, [isLive, selectedServerId, routeOwner, liveStale, busy, sharedBusy, apiFetch, beginWrite, endWrite])
|
}, [isLive, selectedServerId, liveStale, rows, apiFetch])
|
||||||
|
|
||||||
useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
|
|
||||||
|
|
||||||
const loadRevisions = useCallback(async () => {
|
const loadRevisions = useCallback(async () => {
|
||||||
if (!isLive || !selectedServerId) return
|
if (!isLive || !selectedServerId) return
|
||||||
const epoch = ++historyEpoch.current
|
|
||||||
setHistoryLoading(true)
|
setHistoryLoading(true)
|
||||||
setHistoryError(null)
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<unknown>(
|
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
|
||||||
`/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
|
`/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
|
||||||
)
|
)
|
||||||
if (epoch !== historyEpoch.current) return
|
setRevisions(res.revisions)
|
||||||
setRevisions(parseConfigRevisions(res, selectedServerId, "recursive-routes"))
|
} catch (e) {
|
||||||
} catch (error) {
|
toast.error(e instanceof Error ? e.message : "Не удалось загрузить историю")
|
||||||
if (epoch !== historyEpoch.current) return
|
setRevisions([])
|
||||||
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
|
|
||||||
} finally {
|
} finally {
|
||||||
if (epoch === historyEpoch.current) setHistoryLoading(false)
|
setHistoryLoading(false)
|
||||||
}
|
}
|
||||||
}, [isLive, selectedServerId, apiFetch])
|
}, [isLive, selectedServerId, apiFetch])
|
||||||
|
|
||||||
const restoreRevision = useCallback(async (id: string) => {
|
const restoreRevision = useCallback(async (id: string) => {
|
||||||
if (!isLive || !selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || writeLock.current || busy !== null || sharedBusy || !can("mm:network:write")) throw new Error("Восстановление сейчас недоступно")
|
if (!isLive || !selectedServerId) return
|
||||||
if (!beginWrite()) throw new Error("Другая операция ещё выполняется")
|
setHistoryRestoring(true)
|
||||||
writeLock.current = true; readEpoch.current++
|
|
||||||
setHistoryRestoring(true); setBusy("apply")
|
|
||||||
try {
|
try {
|
||||||
const raw = await apiFetch<unknown>(`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`, { method: "POST", body: JSON.stringify({ serverId: selectedServerId }) })
|
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>(
|
||||||
const parsed = recursiveReceiptSchema.safeParse(raw)
|
`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`,
|
||||||
if (!parsed.success || String(parsed.data.serverId) !== selectedServerId) throw new Error("Сервер не подтвердил восстановление")
|
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
|
||||||
if (!alive.current) return
|
)
|
||||||
setRows(parsed.data.routes); setRouteOwner(selectedServerId); setLiveStale(false); setWriteAt(new Date().toISOString())
|
setRows(res.routes ?? [])
|
||||||
toast.success("Версия применена на роутер"); await loadRevisions()
|
setLiveStale(false)
|
||||||
} catch (error) {
|
toast.success("Версия применена на роутер")
|
||||||
if (alive.current) { setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось восстановить маршруты") }
|
await loadRevisions()
|
||||||
throw error
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось откатить")
|
||||||
} finally {
|
} finally {
|
||||||
writeLock.current = false
|
setHistoryRestoring(false)
|
||||||
endWrite()
|
|
||||||
if (alive.current) { setHistoryRestoring(false); setBusy(null) }
|
|
||||||
}
|
}
|
||||||
}, [isLive, selectedServerId, servers, busy, sharedBusy, apiFetch, loadRevisions, beginWrite, endWrite])
|
}, [isLive, selectedServerId, apiFetch, loadRevisions])
|
||||||
|
|
||||||
function groupKeyOf(row: RecursiveRouteRow): string {
|
function groupKeyOf(row: RecursiveRouteRow): string {
|
||||||
return JSON.stringify([row.dstAddress.trim().toLowerCase(), row.routingTable.trim()])
|
return row.dstAddress.trim().toLowerCase()
|
||||||
}
|
|
||||||
const sheetTrigger = useRef<HTMLElement | null>(null)
|
|
||||||
function returnSheetFocus() {
|
|
||||||
const target = sheetTrigger.current
|
|
||||||
if (target?.isConnected) return target
|
|
||||||
const label = target?.getAttribute("aria-label")
|
|
||||||
return label ? document.querySelector<HTMLElement>(`button[aria-label="${CSS.escape(label)}"]`) : null
|
|
||||||
}
|
}
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
if (mutationsLocked) return
|
|
||||||
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
||||||
setSaveError(null)
|
|
||||||
setSheetMode("create")
|
setSheetMode("create")
|
||||||
setEditingGroupKey(null)
|
setEditingGroupKey(null)
|
||||||
setSheetInitial(emptyForm())
|
setSheetInitial(emptyForm())
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
function openEdit(group: RouteGroup) {
|
function openEdit(group: RouteGroup) {
|
||||||
if (mutationsLocked) return
|
|
||||||
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
||||||
setSaveError(null)
|
|
||||||
const endpoints = rows.filter(row => groupKeyOf(row) === group.key)
|
|
||||||
setSheetMode("edit")
|
setSheetMode("edit")
|
||||||
setEditingGroupKey(group.key)
|
setEditingGroupKey(group.key)
|
||||||
setSheetInitial({
|
setSheetInitial({
|
||||||
dstAddress: group.dstAddress,
|
dstAddress: group.dstAddress,
|
||||||
routingTable: group.routingTable,
|
routingTable: group.routingTable,
|
||||||
comment: group.comment,
|
comment: group.comment,
|
||||||
endpoints: endpoints.map(ep => ({
|
endpoints: group.endpoints.map(ep => ({
|
||||||
id: ep.id,
|
id: ep.id,
|
||||||
disabled: ep.disabled,
|
|
||||||
gateway: ep.gateway,
|
gateway: ep.gateway,
|
||||||
distance: ep.distance,
|
distance: ep.distance,
|
||||||
scope: ep.scope,
|
scope: ep.scope,
|
||||||
@@ -595,7 +535,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
|
|||||||
})
|
})
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
async function handleSaveSheet(v: RouteForm) {
|
function handleSaveSheet(v: RouteForm) {
|
||||||
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
|
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
|
||||||
id,
|
id,
|
||||||
dstAddress: v.dstAddress,
|
dstAddress: v.dstAddress,
|
||||||
@@ -606,8 +546,8 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
|
|||||||
routingTable: v.routingTable,
|
routingTable: v.routingTable,
|
||||||
checkGateway: ep.checkGateway,
|
checkGateway: ep.checkGateway,
|
||||||
comment: v.comment,
|
comment: v.comment,
|
||||||
disabled: ep.disabled,
|
disabled: false,
|
||||||
country: ep.country || "",
|
country: ep.country || inferCountry(ep.gateway) || "",
|
||||||
})
|
})
|
||||||
let next: RecursiveRouteRow[]
|
let next: RecursiveRouteRow[]
|
||||||
if (sheetMode === "create") {
|
if (sheetMode === "create") {
|
||||||
@@ -620,17 +560,15 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
|
|||||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||||
next = [...kept, ...expanded]
|
next = [...kept, ...expanded]
|
||||||
} else {
|
} else {
|
||||||
return false
|
setSheetOpen(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
const saved = await applyRoutes(next)
|
setSheetOpen(false)
|
||||||
if (saved && alive.current) setSheetOpen(false)
|
void applyRoutes(next)
|
||||||
return saved
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const contextError = context.error ?? (isLive && liveServerListReady && context.serverId && !servers.some(s => s.id === context.serverId) ? "Устройство из ссылки недоступно" : null)
|
|
||||||
const hasRouteSnapshot = routeOwner === selectedServerId && (!liveStale || rows.length > 0)
|
|
||||||
const currentServer = servers.find(s => s.id === selectedServerId)
|
const currentServer = servers.find(s => s.id === selectedServerId)
|
||||||
const mutationsLocked = Boolean(contextError) || !isLive || busy !== null || sharedBusy || liveStale || routeOwner !== selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || !can("mm:network:write")
|
const mutationsLocked = !isLive || busy !== null || liveStale
|
||||||
const rrRailItems = useMemo<ServerTileItem[]>(() => (
|
const rrRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
servers.map((s) => ({
|
servers.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -638,7 +576,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
|
|||||||
host: s.host,
|
host: s.host,
|
||||||
site: s.site,
|
site: s.site,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
status: s.status ?? undefined,
|
status: s.status,
|
||||||
type: s.type,
|
type: s.type,
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
}))
|
}))
|
||||||
@@ -678,15 +616,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
|
|||||||
<ServerRailLayout
|
<ServerRailLayout
|
||||||
items={rrRailItems}
|
items={rrRailItems}
|
||||||
selectedId={selectedServerId}
|
selectedId={selectedServerId}
|
||||||
onSelect={id => {
|
onSelect={setSelectedServerId}
|
||||||
if (writeLock.current || sharedBusy || id === selectedServerId) return
|
|
||||||
readEpoch.current++; gatewayEpoch.current++; historyEpoch.current++
|
|
||||||
setSelectedServerId(id); setRows([]); setRouteOwner(""); setLiveStale(true); setBusy(null)
|
|
||||||
setGatewayOptions([]); setGatewayError(null); setOpError(null); setSaveError(null)
|
|
||||||
setReadAt(null); setWriteAt(null); setSheetOpen(false); setHistoryOpen(false); setRevisions([]); setHistoryError(null)
|
|
||||||
patchUrl({ serverId: id, route: null, table: null })
|
|
||||||
}}
|
|
||||||
preserveSelection={true}
|
|
||||||
showAll={false}
|
showAll={false}
|
||||||
showCount={false}
|
showCount={false}
|
||||||
loading={isLive && !liveServerListReady}
|
loading={isLive && !liveServerListReady}
|
||||||
@@ -699,17 +629,17 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
|
|||||||
{isLive && (
|
{isLive && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="button" variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { if (!servers.length) setInventoryTick(t => t + 1); else { void loadRoutes(); void loadGateways() } }}
|
onClick={() => void loadRoutes()}
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
title="Прочитать маршруты с роутера"
|
title="Прочитать маршруты с роутера"
|
||||||
>
|
>
|
||||||
<RefreshCwIcon aria-hidden="true" className={cn("size-4", busy === "load" && "animate-spin")} />
|
<RefreshCwIcon className={cn("size-4", busy === "load" && "animate-spin")} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button" variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHistoryOpen(true)
|
setHistoryOpen(true)
|
||||||
@@ -717,13 +647,13 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
|
|||||||
}}
|
}}
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
>
|
>
|
||||||
<HistoryIcon aria-hidden="true" className="size-4" />
|
<HistoryIcon className="size-4" />
|
||||||
История
|
История
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button type="button" size="sm" onClick={openCreate} disabled={mutationsLocked}>
|
<Button size="sm" onClick={openCreate} disabled={mutationsLocked}>
|
||||||
<PlusIcon aria-hidden="true" className="size-4" />Добавить
|
<PlusIcon className="size-4" />Добавить
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -732,39 +662,32 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
|
|||||||
banner={
|
banner={
|
||||||
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
|
<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">
|
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||||
<SearchIcon aria-hidden="true" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||||
<Input aria-label="Поиск маршрутов" className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
||||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
{search && (
|
{search && (
|
||||||
<button type="button" aria-label="Сбросить поиск маршрутов" onClick={() => setSearch("")}
|
<button onClick={() => setSearch("")}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||||
<XIcon aria-hidden="true" className="size-3.5" />
|
<XIcon className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<span className="text-muted-foreground">Всего маршрутов</span>
|
<span className="text-muted-foreground">Всего маршрутов</span>
|
||||||
<span className="font-semibold tabular-nums">{hasRouteSnapshot ? rows.length : "—"}</span>
|
<span className="font-semibold tabular-nums">{rows.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground ml-auto">
|
<p className="text-xs text-muted-foreground ml-auto">
|
||||||
{!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
||||||
</p>
|
</p>
|
||||||
{busy === "load" && <p role="status" className="w-full text-xs text-muted-foreground">Обновление маршрутов…</p>}
|
{opError && (
|
||||||
{sharedBusy && busy !== "apply" && <p role="status" className="w-full text-sm text-muted-foreground">Операция предыдущего контекста ещё выполняется. Изменения заблокированы до ответа API.</p>}
|
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||||
{contextError && <p role="alert" className="w-full text-sm text-warning">{contextError}. Выберите устройство или сбросьте ссылку. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ serverId: null, q: null, route: null, table: null })}>Сбросить контекст маршрутов</Button></p>}
|
{opError}
|
||||||
{expandedGroupKey && !liveStale && !groupedRoutes.some(group => group.key === expandedGroupKey) && <p role="status" className="w-full text-sm text-muted-foreground">Маршрут из ссылки отсутствует в текущем срезе. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ route: null, table: null })}>Закрыть маршрут</Button></p>}
|
|
||||||
{(opError || saveError) && (
|
|
||||||
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2" role="alert">
|
|
||||||
{opError || saveError}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isLive && readAt && <p role="status" className="w-full text-xs text-muted-foreground">Успешный ответ получен {new Date(readAt).toLocaleTimeString("ru-RU")}. Время получения браузером, не измерения RouterOS.</p>}
|
{liveStale && (
|
||||||
{isLive && writeAt && <p role="status" className="w-full text-xs text-muted-foreground">Запись подтверждена {new Date(writeAt).toLocaleTimeString("ru-RU")}.</p>}
|
<div className="w-full text-xs text-amber-700 dark:text-amber-400 bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2 flex items-center gap-2">
|
||||||
{isLive && gatewayError && <p role="status" className="w-full text-xs text-warning">Шлюзы не загружены: {gatewayError}. Адрес можно ввести вручную.</p>}
|
<AlertTriangleIcon className="size-3.5 shrink-0" />
|
||||||
{isLive && liveStale && (
|
Роутер недоступен — показан кэш. Изменения заблокированы.
|
||||||
<div className="w-full text-xs text-warning bg-warning/10 border border-warning/20 rounded-md px-3 py-2 flex items-center gap-2">
|
|
||||||
<AlertTriangleIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
||||||
{routeOwner === selectedServerId ? "Последний снимок; актуальное состояние не подтверждено." : "Данные маршрутов пока неизвестны."} Изменения заблокированы.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -779,43 +702,38 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
|
|||||||
) : (
|
) : (
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
{currentServer && (
|
{currentServer && (
|
||||||
<div className="flex flex-wrap items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
|
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
|
||||||
{currentServer.status ? <StatusDot status={currentServer.status} pulse={currentServer.status === "online"} /> : <span className="text-xs text-muted-foreground">Состояние неизвестно</span>}
|
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} />
|
||||||
<Flag code={currentServer.country} size={16} />
|
<Flag code={currentServer.country} size={16} />
|
||||||
<span className="min-w-0 break-all font-mono text-sm font-semibold">{currentServer.name}</span>
|
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
|
||||||
{currentServer.type && <TypeChip type={currentServer.type} />}
|
<TypeChip type={currentServer.type} />
|
||||||
<code className="break-all text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
|
<code className="text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
|
||||||
<span className="text-xs text-muted-foreground">{currentServer.asn}</span>
|
<span className="text-xs text-muted-foreground">{currentServer.asn}</span>
|
||||||
{currentServer.latency !== null && (
|
{currentServer.latency !== null && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-xs font-mono",
|
"text-xs font-mono",
|
||||||
currentServer.latency > 60 ? "text-warning" : "text-muted-foreground",
|
currentServer.latency > 60 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground",
|
||||||
)}>{currentServer.latency}мс</span>
|
)}>{currentServer.latency}мс</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span>{hasRouteSnapshot ? rows.length + " маршрутов" : "Количество неизвестно"}</span>
|
<span>{rows.length} маршрутов</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RecursiveRoutesDataGrid
|
<RecursiveRoutesDataGrid
|
||||||
emptyTitle={liveStale ? "Маршруты пока неизвестны" : search ? "Нет маршрутов по текущему поиску" : undefined}
|
|
||||||
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
|
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
|
||||||
expandedKey={expandedGroupKey}
|
expandedKey={expandedGroupKey}
|
||||||
onExpandedChange={key => {
|
onExpandedChange={setExpandedGroupKey}
|
||||||
const group = groupedRoutes.find(group => group.key === key)
|
|
||||||
patchUrl({ route: group?.dstAddress ?? null, table: group?.routingTable ?? null })
|
|
||||||
}}
|
|
||||||
mutationsDisabled={mutationsLocked}
|
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={(g) => {
|
onDelete={(g) => {
|
||||||
if (mutationsLocked) return
|
if (mutationsLocked) return
|
||||||
void applyRoutes(rows.filter((r) => groupKeyOf(r) !== g.key))
|
void applyRoutes(rows.filter((r) => groupKeyOf(r) !== g.key))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={openCreate} disabled={mutationsLocked}
|
<button onClick={openCreate} disabled={mutationsLocked}
|
||||||
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t disabled:opacity-50">
|
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t disabled:opacity-50">
|
||||||
<PlusIcon aria-hidden="true" className="size-3.5" />
|
<PlusIcon className="size-3.5" />
|
||||||
Добавить маршрут
|
Добавить маршрут
|
||||||
</button>
|
</button>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
@@ -823,27 +741,21 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
|
|||||||
</ServerRailLayout>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<RouteSheet
|
<RouteSheet
|
||||||
returnFocus={returnSheetFocus}
|
|
||||||
busy={busy !== null}
|
|
||||||
saveError={saveError}
|
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
mode={sheetMode}
|
mode={sheetMode}
|
||||||
initial={sheetInitial}
|
initial={sheetInitial}
|
||||||
onSave={handleSaveSheet}
|
onSave={handleSaveSheet}
|
||||||
onClose={() => { if (!writeLock.current) setSheetOpen(false) }}
|
onClose={() => setSheetOpen(false)}
|
||||||
gateways={gatewayOptions}
|
gateways={gatewayOptions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfigHistorySheet
|
<ConfigHistorySheet
|
||||||
open={historyOpen}
|
open={historyOpen}
|
||||||
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
|
onOpenChange={setHistoryOpen}
|
||||||
loadError={historyError}
|
|
||||||
onRetry={() => { void loadRevisions() }}
|
|
||||||
title="История маршрутов"
|
title="История маршрутов"
|
||||||
itemLabel="маршрутов"
|
itemLabel="маршрутов"
|
||||||
revisions={revisions}
|
revisions={revisions}
|
||||||
loading={historyLoading}
|
loading={historyLoading}
|
||||||
restoreDisabled={!can("mm:network:write") || !currentServer?.enabled || (busy !== null || sharedBusy) && !historyRestoring}
|
|
||||||
restoring={historyRestoring}
|
restoring={historyRestoring}
|
||||||
onRestore={restoreRevision}
|
onRestore={restoreRevision}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-v
|
|||||||
import {
|
import {
|
||||||
formatCommitSubject,
|
formatCommitSubject,
|
||||||
getReleaseCommitPreview,
|
getReleaseCommitPreview,
|
||||||
|
RELEASE_COMMIT_PREVIEW_LIMIT,
|
||||||
} from "@/lib/release-commits"
|
} from "@/lib/release-commits"
|
||||||
import { readReleaseManifest } from "@/lib/release-manifest"
|
import { readReleaseManifest } from "@/lib/release-manifest"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -15,23 +16,23 @@ export const dynamic = "force-dynamic"
|
|||||||
|
|
||||||
function formatPublishedAt(value: string | null, isProdBuild: boolean): string {
|
function formatPublishedAt(value: string | null, isProdBuild: boolean): string {
|
||||||
if (value) {
|
if (value) {
|
||||||
return "Дата манифеста: " + new Intl.DateTimeFormat("ru-RU", {
|
return new Intl.DateTimeFormat("ru-RU", {
|
||||||
dateStyle: "long",
|
dateStyle: "long",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
}).format(new Date(value))
|
}).format(new Date(value))
|
||||||
}
|
}
|
||||||
return isProdBuild ? "Дата манифеста не указана" : "Локальная сборка"
|
return isProdBuild ? "Прод-сборка" : "Локальная сборка"
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReleasesPage() {
|
export default function ReleasesPage() {
|
||||||
const manifest = readReleaseManifest()
|
const manifest = readReleaseManifest()
|
||||||
const version = getAppVersion()
|
const version = getAppVersion()
|
||||||
const isProdBuild = !version.endsWith("-dev")
|
const isProdBuild = !version.endsWith("-dev")
|
||||||
const releaseUrl = getReleaseUrl() || manifest?.releaseUrl || null
|
const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null
|
||||||
const commits = getReleaseCommitPreview(manifest?.commits ?? [])
|
const commits = getReleaseCommitPreview(manifest.commits)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Система" }, { label: "Релизы" }]}
|
crumbs={[{ label: "Система" }, { label: "Релизы" }]}
|
||||||
actions={
|
actions={
|
||||||
@@ -39,55 +40,53 @@ export default function ReleasesPage() {
|
|||||||
<Link
|
<Link
|
||||||
href={releaseUrl}
|
href={releaseUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noreferrer"
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="size-4" aria-hidden />
|
<ExternalLinkIcon className="size-4" />
|
||||||
Открыть релиз
|
Открыть в Gitea
|
||||||
</Link>
|
</Link>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="max-w-[960px] mx-auto space-y-6">
|
<div className="max-w-[960px] mx-auto space-y-6">
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="Текущая версия"
|
title="Текущая версия"
|
||||||
description={isProdBuild ? "Версия запущенной сборки MikrotikManager." : "Локальная разработка. Публикация релиза не подтверждена."}
|
description="Сборка MikrotikManager, опубликованная через CI/CD."
|
||||||
contentClassName="flex flex-col gap-3 px-5 pb-5"
|
contentClassName="flex flex-col gap-3 px-5 pb-5"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-2xl font-semibold tracking-tight break-all">{formatAppVersionLabel(version)}</span>
|
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span>
|
||||||
<span className="text-sm text-muted-foreground">{isProdBuild ? formatPublishedAt(manifest?.version === version ? manifest.publishedAt : null, true) : "Локальная сборка"}</span>
|
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt, isProdBuild)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
||||||
</p>
|
</p>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
{!manifest ? <p role="alert" className="text-sm text-destructive">Метаданные релиза недоступны или повреждены. Дата, ссылка и список коммитов не подтверждены.</p> : manifest.version !== version && isProdBuild ? <p role="status" className="text-sm text-muted-foreground">Манифест относится к {formatAppVersionLabel(manifest.version)}; версия запущенной сборки отличается.</p> : null}
|
|
||||||
|
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="Сводка коммитов"
|
title="Сводка коммитов"
|
||||||
description={
|
description={
|
||||||
commits.length > 0
|
commits.length > 0
|
||||||
? `Коммиты манифеста ${formatAppVersionLabel(manifest!.version)}: ${commits.length}.`
|
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
|
||||||
: manifest ? "В манифесте нет коммитов. При публикации список формируется CI." : "Список коммитов не подтверждён."
|
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."
|
||||||
}
|
}
|
||||||
contentClassName="px-5 pb-5"
|
contentClassName="px-5 pb-5"
|
||||||
>
|
>
|
||||||
{commits.length === 0 ? (
|
{commits.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{manifest ? "Коммитов для отображения пока нет." : "Нет подтверждённых метаданных."}</p>
|
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col gap-3">
|
<ul className="flex flex-col gap-3">
|
||||||
{commits.map((commit) => (
|
{commits.map((commit) => (
|
||||||
<li key={commit.sha} className="rounded-md border px-3 py-2 break-words">
|
<li key={commit.sha} className="rounded-md border px-3 py-2">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span className="font-mono uppercase">{commit.type}</span>
|
<span className="font-mono uppercase">{commit.type}</span>
|
||||||
<span className="font-mono">{commit.shortSha}</span>
|
<span className="font-mono">{commit.shortSha}</span>
|
||||||
<span>{commit.author}</span>
|
<span>{commit.author}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm break-words">{formatCommitSubject(commit.subject)}</p>
|
<p className="mt-1 text-sm">{formatCommitSubject(commit.subject)}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+317
-289
File diff suppressed because it is too large.
Load diff
+76
-262
@@ -1,21 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
|
||||||
import { can } from "@/lib/auth"
|
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { serverCreateSchema } from "@mmapp/contracts/servers"
|
|
||||||
import { OperationGate, pollServers, serverOperationError, type PollResult } from "@/lib/server-operations"
|
|
||||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog"
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
|
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
|
||||||
import { useServerUrlState } from "@/hooks/use-server-url-state"
|
import type { Filter } from "@/components/reui/filters"
|
||||||
import { ServerFilters } from "@/components/data-grids/server-filters"
|
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
|
||||||
import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
|
import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields"
|
||||||
import { SERVER_FILTER_ACCESSORS } from "@/lib/data-filters/server-filter-fields"
|
|
||||||
import { servers as initialServers } from "@/lib/data"
|
import { servers as initialServers } from "@/lib/data"
|
||||||
import type { ServerType, Server, WanUplink } from "@/lib/data"
|
import type { ServerType, Server, WanUplink } from "@/lib/data"
|
||||||
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
|
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
|
||||||
@@ -53,9 +45,9 @@ import {
|
|||||||
StepperSeparator,
|
StepperSeparator,
|
||||||
StepperTitle,
|
StepperTitle,
|
||||||
StepperTrigger,
|
StepperTrigger,
|
||||||
} from "@/components/reui-kit/accessible-stepper"
|
} from "@/components/reui/stepper"
|
||||||
import {
|
import {
|
||||||
RefreshCwIcon, PlusIcon, TrashIcon,
|
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||||
EyeIcon, EyeOffIcon,
|
EyeIcon, EyeOffIcon,
|
||||||
ChevronRightIcon, ChevronDownIcon,
|
ChevronRightIcon, ChevronDownIcon,
|
||||||
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||||
@@ -221,18 +213,15 @@ type TypeFilter = "all" | ServerType
|
|||||||
type SheetMode = "add" | "edit"
|
type SheetMode = "add" | "edit"
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
function ServersPageContent() {
|
export default function ServersPage() {
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const selectedServerId = searchParams.get("server")
|
|
||||||
const clearParams = new URLSearchParams(searchParams.toString())
|
|
||||||
clearParams.delete("server")
|
|
||||||
const allServersHref = "/servers" + (clearParams.size ? "?" + clearParams : "")
|
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const [serverList, setServerList] = useState<Server[]>([])
|
const [serverList, setServerList] = useState<Server[]>([])
|
||||||
const [listState, setListState] = useState<"loading" | "ready" | "error">("loading")
|
const [_backendOk, setBackendOk] = useState(false)
|
||||||
const { search, setSearch, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset: resetFilters } = useServerUrlState()
|
const [search, setSearch] = useState("")
|
||||||
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||||
|
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
@@ -242,92 +231,35 @@ function ServersPageContent() {
|
|||||||
const [testMsg, setTestMsg] = useState("")
|
const [testMsg, setTestMsg] = useState("")
|
||||||
const [pollingIds, setPollingIds] = useState<Set<string>>(new Set())
|
const [pollingIds, setPollingIds] = useState<Set<string>>(new Set())
|
||||||
const [pollAllBusy, setPollAllBusy] = useState(false)
|
const [pollAllBusy, setPollAllBusy] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [detailsLoading, setDetailsLoading] = useState(false)
|
|
||||||
const [detailsReady, setDetailsReady] = useState(true)
|
|
||||||
const [formError, setFormError] = useState("")
|
|
||||||
const [operationMessage, setOperationMessage] = useState("")
|
|
||||||
const [operationError, setOperationError] = useState("")
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Server | null>(null)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
const [deleteError, setDeleteError] = useState("")
|
|
||||||
const [pollResults, setPollResults] = useState<PollResult[]>([])
|
|
||||||
const [rowBusy, setRowBusy] = useState(false)
|
|
||||||
const gate = useRef(new OperationGate())
|
|
||||||
const formVersion = useRef(0)
|
|
||||||
const testVersion = useRef(0)
|
|
||||||
const contextVersion = useRef(0)
|
|
||||||
const canWrite = can("mm:servers:write")
|
|
||||||
const accessDenied = "Недостаточно прав: изменение и опрос серверов требуют разрешения на запись."
|
|
||||||
function requireWrite(report: (message: string) => void = setOperationError) {
|
|
||||||
if (can("mm:servers:write")) return true
|
|
||||||
report(accessDenied)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const controlsBusy = saving || rowBusy || pollAllBusy || pollingIds.size > 0
|
|
||||||
|
|
||||||
const set = <K extends keyof FormState>(k: K, v: FormState[K]) => {
|
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
|
||||||
if (gate.current.has("save") || detailsLoading) return
|
|
||||||
if (["host", "port", "proto", "verifySsl", "apiPath", "username", "password"].includes(k)) {
|
|
||||||
testVersion.current++
|
|
||||||
setTestState("idle"); setTestMsg("")
|
|
||||||
}
|
|
||||||
setForm((f) => ({ ...f, [k]: v }))
|
setForm((f) => ({ ...f, [k]: v }))
|
||||||
}
|
|
||||||
|
|
||||||
function closeForm(next: boolean) {
|
|
||||||
if (gate.current.has("save")) return
|
|
||||||
if (!next) { formVersion.current++; testVersion.current++ }
|
|
||||||
setOpen(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── load from backend when mode/url changes ───────────────────────────────
|
// ── load from backend when mode/url changes ───────────────────────────────
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const version = ++contextVersion.current
|
|
||||||
let cancelled = false
|
|
||||||
const invalidate = () => {
|
|
||||||
cancelled = true
|
|
||||||
contextVersion.current++
|
|
||||||
formVersion.current++
|
|
||||||
testVersion.current++
|
|
||||||
}
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (cancelled) return
|
|
||||||
formVersion.current++; testVersion.current++
|
|
||||||
setOpen(false); setDeleteTarget(null); setOperationError(""); setOperationMessage(""); setPollResults([])
|
|
||||||
setListState("loading")
|
|
||||||
})
|
|
||||||
if (mode !== "live") {
|
if (mode !== "live") {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (cancelled) return
|
|
||||||
setServerList(initialServers)
|
setServerList(initialServers)
|
||||||
setListState("ready")
|
setBackendOk(false)
|
||||||
})
|
})
|
||||||
return invalidate
|
return
|
||||||
}
|
}
|
||||||
listServers(backendUrl)
|
listServers(backendUrl)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (cancelled || version !== contextVersion.current) return
|
setBackendOk(true)
|
||||||
setListState("ready")
|
|
||||||
setServerList(data.map(toFrontendServer))
|
setServerList(data.map(toFrontendServer))
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(() => {
|
||||||
if (cancelled || version !== contextVersion.current) return
|
setBackendOk(false)
|
||||||
setOperationError(serverOperationError(error))
|
|
||||||
setListState("error")
|
|
||||||
setServerList([])
|
setServerList([])
|
||||||
})
|
})
|
||||||
return invalidate
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [mode, backendUrl])
|
}, [mode, backendUrl])
|
||||||
|
|
||||||
// ── actions ──────────────────────────────────────────────────────────────
|
// ── actions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function openAdd() {
|
function openAdd() {
|
||||||
if (!requireWrite()) return
|
|
||||||
if (gate.current.has("save")) return
|
|
||||||
formVersion.current++; testVersion.current++
|
|
||||||
setFormError(""); setDetailsLoading(false); setDetailsReady(true)
|
|
||||||
setSheetMode("add"); setEditingId(null)
|
setSheetMode("add"); setEditingId(null)
|
||||||
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
||||||
setSheetStep(1)
|
setSheetStep(1)
|
||||||
@@ -335,16 +267,11 @@ function ServersPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openEdit(s: Server) {
|
async function openEdit(s: Server) {
|
||||||
if (!requireWrite()) return
|
|
||||||
if (gate.current.has("save")) return
|
|
||||||
const version = ++formVersion.current
|
|
||||||
testVersion.current++
|
|
||||||
setFormError(""); setDetailsLoading(isLive); setDetailsReady(!isLive)
|
|
||||||
setSheetMode("edit"); setEditingId(s.id)
|
setSheetMode("edit"); setEditingId(s.id)
|
||||||
setForm({
|
setForm({
|
||||||
...defaultForm,
|
...defaultForm,
|
||||||
name: s.name, type: s.type, site: s.site, country: s.country ?? "RU",
|
name: s.name, type: s.type, site: s.site, country: s.country ?? "RU",
|
||||||
asn: s.asn ?? "", enabled: s.enabled, host: s.host,
|
asn: s.asn ?? "", enabled: s.status !== "offline", host: s.host,
|
||||||
comment: s.comment ?? "",
|
comment: s.comment ?? "",
|
||||||
lanSubnet: s.lanSubnet ?? "",
|
lanSubnet: s.lanSubnet ?? "",
|
||||||
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
||||||
@@ -355,11 +282,9 @@ function ServersPageContent() {
|
|||||||
if (isLive) {
|
if (isLive) {
|
||||||
try {
|
try {
|
||||||
const full = await getServer(backendUrl, s.id)
|
const full = await getServer(backendUrl, s.id)
|
||||||
if (version !== formVersion.current) return
|
|
||||||
setForm(prev => ({
|
setForm(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
username: full.username ?? "",
|
username: full.username ?? "",
|
||||||
enabled: full.enabled,
|
|
||||||
password: full.password ?? "",
|
password: full.password ?? "",
|
||||||
port: String(full.port ?? 443),
|
port: String(full.port ?? 443),
|
||||||
proto: full.useSsl ? "https" : "http",
|
proto: full.useSsl ? "https" : "http",
|
||||||
@@ -370,24 +295,16 @@ function ServersPageContent() {
|
|||||||
? JSON.parse(JSON.stringify(full.wanUplinks))
|
? JSON.parse(JSON.stringify(full.wanUplinks))
|
||||||
: prev.wanUplinks,
|
: prev.wanUplinks,
|
||||||
}))
|
}))
|
||||||
setDetailsReady(true)
|
} catch {
|
||||||
} catch (error) {
|
// ignore — user can fill in manually
|
||||||
if (version === formVersion.current) setFormError(serverOperationError(error))
|
|
||||||
} finally {
|
|
||||||
if (version === formVersion.current) setDetailsLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!requireWrite(setFormError)) return
|
|
||||||
if (!detailsReady || detailsLoading || !gate.current.acquire("save")) return
|
|
||||||
const context = contextVersion.current
|
|
||||||
setSaving(true); setFormError("")
|
|
||||||
try {
|
|
||||||
const payload: ServerCreate = {
|
const payload: ServerCreate = {
|
||||||
host: form.host,
|
host: form.host,
|
||||||
port: Number(form.port),
|
port: Number(form.port) || (form.proto === "https" ? 443 : 80),
|
||||||
username: form.username,
|
username: form.username,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
useSsl: form.proto === "https",
|
useSsl: form.proto === "https",
|
||||||
@@ -403,32 +320,18 @@ function ServersPageContent() {
|
|||||||
wanUplinks: form.type === "home-router" ? form.wanUplinks : [],
|
wanUplinks: form.type === "home-router" ? form.wanUplinks : [],
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.host.trim() || !form.username.trim() || !Number.isInteger(payload.port) || payload.port! > 65535 || payload.port! < 1) {
|
|
||||||
setFormError("Укажите хост, имя пользователя и порт от 1 до 65535.")
|
|
||||||
setSheetStep(3)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const validation = serverCreateSchema.safeParse(payload)
|
|
||||||
if (!validation.success) {
|
|
||||||
setFormError("Проверьте параметры сервера: " + validation.error.issues.map(i => i.path.join(".")).join(", "))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
try {
|
try {
|
||||||
if (sheetMode === "edit" && editingId) {
|
if (sheetMode === "edit" && editingId) {
|
||||||
const updatedPayload: ServerUpdate = payload
|
const updatedPayload: ServerUpdate = payload
|
||||||
const updated = await updateServer(backendUrl, editingId, updatedPayload)
|
const updated = await updateServer(backendUrl, editingId, updatedPayload)
|
||||||
if (context !== contextVersion.current) return
|
|
||||||
setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s))
|
setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s))
|
||||||
} else {
|
} else {
|
||||||
const created = await createServer(backendUrl, payload)
|
const created = await createServer(backendUrl, payload)
|
||||||
if (context !== contextVersion.current) return
|
|
||||||
setServerList(list => [...list, toFrontendServer(created)])
|
setServerList(list => [...list, toFrontendServer(created)])
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context === contextVersion.current) setFormError(serverOperationError(e))
|
console.error("Ошибка сохранения:", e)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// offline mode — update local state only
|
// offline mode — update local state only
|
||||||
@@ -446,7 +349,7 @@ function ServersPageContent() {
|
|||||||
comment: form.comment,
|
comment: form.comment,
|
||||||
lanSubnet: form.lanSubnet || undefined,
|
lanSubnet: form.lanSubnet || undefined,
|
||||||
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
||||||
enabled: form.enabled,
|
status: form.enabled ? (s.status === "offline" ? "online" : s.status) : "offline",
|
||||||
}
|
}
|
||||||
: s
|
: s
|
||||||
))
|
))
|
||||||
@@ -465,85 +368,57 @@ function ServersPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
formVersion.current++; testVersion.current++
|
|
||||||
setOperationMessage(isLive ? "Сервер сохранён." : "Сервер сохранён в демо-режиме.")
|
|
||||||
} finally {
|
|
||||||
gate.current.release("save"); setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleStatus(id: string) {
|
function handleToggleStatus(id: string) {
|
||||||
if (!requireWrite()) return
|
setServerList(list => list.map(s =>
|
||||||
const server = serverList.find(s => s.id === id)
|
s.id === id ? { ...s, status: s.status === "offline" ? "online" : "offline" } : s
|
||||||
if (!server || !gate.current.acquire("rows")) return
|
))
|
||||||
const context = contextVersion.current
|
|
||||||
setRowBusy(true); setOperationError(""); setOperationMessage("")
|
|
||||||
try {
|
|
||||||
let enabled = !server.enabled
|
|
||||||
if (isLive) enabled = (await updateServer(backendUrl, id, { enabled })).enabled
|
|
||||||
if (context !== contextVersion.current) return
|
|
||||||
setServerList(list => list.map(s => s.id === id ? { ...s, enabled } : s))
|
|
||||||
setOperationMessage(`${server.name}: ${enabled ? "включён" : "отключён"}${isLive ? "." : " (демо)."}`)
|
|
||||||
} catch (error) {
|
|
||||||
if (context === contextVersion.current) setOperationError(serverOperationError(error))
|
|
||||||
} finally { gate.current.release("rows"); setRowBusy(false) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!requireWrite(setDeleteError)) return
|
if (isLive) {
|
||||||
if (!gate.current.acquire("rows")) return
|
try {
|
||||||
const context = contextVersion.current
|
await deleteServer(backendUrl, id)
|
||||||
setDeleting(true); setRowBusy(true); setDeleteError("")
|
} catch (e) {
|
||||||
try {
|
console.error("Ошибка удаления:", e)
|
||||||
if (isLive) await deleteServer(backendUrl, id)
|
}
|
||||||
if (context !== contextVersion.current) return
|
}
|
||||||
setServerList(list => list.filter(s => s.id !== id))
|
setServerList(list => list.filter(s => s.id !== id))
|
||||||
setDeleteTarget(null); setOperationError(""); setOperationMessage("Сервер удалён из списка.")
|
|
||||||
} catch (error) {
|
|
||||||
if (context === contextVersion.current) setDeleteError(serverOperationError(error))
|
|
||||||
} finally { gate.current.release("rows"); setDeleting(false); setRowBusy(false) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePoll(id: string) {
|
async function handlePoll(id: string) {
|
||||||
await runPoll([id])
|
if (!isLive) return
|
||||||
|
setPollingIds(p => new Set(p).add(id))
|
||||||
|
try {
|
||||||
|
await pollServer(backendUrl, id)
|
||||||
|
// Reload full list to get updated status/version
|
||||||
|
const data = await listServers(backendUrl)
|
||||||
|
setServerList(data.map(toFrontendServer))
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Poll error:", e)
|
||||||
|
} finally {
|
||||||
|
setPollingIds(p => { const n = new Set(p); n.delete(id); return n })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePollAll() {
|
async function handlePollAll() {
|
||||||
await runPoll(serverList.map(s => s.id))
|
if (!isLive) return
|
||||||
}
|
setPollAllBusy(true)
|
||||||
|
|
||||||
async function runPoll(ids: string[]) {
|
|
||||||
if (!requireWrite()) return
|
|
||||||
if (!isLive || !gate.current.acquire("rows")) return
|
|
||||||
const context = contextVersion.current
|
|
||||||
setPollAllBusy(true); setPollingIds(new Set(ids)); setOperationError(""); setOperationMessage(""); setPollResults([])
|
|
||||||
try {
|
try {
|
||||||
const results = await pollServers(ids, async id => {
|
const ids = serverList.map(s => s.id)
|
||||||
if (context !== contextVersion.current) throw new Error("Контекст изменился; опрос отменён.")
|
await Promise.all(ids.map(id => pollServer(backendUrl, id).catch(() => {})))
|
||||||
if (!can("mm:servers:write")) throw new Error(accessDenied)
|
const data = await listServers(backendUrl)
|
||||||
await pollServer(backendUrl, id)
|
setServerList(data.map(toFrontendServer))
|
||||||
})
|
|
||||||
if (context !== contextVersion.current) return
|
|
||||||
setPollResults(results)
|
|
||||||
try {
|
|
||||||
const data = await listServers(backendUrl)
|
|
||||||
if (context === contextVersion.current) setServerList(data.map(toFrontendServer))
|
|
||||||
} catch (error) {
|
|
||||||
if (context === contextVersion.current) setOperationError("Опрос завершён, но список не обновлён. " + serverOperationError(error))
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
gate.current.release("rows"); setPollAllBusy(false); setPollingIds(new Set())
|
setPollAllBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
if (!requireWrite(setFormError)) return
|
|
||||||
if (!detailsReady || gate.current.has("save") || gate.current.has("test")) return
|
|
||||||
const version = ++testVersion.current
|
|
||||||
if (!form.host || !form.username) {
|
if (!form.host || !form.username) {
|
||||||
setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return
|
setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return
|
||||||
}
|
}
|
||||||
gate.current.acquire("test")
|
|
||||||
setTestState("testing"); setTestMsg("")
|
setTestState("testing"); setTestMsg("")
|
||||||
try {
|
try {
|
||||||
const data = await testServerConnection(backendUrl, {
|
const data = await testServerConnection(backendUrl, {
|
||||||
@@ -555,36 +430,28 @@ function ServersPageContent() {
|
|||||||
username: form.username,
|
username: form.username,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
})
|
})
|
||||||
if (version !== testVersion.current) return
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setTestState("ok"); setTestMsg(data.message)
|
setTestState("ok"); setTestMsg(data.message)
|
||||||
} else {
|
} else {
|
||||||
setTestState("error"); setTestMsg(data.message)
|
setTestState("error"); setTestMsg(data.message)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
if (version !== testVersion.current) return
|
|
||||||
setTestState("error")
|
setTestState("error")
|
||||||
setTestMsg(serverOperationError(error))
|
setTestMsg(`Не удалось связаться с бекендом — убедитесь, что он запущен на ${backendUrl}`)
|
||||||
} finally {
|
|
||||||
gate.current.release("test")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── derived ──────────────────────────────────────────────────────────────
|
// ── derived ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const selectedServer = serverList.find(server => server.id === selectedServerId)
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (urlError) return []
|
|
||||||
const base = serverList.filter(sv => {
|
const base = serverList.filter(sv => {
|
||||||
if (selectedServerId !== null && sv.id !== selectedServerId) return false
|
|
||||||
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
||||||
if (!search) return true
|
if (!search) return true
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
||||||
})
|
})
|
||||||
return applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS)
|
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS)
|
||||||
}, [serverList, search, typeFilter, advancedFilters, selectedServerId, urlError])
|
}, [serverList, search, typeFilter, advancedFilters])
|
||||||
|
|
||||||
const counts = useMemo(() => ({
|
const counts = useMemo(() => ({
|
||||||
all: serverList.length,
|
all: serverList.length,
|
||||||
@@ -611,10 +478,11 @@ function ServersPageContent() {
|
|||||||
crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
|
crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={() => selectedServerId !== null ? selectedServer && void runPoll([selectedServer.id]) : void handlePollAll()} disabled={!canWrite || !isLive || controlsBusy || (selectedServerId !== null ? !selectedServer : serverList.length === 0)}>
|
<Button variant="outline" size="sm" onClick={handlePollAll} disabled={!isLive || pollAllBusy}>
|
||||||
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />{selectedServerId !== null ? "Проверить сервер" : "Проверить все"}
|
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />Проверить все
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={openAdd} disabled={!canWrite || controlsBusy}><PlusIcon className="size-4" />Добавить сервер</Button>
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
|
<Button size="sm" onClick={openAdd}><PlusIcon className="size-4" />Добавить сервер</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -622,32 +490,6 @@ function ServersPageContent() {
|
|||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{!canWrite && <Alert variant="info" role="status"><AlertTitle>Только просмотр</AlertTitle><AlertDescription>Изменение, удаление, опрос серверов и тест соединения требуют разрешения на запись. Просмотр деталей и фильтры доступны.</AlertDescription></Alert>}
|
|
||||||
{operationError && <Alert variant="destructive" role="alert"><AlertTitle>Действие не завершено</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
|
|
||||||
{operationMessage && <Alert variant="success" role="status"><AlertDescription>{operationMessage}</AlertDescription></Alert>}
|
|
||||||
{pollResults.length > 0 && (
|
|
||||||
<Alert variant={pollResults.some(r => r.error) ? "warning" : "success"} role="status">
|
|
||||||
<AlertTitle>Опрос: успешно {pollResults.filter(r => !r.error).length} из {pollResults.length}</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<ul>{pollResults.map(r => <li key={r.id}>{serverList.find(s => s.id === r.id)?.name ?? r.id}: {r.error ?? "опрос выполнен"}</li>)}</ul>
|
|
||||||
{pollResults.some(r => r.error) && <Button type="button" variant="outline" size="sm" disabled={!canWrite || controlsBusy} onClick={() => runPoll(pollResults.filter(r => r.error).map(r => r.id))}>Повторить неуспешные</Button>}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedServerId !== null && (
|
|
||||||
<Alert variant={listState === "error" || (listState === "ready" && !selectedServer) ? "warning" : "info"} role="status" aria-label="Сервер по ссылке">
|
|
||||||
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedServer ? "Выбран сервер: " + selectedServer.name : "Сервер по ссылке"}</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<p>{listState === "loading" ? "Загрузка сервера…" : listState === "error" ? "Не удалось загрузить сервер. Проверьте сообщение об ошибке выше." : selectedServer ? selectedServer.host + " · Список ограничен выбранным сервером. Сводка относится ко всем серверам." : "Сервер не найден или недоступен. Возможно, он удалён или у вас нет доступа."}</p>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
<Link href={allServersHref} className="underline underline-offset-2">Все серверы</Link>
|
|
||||||
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
|
|
||||||
</div>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка серверов"
|
aria-label="Сводка серверов"
|
||||||
items={[
|
items={[
|
||||||
@@ -683,8 +525,6 @@ function ServersPageContent() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
{urlError && <Alert variant="warning" role="alert"><AlertTitle>Фильтры требуют внимания</AlertTitle><AlertDescription>{urlError}</AlertDescription></Alert>}
|
|
||||||
{hasFilters && <div><Button variant="outline" size="sm" onClick={resetFilters}>Сбросить фильтры списка</Button></div>}
|
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
segmented={{
|
segmented={{
|
||||||
@@ -696,7 +536,9 @@ function ServersPageContent() {
|
|||||||
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
||||||
})),
|
})),
|
||||||
}}
|
}}
|
||||||
filterControl={<ServerFilters query={advancedFilters} onQueryChange={setAdvancedFilters} />}
|
filters={advancedFilters}
|
||||||
|
onFiltersChange={setAdvancedFilters}
|
||||||
|
filterFields={SERVER_FILTER_FIELDS}
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по имени, хосту…"
|
searchPlaceholder="Поиск по имени, хосту…"
|
||||||
@@ -704,14 +546,11 @@ function ServersPageContent() {
|
|||||||
/>
|
/>
|
||||||
<ServersDataGrid
|
<ServersDataGrid
|
||||||
servers={filtered}
|
servers={filtered}
|
||||||
hasUnfilteredRows={serverList.length > 0}
|
|
||||||
isLive={isLive}
|
isLive={isLive}
|
||||||
pollingIds={pollingIds}
|
pollingIds={pollingIds}
|
||||||
busy={controlsBusy}
|
|
||||||
canWrite={canWrite}
|
|
||||||
onPoll={handlePoll}
|
onPoll={handlePoll}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={id => { if (!requireWrite()) return; setDeleteError(""); setDeleteTarget(serverList.find(s => s.id === id) ?? null) }}
|
onDelete={handleDelete}
|
||||||
onToggleStatus={handleToggleStatus}
|
onToggleStatus={handleToggleStatus}
|
||||||
/>
|
/>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
@@ -719,19 +558,13 @@ function ServersPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */}
|
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */}
|
||||||
<Sheet open={open} onOpenChange={closeForm}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
<SheetContent side="right" showCloseButton={!saving} className="data-[side=right]:w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>{sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"}</SheetTitle>
|
<SheetTitle>{sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"}</SheetTitle>
|
||||||
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
{detailsLoading && <p className="px-6 py-2 text-sm" role="status">Загрузка параметров сервера…</p>}
|
|
||||||
{formError && <div className="px-6 py-2"><Alert variant="destructive" role="alert"><AlertDescription>{formError}</AlertDescription></Alert>
|
|
||||||
{!detailsReady && !detailsLoading && editingId && <Button type="button" variant="outline" onClick={() => { const server = serverList.find(s => s.id === editingId); if (server) void openEdit(server) }}>Повторить загрузку</Button>}
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
<fieldset disabled={!canWrite || saving || !detailsReady} className="flex min-h-0 min-w-0 flex-1 flex-col" aria-busy={saving || detailsLoading}>
|
|
||||||
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
<StepperNav className="mb-5">
|
<StepperNav className="mb-5">
|
||||||
<StepperItem step={1}>
|
<StepperItem step={1}>
|
||||||
@@ -772,7 +605,6 @@ function ServersPageContent() {
|
|||||||
|
|
||||||
<FormField label="Тип узла" required>
|
<FormField label="Тип узла" required>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
className="max-w-full flex-wrap"
|
|
||||||
value={form.type}
|
value={form.type}
|
||||||
onChange={v => set("type", v)}
|
onChange={v => set("type", v)}
|
||||||
options={[
|
options={[
|
||||||
@@ -893,7 +725,7 @@ function ServersPageContent() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
|
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
|
||||||
disabled={!canWrite || testState === "testing"} onClick={handleTest}>
|
disabled={testState === "testing"} onClick={handleTest}>
|
||||||
{testState === "testing"
|
{testState === "testing"
|
||||||
? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</>
|
? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</>
|
||||||
: <><WifiIcon className="size-4" />Проверить подключение</>}
|
: <><WifiIcon className="size-4" />Проверить подключение</>}
|
||||||
@@ -944,40 +776,22 @@ function ServersPageContent() {
|
|||||||
</StepperContent>
|
</StepperContent>
|
||||||
</StepperPanel>
|
</StepperPanel>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
<SheetClose render={<Button variant="outline" disabled={saving} />}>Отмена</SheetClose>
|
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
|
||||||
{sheetStep > 1 && (
|
{sheetStep > 1 && (
|
||||||
<Button variant="outline" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||||
)}
|
)}
|
||||||
{sheetStep < 4 ? (
|
{sheetStep < 4 ? (
|
||||||
<Button className="ml-auto" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button className="ml-auto" disabled={!canWrite || saving || !detailsReady || testState === "testing"} onClick={handleSave}>
|
<Button className="ml-auto" onClick={handleSave}>
|
||||||
{saving ? "Сохранение…" : sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
<AlertDialog open={deleteTarget !== null} onOpenChange={next => { if (!next && !gate.current.has("rows")) setDeleteTarget(null) }}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Удалить сервер?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>Сервер «{deleteTarget?.name}» ({deleteTarget?.host}) будет удалён из списка управления.</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<Button type="button" variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>Отмена</Button>
|
|
||||||
<Button type="button" variant="destructive" disabled={!canWrite || deleting} onClick={() => { if (deleteTarget) void handleDelete(deleteTarget.id) }}>{deleting ? "Удаление…" : "Удалить"}</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ServersPage() {
|
|
||||||
return <Suspense fallback={<p role="status" className="p-6">Загрузка серверов…</p>}><ServersPageContent /></Suspense>
|
|
||||||
}
|
|
||||||
+805
-49
@@ -1,44 +1,291 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { useTheme } from "@/components/theme-provider"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { SystemDatabaseSection } from "@/components/settings/system-database-section"
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { EvoBgpSettingsSection } from "@/components/settings/evobgp-settings-section"
|
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { useEvoBGP, type EvoBgpSavePayload, type EvoBgpTestDraft } from "@/lib/evobgp-context"
|
||||||
|
import { DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS } from "@/lib/route-optimizer-data"
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
AlertDialog,
|
||||||
AlertCircleIcon,
|
AlertDialogAction,
|
||||||
XIcon,
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import {
|
||||||
|
SaveIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
|
||||||
|
AlertCircleIcon, EyeIcon, EyeOffIcon,
|
||||||
|
RefreshCwIcon, LoaderCircleIcon,
|
||||||
|
DownloadIcon, UploadIcon, XIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
|
||||||
|
import { purgeTrafficFlowData } from "@/shared/api/traffic-flow"
|
||||||
|
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
|
||||||
|
|
||||||
|
|
||||||
|
const INIT_API_KEYS: ApiKey[] = [
|
||||||
|
{ id: "k1", name: "Monitoring Script", prefix: "rl_live_mU9k2…", created: "12 дн назад", last: "2м назад", scopes: ["servers:read", "probes:read"] },
|
||||||
|
{ id: "k2", name: "CI/CD deploy hook", prefix: "rl_live_vB3p8…", created: "1 мес назад", last: "вчера", scopes: ["filters:write", "domains:write"] },
|
||||||
|
{ id: "k3", name: "Grafana datasource", prefix: "rl_live_xK7r1…", created: "2 мес назад", last: "сейчас", scopes: ["traffic:read", "servers:read"] },
|
||||||
|
]
|
||||||
|
|
||||||
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const
|
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const
|
||||||
type NavSection = typeof SECTIONS_NAV[number]
|
type NavSection = typeof SECTIONS_NAV[number]
|
||||||
|
|
||||||
|
function makeApiFetch(backendUrl: string) {
|
||||||
|
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
return requestJson<T>(backendUrl, path, init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-start sm:flex-row sm:items-center sm:justify-between gap-3 py-3.5">
|
<div className="flex items-center justify-between gap-4 py-3.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium">{label}</p>
|
<p className="text-sm font-medium">{label}</p>
|
||||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 max-w-full">{children}</div>
|
<div className="shrink-0">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DatabaseRestoreConfirm({
|
||||||
|
open,
|
||||||
|
filename,
|
||||||
|
busy,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
filename: string
|
||||||
|
busy?: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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}. Текущие данные PostgreSQL на бекенде будут полностью заменены.
|
||||||
|
Рекомендуется сначала скачать актуальный бэкап.
|
||||||
|
</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 ? "Восстановление…" : "Восстановить"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [section, setSection] = useState<NavSection>("Общие")
|
const [section, setSection] = useState<NavSection>("Общие")
|
||||||
const { mode, setMode, backendUrl, backendUrlLocked, mockModeAvailable, backendStatus, backendChecking, checkBackend } = useDataSource()
|
const [saved, setSaved] = useState(false)
|
||||||
const { theme, setTheme } = useTheme()
|
const [copied, setCopied] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// data source
|
||||||
|
const { mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, mockModeAvailable, backendStatus, checkBackend } = useDataSource()
|
||||||
|
const evo = useEvoBGP()
|
||||||
|
const [evoBaseDraft, setEvoBaseDraft] = useState("")
|
||||||
|
const [evoEnabledDraft, setEvoEnabledDraft] = useState(false)
|
||||||
|
const [evoKeyDraft, setEvoKeyDraft] = useState("")
|
||||||
|
const [evoSaveBusy, setEvoSaveBusy] = useState(false)
|
||||||
|
const [evoSaveErr, setEvoSaveErr] = useState<string | null>(null)
|
||||||
|
const [urlDraft, setUrlDraft] = useState(backendUrl)
|
||||||
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUrlDraft(backendUrl)
|
||||||
|
}, [backendUrl])
|
||||||
|
|
||||||
|
const commitBackendUrl = useCallback(() => {
|
||||||
|
const normalized = urlDraft.trim().replace(/\/$/, "")
|
||||||
|
setUrlDraft(normalized)
|
||||||
|
setBackendUrl(normalized)
|
||||||
|
void checkBackend()
|
||||||
|
}, [urlDraft, setBackendUrl, checkBackend])
|
||||||
|
const [evoTestResult, setEvoTestResult] = useState<{ ok: boolean; message: string } | null>(null)
|
||||||
|
const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
|
||||||
|
const [showEvoKey, setShowEvoKey] = useState(false)
|
||||||
|
|
||||||
|
// general
|
||||||
|
const [lang, setLang] = useState("ru")
|
||||||
|
const [theme, setTheme] = useState("system")
|
||||||
|
const [timezone, setTimezone] = useState("Europe/Moscow")
|
||||||
|
const [refreshSec, setRefreshSec] = useState("30")
|
||||||
|
const [probeTimeout, setProbeTimeout] = useState("5")
|
||||||
|
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
||||||
|
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
||||||
|
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
|
||||||
|
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
|
||||||
|
const [dbPurgeOpen, setDbPurgeOpen] = useState(false)
|
||||||
|
const [dbPurgeBusy, setDbPurgeBusy] = useState(false)
|
||||||
|
|
||||||
|
// notifications
|
||||||
|
const [notifEmail, setNotifEmail] = useState(true)
|
||||||
|
const [notifSlack, setNotifSlack] = useState(false)
|
||||||
|
const [notifWh, setNotifWh] = useState(true)
|
||||||
|
const [notifDegr, setNotifDegr] = useState(true)
|
||||||
|
const [notifOffline, setNotifOffline] = useState(true)
|
||||||
|
const [notifBgp, setNotifBgp] = useState(true)
|
||||||
|
const [notifBackup, setNotifBackup] = useState(false)
|
||||||
|
|
||||||
|
// api keys
|
||||||
|
const [apiKeys, setApiKeys] = useState<ApiKey[]>(INIT_API_KEYS)
|
||||||
|
|
||||||
|
// security
|
||||||
|
const [mfa, setMfa] = useState(true)
|
||||||
|
const [sessMin, setSessMin] = useState("480")
|
||||||
|
const [ipAllow, setIpAllow] = useState("10.0.0.0/8\n192.168.0.0/16")
|
||||||
|
const [auditLog, setAuditLog] = useState(true)
|
||||||
|
|
||||||
|
const handleCopy = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text).catch(() => {})
|
||||||
|
setCopied(text); setTimeout(() => setCopied(null), 1500)
|
||||||
|
toast.success("Скопировано в буфер обмена")
|
||||||
|
}
|
||||||
|
|
||||||
|
const markSaved = useCallback(() => {
|
||||||
|
setSaved(true)
|
||||||
|
toast.success("Настройки сохранены")
|
||||||
|
setTimeout(() => setSaved(false), 2000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (section !== "EvoBGP") return
|
||||||
|
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
|
||||||
|
}, [section, mode, backendStatus, evo.loadSettings])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (section !== "EvoBGP" || !evo.settingsLoaded) return
|
||||||
|
setEvoBaseDraft(evo.baseUrl)
|
||||||
|
setEvoEnabledDraft(evo.enabled)
|
||||||
|
setEvoKeyDraft("")
|
||||||
|
setEvoSaveErr(null)
|
||||||
|
}, [section, evo.settingsLoaded, evo.baseUrl, evo.enabled])
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (section === "EvoBGP") {
|
||||||
|
if (mode !== "live" || backendStatus !== true) {
|
||||||
|
markSaved()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEvoSaveBusy(true)
|
||||||
|
setEvoSaveErr(null)
|
||||||
|
try {
|
||||||
|
const patch: EvoBgpSavePayload = {
|
||||||
|
baseUrl: evoBaseDraft.trim(),
|
||||||
|
enabled: evoEnabledDraft,
|
||||||
|
}
|
||||||
|
if (evoKeyDraft.trim()) patch.apiKey = evoKeyDraft.trim()
|
||||||
|
await evo.saveSettings(patch)
|
||||||
|
setEvoKeyDraft("")
|
||||||
|
markSaved()
|
||||||
|
toast.success("Настройки EvoBGP сохранены")
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "Ошибка сохранения"
|
||||||
|
setEvoSaveErr(msg)
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setEvoSaveBusy(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
markSaved()
|
||||||
|
}, [
|
||||||
|
section,
|
||||||
|
mode,
|
||||||
|
backendStatus,
|
||||||
|
evoBaseDraft,
|
||||||
|
evoEnabledDraft,
|
||||||
|
evoKeyDraft,
|
||||||
|
markSaved,
|
||||||
|
evo.saveSettings,
|
||||||
|
])
|
||||||
|
|
||||||
|
const systemDbAvailable = mode === "live" && backendStatus === true
|
||||||
|
|
||||||
|
const handleSystemDatabaseBackup = useCallback(async () => {
|
||||||
|
if (!systemDbAvailable) return
|
||||||
|
setDbBackupBusy(true)
|
||||||
|
try {
|
||||||
|
const { blob, filename } = await downloadSystemDatabaseBackup(backendUrl)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast.success("Бэкап базы приложения скачан")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось создать бэкап")
|
||||||
|
} finally {
|
||||||
|
setDbBackupBusy(false)
|
||||||
|
}
|
||||||
|
}, [backendUrl, systemDbAvailable])
|
||||||
|
|
||||||
|
const handleSystemDatabaseRestoreConfirm = useCallback(async () => {
|
||||||
|
if (!dbRestoreFile || !systemDbAvailable) return
|
||||||
|
setDbRestoreBusy(true)
|
||||||
|
try {
|
||||||
|
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
|
||||||
|
toast.success("База приложения восстановлена")
|
||||||
|
setDbRestoreFile(null)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
|
||||||
|
} finally {
|
||||||
|
setDbRestoreBusy(false)
|
||||||
|
}
|
||||||
|
}, [backendUrl, dbRestoreFile, systemDbAvailable])
|
||||||
|
|
||||||
|
const handleNetflowPurgeConfirm = useCallback(async () => {
|
||||||
|
if (!systemDbAvailable) return
|
||||||
|
setDbPurgeBusy(true)
|
||||||
|
try {
|
||||||
|
const result = await purgeTrafficFlowData(backendUrl)
|
||||||
|
toast.success(formatFlowPurgeResult(result))
|
||||||
|
setDbPurgeOpen(false)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось сбросить NetFlow")
|
||||||
|
} finally {
|
||||||
|
setDbPurgeBusy(false)
|
||||||
|
}
|
||||||
|
}, [backendUrl, systemDbAvailable])
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
|
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
||||||
|
|
||||||
// ── Общие ──
|
// ── Общие ──
|
||||||
if (section === "Общие") return (
|
if (section === "Общие") return (
|
||||||
@@ -49,18 +296,18 @@ export default function SettingsPage() {
|
|||||||
title="Источник данных"
|
title="Источник данных"
|
||||||
description={
|
description={
|
||||||
mockModeAvailable
|
mockModeAvailable
|
||||||
? "Выберите живые или демонстрационные данные. Режим применяется сразу в этом браузере."
|
? "Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts"
|
||||||
: "Источник живых данных задан конфигурацией приложения."
|
: "В продакшен-образе доступны только живые данные от бекенда"
|
||||||
}
|
}
|
||||||
contentClassName="divide-y px-5"
|
contentClassName="divide-y px-5"
|
||||||
>
|
>
|
||||||
|
|
||||||
{/* mode toggle */}
|
{/* mode toggle */}
|
||||||
{mockModeAvailable ? (
|
{mockModeAvailable ? (
|
||||||
<SettingRow label="Режим данных" description="Демонстрационный режим использует тестовые данные. Живой режим обращается к серверу.">
|
<SettingRow label="Режим данных" description="Моковые — статичные тестовые данные; Живые — RouterOS REST API через бекенд">
|
||||||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||||||
{([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => (
|
{([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => (
|
||||||
<button key={v} type="button" aria-pressed={mode===v} onClick={() => setMode(v)}
|
<button key={v} onClick={() => setMode(v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 text-xs transition-colors border-input",
|
"px-3 text-xs transition-colors border-input",
|
||||||
i === 0 && "border-r",
|
i === 0 && "border-r",
|
||||||
@@ -86,11 +333,31 @@ export default function SettingsPage() {
|
|||||||
label="URL бекенда"
|
label="URL бекенда"
|
||||||
description={
|
description={
|
||||||
backendUrlLocked
|
backendUrlLocked
|
||||||
? "Адрес подключения задан конфигурацией приложения."
|
? "В Docker-образе URL API задаётся через прокси фронтенда на backend; поле только для просмотра."
|
||||||
: "Адрес сервера приложения. Изменение применяется после Enter или выхода из поля."
|
: "Адрес Node.js/Fastify сервера (запускается командой npm run dev в /backend)"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<BackendUrlField key={backendUrl} />
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
className="w-56 h-8 text-sm font-mono"
|
||||||
|
value={urlDraft}
|
||||||
|
onChange={e => setUrlDraft(e.target.value)}
|
||||||
|
onBlur={commitBackendUrl}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
commitBackendUrl()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
readOnly={backendUrlLocked}
|
||||||
|
placeholder="http://localhost:8000"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 px-2.5 shrink-0"
|
||||||
|
onClick={commitBackendUrl}
|
||||||
|
title="Проверить соединение">
|
||||||
|
<RefreshCwIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
{/* connection status */}
|
{/* connection status */}
|
||||||
@@ -99,11 +366,11 @@ export default function SettingsPage() {
|
|||||||
{backendStatus === undefined && (
|
{backendStatus === undefined && (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span className="size-2 rounded-full bg-muted-foreground/40 inline-block" />
|
<span className="size-2 rounded-full bg-muted-foreground/40 inline-block" />
|
||||||
{backendChecking ? "Проверка…" : "Не проверялось"}
|
Не проверялось
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{backendStatus === true && (
|
{backendStatus === true && (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-emerald-700 dark:text-emerald-400">
|
<span className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
|
||||||
<CheckIcon className="size-3.5" />Доступен
|
<CheckIcon className="size-3.5" />Доступен
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -121,49 +388,516 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/* hint when live but backend unreachable */}
|
{/* hint when live but backend unreachable */}
|
||||||
{mode === "live" && backendStatus === false && (
|
{mode === "live" && backendStatus === false && (
|
||||||
<div className="flex items-start gap-2 py-3 text-xs text-amber-700 dark:text-amber-400">
|
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
|
||||||
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
||||||
<span>Сервер недоступен. Последние полученные данные сохраняются; новые запросы могут завершаться ошибкой.</span>
|
<span>Бекенд недоступен — данные будут отображаться из кеша или моков. Запустите бекенд: <code className="font-mono bg-muted px-1 rounded">cd backend && npm run dev</code></span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
<OpsPanel title="Интерфейс" description="Тема применяется сразу в этом браузере." contentClassName="divide-y px-5">
|
{/* ── Основные настройки ── */}
|
||||||
<SettingRow label="Язык интерфейса"><span className="text-sm">Русский</span></SettingRow>
|
<OpsPanel title="Основные настройки" contentClassName="divide-y px-5">
|
||||||
<SettingRow label="Тема оформления"><div className="flex flex-wrap gap-2">
|
<SettingRow label="Язык интерфейса">
|
||||||
{([["light","Светлая"],["dark","Тёмная"],["system","Авто"]] as const).map(([value,label]) => <Button key={value} size="sm" variant={theme===value?"default":"outline"} aria-pressed={theme===value} disabled={!theme} onClick={()=>setTheme(value)}>{label}</Button>)}
|
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
</div></SettingRow>
|
value={lang} onChange={e => setLang(e.target.value)}>
|
||||||
<p className="py-3 text-xs text-muted-foreground">Периоды сбора данных и параметры диагностики настраиваются в соответствующих разделах.</p>
|
<option value="ru">Русский</option>
|
||||||
<div className="flex flex-wrap gap-2 pb-4"><Link href="/data-collection" className={buttonVariants({variant:"outline",size:"sm"})}>Сбор данных</Link><Link href="/probes" className={buttonVariants({variant:"outline",size:"sm"})}>Диагностика</Link></div>
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Тема оформления">
|
||||||
|
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||||||
|
{[["light","Светлая"],["dark","Тёмная"],["system","Авто"]].map(([v, l]) => (
|
||||||
|
<button key={v} onClick={() => setTheme(v)}
|
||||||
|
className={cn("px-3 text-xs transition-colors border-r last:border-r-0 border-input",
|
||||||
|
theme === v ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Часовой пояс">
|
||||||
|
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
value={timezone} onChange={e => setTimezone(e.target.value)}>
|
||||||
|
{["Europe/Moscow","Europe/Berlin","Europe/Amsterdam","Asia/Singapore","UTC"].map(z => (
|
||||||
|
<option key={z} value={z}>{z}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Интервал автообновления (сек)"
|
||||||
|
description="Как часто страницы обновляют данные">
|
||||||
|
<Input className="w-20 h-8 text-sm" value={refreshSec} onChange={e => setRefreshSec(e.target.value)} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Таймаут диагностики (сек)"
|
||||||
|
description="Максимальное время ожидания при probe-тестах">
|
||||||
|
<Input className="w-20 h-8 text-sm" value={probeTimeout} onChange={e => setProbeTimeout(e.target.value)} />
|
||||||
|
</SettingRow>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
<SystemDatabaseSection />
|
<OpsPanel
|
||||||
|
title="База данных приложения"
|
||||||
|
description="Резервная копия PostgreSQL (pg_dump custom) и сброс таблиц NetFlow. На время операции планировщик и коллектор IPFIX приостанавливаются. Preview: https://reui.io/preview/base/settings-16"
|
||||||
|
contentClassName="divide-y px-5"
|
||||||
|
>
|
||||||
|
{!systemDbAvailable && (
|
||||||
|
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
||||||
|
<span>Доступно только в live-режиме при доступном бекенде.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SettingRow
|
||||||
|
label="Скачать бэкап"
|
||||||
|
description="Консистентный дамп PostgreSQL (pg_dump -Fc)"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
||||||
|
onClick={() => { void handleSystemDatabaseBackup() }}
|
||||||
|
>
|
||||||
|
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
|
||||||
|
{dbBackupBusy ? "Подготовка…" : "Скачать"}
|
||||||
|
</Button>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="Восстановить из файла"
|
||||||
|
description="Полностью заменяет текущую базу PostgreSQL (pg_restore)"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
||||||
|
onClick={() => setDbRestoreDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<UploadIcon className="size-4" />
|
||||||
|
Выбрать файл
|
||||||
|
</Button>
|
||||||
|
{dbRestoreFile && (
|
||||||
|
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="Сбросить данные NetFlow"
|
||||||
|
description="Удалит сессии и агрегаты NetFlow в PostgreSQL. Ключи WG и пиры не трогает"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
||||||
|
onClick={() => setDbPurgeOpen(true)}
|
||||||
|
>
|
||||||
|
{dbPurgeBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <TrashIcon className="size-4" />}
|
||||||
|
{dbPurgeBusy ? "Сброс…" : "Сбросить"}
|
||||||
|
</Button>
|
||||||
|
</SettingRow>
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
<OpsPanel id="route-ai" title="Оптимизатор маршрутов" description="Текущие пороги, веса и режим применения доступны на странице оптимизатора." contentClassName="px-5 py-4"><Link href="/route-optimizer" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оптимизатор</Link></OpsPanel>
|
<OpsPanel
|
||||||
|
id="route-ai"
|
||||||
|
className="scroll-mt-4"
|
||||||
|
title="Route AI"
|
||||||
|
description="Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и веса настраиваются в инструменте, не через отдельный API."
|
||||||
|
contentClassName="flex flex-col gap-4 px-5 pb-5"
|
||||||
|
>
|
||||||
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-xs">
|
||||||
|
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||||||
|
<dt className="text-muted-foreground">Мин. выигрыш (переключение)</dt>
|
||||||
|
<dd className="font-mono tabular-nums">{ra.switchThreshold}%</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||||||
|
<dt className="text-muted-foreground">Гистерезис</dt>
|
||||||
|
<dd className="font-mono tabular-nums">{ra.hysteresisThreshold}%</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||||||
|
<dt className="text-muted-foreground">Вес задержки (ping)</dt>
|
||||||
|
<dd className="font-mono tabular-nums">{ra.pingWeight}%</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||||||
|
<dt className="text-muted-foreground">Интервал зондирования</dt>
|
||||||
|
<dd className="font-mono tabular-nums">{ra.probeIntervalMin} мин</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5 sm:col-span-2">
|
||||||
|
<dt className="text-muted-foreground">Автоприменение</dt>
|
||||||
|
<dd>{ra.autoApply ? `да, каждые ${ra.autoApplyIntervalMin} мин` : "нет"}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<Link
|
||||||
|
href="/route-optimizer"
|
||||||
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
||||||
|
>
|
||||||
|
Открыть оптимизатор маршрутов
|
||||||
|
</Link>
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (section === "EvoBGP") return <EvoBgpSettingsSection />
|
// ── EvoBGP ──
|
||||||
|
if (section === "EvoBGP") return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{(mode !== "live" || backendStatus !== true) && (
|
||||||
|
<Frame dense className="w-full">
|
||||||
|
<FramePanel className="px-4 py-4">
|
||||||
|
<p className="text-sm font-medium">Интеграция доступна в live-режиме</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»).
|
||||||
|
</p>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)}
|
||||||
|
|
||||||
if (section === "Уведомления") return <OpsPanel title="Оповещения" description="Каналы и правила оповещений настраиваются в отдельном разделе." contentClassName="px-5 py-4"><Link href="/alerts" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оповещения</Link></OpsPanel>
|
<OpsPanel
|
||||||
if (section === "API-ключи") return <OpsPanel title="API-ключи" contentClassName="px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Создание и управление отдельными API-ключами в приложении пока недоступны.</p></OpsPanel>
|
title="EvoBGP API"
|
||||||
return <OpsPanel title="Безопасность" description="Параметры доступа задаются в сервисе авторизации и конфигурации сервера." contentClassName="flex flex-col gap-3 px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Состояние MFA, ограничений IP и настроек аудита в этом разделе не получено. Изменение этих параметров здесь недоступно.</p><Link href="/users" className={buttonVariants({variant:"outline",size:"sm"})}>Управление пользователями</Link></OpsPanel>
|
description={
|
||||||
|
<>
|
||||||
|
Control plane EvoBGP: Bearer-ключ и роль viewer+ — см.{" "}
|
||||||
|
<a
|
||||||
|
className="underline underline-offset-2"
|
||||||
|
href="https://git.shx.one/denozord/EvoBGP/src/branch/main/docs/access.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
access.md
|
||||||
|
</a>
|
||||||
|
. URL и ключ хранятся в PostgreSQL на сервере бекенда (
|
||||||
|
<code className="font-mono bg-muted px-1 rounded">evobgp_settings</code>
|
||||||
|
). Каталог —{" "}
|
||||||
|
<code className="font-mono bg-muted px-1 rounded">GET /v1/router-lists/catalog</code> через прокси.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
contentClassName="divide-y px-5 flex flex-col gap-4 pb-5"
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
label="Базовый URL API"
|
||||||
|
description="Например http://control.example:8080 — без суффикса /v1"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
className="w-full max-w-md h-8 text-sm font-mono"
|
||||||
|
value={evoBaseDraft}
|
||||||
|
onChange={(e) => setEvoBaseDraft(e.target.value)}
|
||||||
|
placeholder="http://localhost:8080"
|
||||||
|
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="API-ключ"
|
||||||
|
description={
|
||||||
|
evo.secretConfigured
|
||||||
|
? "В БД уже есть ключ — введите новый только для замены"
|
||||||
|
: "Сохраняется общей кнопкой «Сохранить» в шапке страницы"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
className="w-full max-w-md h-8 text-sm font-mono"
|
||||||
|
type={showEvoKey ? "text" : "password"}
|
||||||
|
autoComplete="off"
|
||||||
|
value={evoKeyDraft}
|
||||||
|
onChange={(e) => setEvoKeyDraft(e.target.value)}
|
||||||
|
placeholder={evo.secretConfigured ? "Оставьте пустым, чтобы не менять" : "Bearer-токен"}
|
||||||
|
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0"
|
||||||
|
onClick={() => setShowEvoKey((v) => !v)}
|
||||||
|
>
|
||||||
|
{showEvoKey ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="Подставлять данные EvoBGP"
|
||||||
|
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
||||||
|
>
|
||||||
|
<FormToggle
|
||||||
|
checked={evoEnabledDraft}
|
||||||
|
onChange={(v) => setEvoEnabledDraft(v)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-destructive border-destructive/40 hover:bg-destructive/10"
|
||||||
|
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy || !evo.secretConfigured}
|
||||||
|
onClick={async () => {
|
||||||
|
setEvoSaveBusy(true)
|
||||||
|
setEvoSaveErr(null)
|
||||||
|
try {
|
||||||
|
await evo.saveSettings({ apiKey: null })
|
||||||
|
setEvoKeyDraft("")
|
||||||
|
markSaved()
|
||||||
|
} catch (e) {
|
||||||
|
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка")
|
||||||
|
} finally {
|
||||||
|
setEvoSaveBusy(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Удалить ключ из БД
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{evoSaveErr && <p className="text-xs text-destructive">{evoSaveErr}</p>}
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
«Проверить ключ» использует поля формы; незаполненное поле подставляется из БД. «Обновить каталог» — только по сохранённым в БД настройкам.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={
|
||||||
|
mode !== "live" ||
|
||||||
|
backendStatus !== true ||
|
||||||
|
evoBusy !== null ||
|
||||||
|
evoSaveBusy ||
|
||||||
|
!(
|
||||||
|
(evoBaseDraft.trim() || evo.baseUrl.trim()) &&
|
||||||
|
(evoKeyDraft.trim() || evo.secretConfigured)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
setEvoBusy("test")
|
||||||
|
setEvoTestResult(null)
|
||||||
|
const b = evoBaseDraft.trim()
|
||||||
|
const k = evoKeyDraft.trim()
|
||||||
|
let draft: EvoBgpTestDraft | undefined
|
||||||
|
if (b || k) {
|
||||||
|
draft = {}
|
||||||
|
if (b) draft.baseUrl = b
|
||||||
|
if (k) draft.apiKey = k
|
||||||
|
}
|
||||||
|
const r = await evo.testConnection(draft)
|
||||||
|
setEvoTestResult({ ok: r.ok, message: r.message })
|
||||||
|
setEvoBusy(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{evoBusy === "test" ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
||||||
|
Проверить ключ
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={mode !== "live" || backendStatus !== true || evoBusy !== null || evoSaveBusy || !evo.enabled}
|
||||||
|
onClick={async () => {
|
||||||
|
setEvoBusy("refresh")
|
||||||
|
await evo.refresh()
|
||||||
|
setEvoBusy(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{evoBusy === "refresh" || evo.loading ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
||||||
|
Обновить каталог
|
||||||
|
</Button>
|
||||||
|
{evo.snapshot?.fetchedAt && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Загружено: {new Date(evo.snapshot.fetchedAt).toLocaleString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{evoTestResult && (
|
||||||
|
<p className={cn(
|
||||||
|
"text-xs",
|
||||||
|
evoTestResult.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive",
|
||||||
|
)}>
|
||||||
|
{evoTestResult.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{evo.error && (
|
||||||
|
<p className="text-xs text-destructive">{evo.error}</p>
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Уведомления ──
|
||||||
|
if (section === "Уведомления") return (
|
||||||
|
<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} />
|
||||||
|
</SettingRow>
|
||||||
|
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="admin@routerlists.io" /></div>}
|
||||||
|
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
|
||||||
|
<FormToggle checked={notifSlack} onChange={setNotifSlack} />
|
||||||
|
</SettingRow>
|
||||||
|
{notifSlack && <div className="py-3"><Input className="text-sm h-8 font-mono" placeholder="https://hooks.slack.com/…" /></div>}
|
||||||
|
<SettingRow label="Webhook" description="POST-запрос на произвольный endpoint">
|
||||||
|
<FormToggle checked={notifWh} onChange={setNotifWh} />
|
||||||
|
</SettingRow>
|
||||||
|
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
|
||||||
|
</OpsPanel>
|
||||||
|
<OpsPanel title="Триггеры" contentClassName="divide-y px-5">
|
||||||
|
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
|
||||||
|
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Узел ушёл offline">
|
||||||
|
<FormToggle checked={notifOffline} onChange={setNotifOffline} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Падение BGP-сессии">
|
||||||
|
<FormToggle checked={notifBgp} onChange={setNotifBgp} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
|
||||||
|
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
|
||||||
|
</SettingRow>
|
||||||
|
</OpsPanel>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── API-ключи ──
|
||||||
|
if (section === "API-ключи") return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm"><PlusIcon className="size-4" />Создать ключ</Button>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium">{k.name}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{k.prefix}</code>
|
||||||
|
<button onClick={() => handleCopy(k.prefix)} className="text-muted-foreground/40 hover:text-muted-foreground transition-colors">
|
||||||
|
{copied === k.prefix ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{k.scopes.map(s => (
|
||||||
|
<span key={s} className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{s}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-xs text-muted-foreground">Создан: {k.created}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Использован: {k.last}</p>
|
||||||
|
<div className="flex gap-1 justify-end mt-2">
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs">Переиздать</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setApiKeys(p => p.filter(x => x.id !== k.id))}>
|
||||||
|
<TrashIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<OpsPanel
|
||||||
|
title="Документация API"
|
||||||
|
description="Base URL: https://api.routerlists.io/v1"
|
||||||
|
contentClassName="px-4 pb-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
{[
|
||||||
|
["GET", "/servers", "Список серверов"],
|
||||||
|
["GET", "/traffic", "Данные трафика"],
|
||||||
|
["GET", "/filters", "Фильтры маршрутов"],
|
||||||
|
["POST", "/filters", "Создать фильтр"],
|
||||||
|
["GET", "/probes", "Диагностические пробы"],
|
||||||
|
["POST", "/probes/run", "Запустить тест"],
|
||||||
|
].map(([method, path, desc], i) => (
|
||||||
|
<div key={`${method}-${path}-${i}`} className="flex items-baseline gap-2">
|
||||||
|
<span className={cn("font-mono text-[10px] font-bold w-8 shrink-0",
|
||||||
|
method === "GET" ? "text-emerald-500" : "text-sky-500")}>{method}</span>
|
||||||
|
<code className="font-mono text-muted-foreground">{path}</code>
|
||||||
|
<span className="text-muted-foreground/60 text-[10px]">{desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</OpsPanel>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Безопасность ──
|
||||||
|
if (section === "Безопасность") return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<OpsPanel title="Аутентификация" contentClassName="divide-y px-5">
|
||||||
|
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
||||||
|
description="TOTP / Authenticator app для всех администраторов">
|
||||||
|
<FormToggle checked={mfa} onChange={setMfa} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
|
||||||
|
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
|
||||||
|
</SettingRow>
|
||||||
|
</OpsPanel>
|
||||||
|
<OpsPanel
|
||||||
|
title="Allowlist IP-адресов"
|
||||||
|
description="Доступ разрешён только с этих сетей. Одна запись на строку."
|
||||||
|
contentClassName="px-4 pb-4"
|
||||||
|
>
|
||||||
|
<textarea value={ipAllow} onChange={e => setIpAllow(e.target.value)} rows={4}
|
||||||
|
className="w-full rounded-md border bg-muted px-3 py-2 text-xs font-mono resize-none outline-none focus:ring-1 focus:ring-ring" />
|
||||||
|
</OpsPanel>
|
||||||
|
<OpsPanel title="Аудит" contentClassName="divide-y px-5">
|
||||||
|
<SettingRow label="Расширенный журнал аудита"
|
||||||
|
description="Записывать все изменения конфигурации с указанием пользователя и IP">
|
||||||
|
<FormToggle checked={auditLog} onChange={setAuditLog} />
|
||||||
|
</SettingRow>
|
||||||
|
</OpsPanel>
|
||||||
|
<OpsPanel title={<span className="text-destructive">Опасная зона</span>} contentClassName="px-4 pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Сбросить все настройки</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Удалит пользовательские параметры и вернёт заводские значения</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</OpsPanel>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Система" }, { label: "Настройки" }]}
|
crumbs={[{ label: "Система" }, { label: "Настройки" }]}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { void handleSave() }}
|
||||||
|
disabled={
|
||||||
|
section === "EvoBGP" &&
|
||||||
|
(evoSaveBusy || mode !== "live" || backendStatus !== true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{section === "EvoBGP" && evoSaveBusy ? (
|
||||||
|
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||||
|
) : saved ? (
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<SaveIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
{section === "EvoBGP" && evoSaveBusy
|
||||||
|
? "Сохранение…"
|
||||||
|
: saved
|
||||||
|
? "Сохранено!"
|
||||||
|
: "Сохранить"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="max-w-[960px] mx-auto grid grid-cols-1 md:grid-cols-[180px_minmax(0,1fr)] gap-6">
|
<div className="max-w-[960px] mx-auto grid grid-cols-[180px_1fr] gap-6">
|
||||||
|
|
||||||
{/* nav */}
|
{/* nav */}
|
||||||
<nav className="flex flex-wrap md:flex-col gap-1 self-start md:sticky md:top-0">
|
<nav className="flex flex-col gap-1 self-start sticky top-0">
|
||||||
{SECTIONS_NAV.map(s => (
|
{SECTIONS_NAV.map(s => (
|
||||||
<button key={s} aria-current={s===section?"page":undefined} onClick={() => setSection(s)}
|
<button key={s} onClick={() => setSection(s)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-left px-3 py-2 rounded-md text-sm transition-colors",
|
"text-left px-3 py-2 rounded-md text-sm transition-colors",
|
||||||
s === section ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground hover:text-foreground",
|
s === section ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground hover:text-foreground",
|
||||||
@@ -178,18 +912,40 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* delete confirm */}
|
||||||
|
{dbRestoreFile && (
|
||||||
|
<DatabaseRestoreConfirm
|
||||||
|
open={!!dbRestoreFile}
|
||||||
|
filename={dbRestoreFile.name}
|
||||||
|
busy={dbRestoreBusy}
|
||||||
|
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
|
||||||
|
onCancel={() => {
|
||||||
|
if (dbRestoreBusy) return
|
||||||
|
setDbRestoreFile(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NetflowPurgeConfirm
|
||||||
|
open={dbPurgeOpen}
|
||||||
|
busy={dbPurgeBusy}
|
||||||
|
onConfirm={() => { void handleNetflowPurgeConfirm() }}
|
||||||
|
onCancel={() => { if (!dbPurgeBusy) setDbPurgeOpen(false) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FileImportDialog
|
||||||
|
open={dbRestoreDialogOpen}
|
||||||
|
onOpenChange={setDbRestoreDialogOpen}
|
||||||
|
title="Восстановление базы данных"
|
||||||
|
description="Выберите файл pg_dump custom (.dump) — текущая база будет полностью заменена"
|
||||||
|
accept=".dump,.backup,application/octet-stream"
|
||||||
|
onImport={async (files) => {
|
||||||
|
const file = files[0]
|
||||||
|
if (!file) return
|
||||||
|
setDbRestoreFile(file)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function BackendUrlField(){
|
|
||||||
const {backendUrl,setBackendUrl,backendUrlLocked}=useDataSource()
|
|
||||||
const [draft,setDraft]=useState(backendUrl),[error,setError]=useState<string|null>(null)
|
|
||||||
function commit(){
|
|
||||||
if(backendUrlLocked||draft===backendUrl)return
|
|
||||||
try{const url=new URL(draft.trim());if(!["http:","https:"].includes(url.protocol)||url.username||url.password||url.search||url.hash)throw Error();setBackendUrl(url.toString().replace(/\/$/,""));setError(null)}
|
|
||||||
catch{setError("Укажите HTTP(S) адрес без учётных данных и параметров")}
|
|
||||||
}
|
|
||||||
return <div className="flex min-w-0 flex-col gap-2"><Input aria-label="URL бекенда" className="w-full sm:w-64 font-mono" value={draft} onChange={e=>{setDraft(e.target.value);setError(null)}} onBlur={commit} onKeyDown={e=>{if(e.key==="Enter"){e.preventDefault();commit()}}} readOnly={backendUrlLocked} aria-invalid={!!error} placeholder="http://localhost:8000"/>{error?<p role="alert" className="text-xs text-destructive">{error}</p>:null}</div>
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
ServerIcon,
|
ServerIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
@@ -27,14 +26,13 @@ import {
|
|||||||
type StatisticsSliceKind,
|
type StatisticsSliceKind,
|
||||||
} from "@/components/data-grids/statistics-breakdown-data-grid"
|
} from "@/components/data-grids/statistics-breakdown-data-grid"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
import { StatisticsFilters } from "@/components/statistics/statistics-filters"
|
import type { Filter } from "@/components/reui/filters"
|
||||||
|
import { STATISTICS_FILTER_FIELDS } from "@/lib/data-filters/statistics-filter-fields"
|
||||||
import {
|
import {
|
||||||
isStatisticsPivotDim,
|
isStatisticsPivotDim,
|
||||||
isStatisticsSliceKind,
|
isStatisticsSliceKind,
|
||||||
STATISTICS_DIMS,
|
STATISTICS_DIMS,
|
||||||
} from "@/lib/statistics-dims"
|
} from "@/lib/statistics-dims"
|
||||||
import { statisticsContextError, STATISTICS_CONTEXT_KEYS } from "@/lib/statistics-context"
|
|
||||||
import { readStatisticsRange } from "@/lib/statistics-range"
|
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
|
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
|
||||||
import {
|
import {
|
||||||
@@ -97,6 +95,13 @@ interface CubeSlices {
|
|||||||
|
|
||||||
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
|
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
|
||||||
|
|
||||||
|
function readRange(sp: URLSearchParams): DateRangeYmd {
|
||||||
|
const from = sp.get("from")
|
||||||
|
const to = sp.get("to")
|
||||||
|
if (from && to && from <= to) return { from, to }
|
||||||
|
return rangeForPreset("7d")
|
||||||
|
}
|
||||||
|
|
||||||
function readDim(sp: URLSearchParams): StatisticsSliceKind {
|
function readDim(sp: URLSearchParams): StatisticsSliceKind {
|
||||||
const t = sp.get("dim") ?? sp.get("tab")
|
const t = sp.get("dim") ?? sp.get("tab")
|
||||||
return t && isStatisticsSliceKind(t) ? t : "users"
|
return t && isStatisticsSliceKind(t) ? t : "users"
|
||||||
@@ -124,6 +129,29 @@ function readSlices(sp: URLSearchParams): CubeSlices {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slicesToFilters(slices: CubeSlices): Filter[] {
|
||||||
|
return SLICE_KEYS.flatMap((key) => {
|
||||||
|
const val = slices[key]
|
||||||
|
if (!val) return []
|
||||||
|
return [{ id: key, field: key, operator: "is", values: [val] }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtersToSlices(filters: Filter[]): CubeSlices {
|
||||||
|
const next: CubeSlices = {}
|
||||||
|
for (const f of filters) {
|
||||||
|
const raw = String(f.values[0] ?? "").trim()
|
||||||
|
if (!raw) continue
|
||||||
|
if (f.field === "country") next.country = raw.toUpperCase().slice(0, 2)
|
||||||
|
else if (f.field === "service") next.service = raw
|
||||||
|
else if (f.field === "asn") next.asn = raw.replace(/[^\d]/g, "")
|
||||||
|
else if (f.field === "serverId") next.serverId = raw
|
||||||
|
else if (f.field === "userId") next.userId = raw
|
||||||
|
else if (f.field === "iface") next.iface = raw
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery {
|
function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery {
|
||||||
const serverId = slices.serverId ? Number(slices.serverId) : undefined
|
const serverId = slices.serverId ? Number(slices.serverId) : undefined
|
||||||
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
|
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
|
||||||
@@ -231,50 +259,44 @@ function StatisticsPageInner() {
|
|||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const { range, error: rangeError } = useMemo(() => readStatisticsRange(searchParams, rangeForPreset("7d")), [searchParams])
|
const range = useMemo(() => readRange(searchParams), [searchParams])
|
||||||
const paramsError = useMemo(() => statisticsContextError(searchParams), [searchParams])
|
|
||||||
const slices = useMemo(() => readSlices(searchParams), [searchParams])
|
const slices = useMemo(() => readSlices(searchParams), [searchParams])
|
||||||
|
const filters = useMemo(() => slicesToFilters(slices), [slices])
|
||||||
const dim = useMemo(() => readDim(searchParams), [searchParams])
|
const dim = useMemo(() => readDim(searchParams), [searchParams])
|
||||||
const view = useMemo(() => readView(searchParams), [searchParams])
|
const view = useMemo(() => readView(searchParams), [searchParams])
|
||||||
const planes = useMemo(() => readPlanes(searchParams), [searchParams])
|
const planes = useMemo(() => readPlanes(searchParams), [searchParams])
|
||||||
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
|
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
|
||||||
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
|
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
|
||||||
|
|
||||||
const [retry, setRetry] = useState(0)
|
const [data, setData] = useState<StatisticsDto>(EMPTY)
|
||||||
const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry, rangeError, paramsError })
|
const [pivot, setPivot] = useState<StatisticsPivotDto>(EMPTY_PIVOT)
|
||||||
const [result, setResult] = useState<{
|
const [loading, setLoading] = useState(false)
|
||||||
key: string; data: StatisticsDto; pivot: StatisticsPivotDto; error: string | null
|
const [error, setError] = useState<string | null>(null)
|
||||||
} | null>(null)
|
|
||||||
const current = result?.key === requestKey ? result : null
|
|
||||||
const loading = isLive && !rangeError && !paramsError && current === null
|
|
||||||
const error = rangeError ?? paramsError ?? current?.error ?? null
|
|
||||||
const dataReady = isLive && current !== null && !error
|
|
||||||
const pivot = dataReady ? current.pivot : EMPTY_PIVOT
|
|
||||||
|
|
||||||
const updateParams = useCallback(
|
const replaceParams = useCallback(
|
||||||
(patch: Record<string, string | undefined>) => {
|
(patch: Record<string, string | undefined>) => {
|
||||||
const sp = new URLSearchParams(window.location.search)
|
const sp = new URLSearchParams(searchParams.toString())
|
||||||
for (const [k, v] of Object.entries(patch)) {
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
if (v) sp.set(k, v)
|
if (v) sp.set(k, v)
|
||||||
else sp.delete(k)
|
else sp.delete(k)
|
||||||
}
|
}
|
||||||
const qs = sp.toString()
|
const qs = sp.toString()
|
||||||
if (qs === searchParams.toString()) return
|
if (qs === searchParams.toString()) return
|
||||||
window.history.pushState(null, "", (qs ? `/statistics?${qs}` : "/statistics") + window.location.hash)
|
window.history.replaceState(null, "", qs ? `/statistics?${qs}` : "/statistics")
|
||||||
},
|
},
|
||||||
[searchParams],
|
[searchParams],
|
||||||
)
|
)
|
||||||
|
|
||||||
const setRange = useCallback(
|
const setRange = useCallback(
|
||||||
(next: DateRangeYmd) => {
|
(next: DateRangeYmd) => {
|
||||||
updateParams({ from: next.from, to: next.to })
|
replaceParams({ from: next.from, to: next.to })
|
||||||
},
|
},
|
||||||
[updateParams],
|
[replaceParams],
|
||||||
)
|
)
|
||||||
|
|
||||||
const setSlices = useCallback(
|
const setSlices = useCallback(
|
||||||
(next: CubeSlices) => {
|
(next: CubeSlices) => {
|
||||||
updateParams({
|
replaceParams({
|
||||||
country: next.country,
|
country: next.country,
|
||||||
service: next.service,
|
service: next.service,
|
||||||
asn: next.asn,
|
asn: next.asn,
|
||||||
@@ -283,39 +305,44 @@ function StatisticsPageInner() {
|
|||||||
iface: next.iface,
|
iface: next.iface,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[updateParams],
|
[replaceParams],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!prefsHydrated || !isLive || rangeError || paramsError) return
|
if (!prefsHydrated || !isLive) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const query = toQuery(range, slices, planes)
|
const query = toQuery(range, slices, planes)
|
||||||
const dto = await getStatistics(backendUrl, query)
|
const dto = await getStatistics(backendUrl, query)
|
||||||
if (cancelled) return
|
if (!cancelled) setData(dto)
|
||||||
let matrix = EMPTY_PIVOT
|
|
||||||
if (view === "pivot" && pivotRow !== pivotCol) {
|
if (view === "pivot" && pivotRow !== pivotCol) {
|
||||||
matrix = await getStatisticsPivot(backendUrl, {
|
const matrix = await getStatisticsPivot(backendUrl, {
|
||||||
...query,
|
...query,
|
||||||
row: pivotRow,
|
row: pivotRow,
|
||||||
col: pivotCol,
|
col: pivotCol,
|
||||||
metric: "bytes",
|
metric: "bytes",
|
||||||
})
|
})
|
||||||
|
if (!cancelled) setPivot(matrix)
|
||||||
}
|
}
|
||||||
if (!cancelled) setResult({ key: requestKey, data: dto, pivot: matrix, error: null })
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setResult({ key: requestKey, data: EMPTY, pivot: EMPTY_PIVOT, error: e instanceof Error ? e.message : "Не удалось загрузить статистику" })
|
setData(EMPTY)
|
||||||
|
setPivot(EMPTY_PIVOT)
|
||||||
|
setError(e instanceof Error ? e.message : "Не удалось загрузить статистику")
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes, requestKey, rangeError, paramsError])
|
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes])
|
||||||
|
|
||||||
const viewData = dataReady ? current.data : EMPTY
|
const viewData = isLive ? data : EMPTY
|
||||||
const sliced = hasAnySlice(slices)
|
const sliced = hasAnySlice(slices)
|
||||||
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
|
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
|
||||||
|
|
||||||
@@ -329,7 +356,7 @@ function StatisticsPageInner() {
|
|||||||
if (rowId === "__other__" || colId === "__other__") return
|
if (rowId === "__other__" || colId === "__other__") return
|
||||||
let next = applyPivotDim(slices, pivotRow, rowId)
|
let next = applyPivotDim(slices, pivotRow, rowId)
|
||||||
next = applyPivotDim(next, pivotCol, colId)
|
next = applyPivotDim(next, pivotCol, colId)
|
||||||
updateParams({
|
replaceParams({
|
||||||
country: next.country,
|
country: next.country,
|
||||||
service: next.service,
|
service: next.service,
|
||||||
asn: next.asn,
|
asn: next.asn,
|
||||||
@@ -353,7 +380,7 @@ function StatisticsPageInner() {
|
|||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
|
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
|
||||||
actions={rangeError ? <Button type="button" variant="outline" onClick={() => setRange(rangeForPreset("7d"))}>Выбрать последние 7 дней</Button> : <PeriodSelector range={range} onChange={setRange} />}
|
actions={<PeriodSelector range={range} onChange={setRange} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||||
@@ -367,13 +394,13 @@ function StatisticsPageInner() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert variant="destructive" role="alert">
|
<Alert variant="destructive">
|
||||||
<AlertTitle>{rangeError ? "Некорректный период" : paramsError ? "Параметры отчёта требуют внимания" : "Ошибка загрузки"}</AlertTitle>
|
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||||
<AlertDescription>{error}{!rangeError && !paramsError && <Button type="button" variant="outline" size="sm" onClick={() => setRetry(n => n + 1)}>Повторить загрузку</Button>}{paramsError && <Button type="button" variant="outline" size="sm" onClick={() => updateParams(Object.fromEntries(STATISTICS_CONTEXT_KEYS.map(key => [key, undefined])))}>Сбросить параметры отчёта</Button>}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!slices.serverId && dataReady && !emptyCube ? (
|
{!slices.serverId && isLive && !emptyCube ? (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertTitle>Уникальный объём</AlertTitle>
|
<AlertTitle>Уникальный объём</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
@@ -382,7 +409,7 @@ function StatisticsPageInner() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(dataReady || loading) && <KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка трафика"
|
aria-label="Сводка трафика"
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
skeletonCount={5}
|
skeletonCount={5}
|
||||||
@@ -430,9 +457,9 @@ function StatisticsPageInner() {
|
|||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>}
|
/>
|
||||||
|
|
||||||
{dataReady && <StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />}
|
<StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />
|
||||||
|
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
@@ -440,7 +467,7 @@ function StatisticsPageInner() {
|
|||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={view}
|
value={view}
|
||||||
onChange={(next) => updateParams({ view: next === "pivot" ? "pivot" : "explore" })}
|
onChange={(next) => replaceParams({ view: next === "pivot" ? "pivot" : "explore" })}
|
||||||
options={[
|
options={[
|
||||||
{ value: "explore", label: "Разрез" },
|
{ value: "explore", label: "Разрез" },
|
||||||
{ value: "pivot", label: "Сводка" },
|
{ value: "pivot", label: "Сводка" },
|
||||||
@@ -448,7 +475,7 @@ function StatisticsPageInner() {
|
|||||||
/>
|
/>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={planes}
|
value={planes}
|
||||||
onChange={(next) => updateParams({ planes: next === "all" ? "all" : undefined })}
|
onChange={(next) => replaceParams({ planes: next === "all" ? "all" : undefined })}
|
||||||
options={[
|
options={[
|
||||||
{ value: "unique", label: "Уникальный" },
|
{ value: "unique", label: "Уникальный" },
|
||||||
{ value: "all", label: "Все плоскости" },
|
{ value: "all", label: "Все плоскости" },
|
||||||
@@ -458,7 +485,7 @@ function StatisticsPageInner() {
|
|||||||
<DimensionSelect
|
<DimensionSelect
|
||||||
label="Критерий"
|
label="Критерий"
|
||||||
value={dim}
|
value={dim}
|
||||||
onChange={(next) => updateParams({ dim: next, tab: undefined })}
|
onChange={(next) => replaceParams({ dim: next })}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{view === "pivot" ? (
|
{view === "pivot" ? (
|
||||||
@@ -467,20 +494,22 @@ function StatisticsPageInner() {
|
|||||||
label="Строки"
|
label="Строки"
|
||||||
value={pivotRow}
|
value={pivotRow}
|
||||||
exclude={pivotCol}
|
exclude={pivotCol}
|
||||||
onChange={(next) => updateParams({ pivotRow: next })}
|
onChange={(next) => replaceParams({ pivotRow: next })}
|
||||||
/>
|
/>
|
||||||
<PivotDimSelect
|
<PivotDimSelect
|
||||||
label="Колонки"
|
label="Колонки"
|
||||||
value={pivotCol}
|
value={pivotCol}
|
||||||
exclude={pivotRow}
|
exclude={pivotRow}
|
||||||
onChange={(next) => updateParams({ pivotCol: next })}
|
onChange={(next) => replaceParams({ pivotCol: next })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
filterControl={<StatisticsFilters key={JSON.stringify(slices)} slices={slices} onApply={setSlices} />}
|
filters={filters}
|
||||||
countLabel={dataReady ? countLabel : undefined}
|
onFiltersChange={(next) => setSlices(filtersToSlices(next))}
|
||||||
|
filterFields={STATISTICS_FILTER_FIELDS}
|
||||||
|
countLabel={countLabel}
|
||||||
/>
|
/>
|
||||||
<SliceChips
|
<SliceChips
|
||||||
chips={chips}
|
chips={chips}
|
||||||
@@ -490,9 +519,7 @@ function StatisticsPageInner() {
|
|||||||
setSlices(next)
|
setSlices(next)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isLive && !dataReady ? (
|
{emptyCube ? (
|
||||||
<div role="status" className="p-6 text-sm text-muted-foreground">{loading ? "Загрузка статистики…" : rangeError || paramsError ? "Исправьте параметры отчёта для загрузки статистики." : "Статистика недоступна. Повторите загрузку."}</div>
|
|
||||||
) : emptyCube ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Нет данных куба"
|
title="Нет данных куба"
|
||||||
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
|
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
|
||||||
|
|||||||
+421
-111
@@ -1,30 +1,27 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect, Suspense } from "react"
|
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { servers as mockServers } from "@/lib/data"
|
import { servers as mockServers } from "@/lib/data"
|
||||||
import {useSearchParams} from "next/navigation"
|
import type { ServerStatus } from "@/lib/data"
|
||||||
import {can} from "@/lib/auth"
|
|
||||||
import {terminalServersSchema,readTerminalOutput,readTerminalUrl,patchTerminalUrl} from "@/lib/terminal-response"
|
|
||||||
import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert"
|
|
||||||
import {Input} from "@/components/ui/input"
|
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
|
||||||
FrameFooter,
|
FrameFooter,
|
||||||
FrameHeader,
|
FrameHeader,
|
||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from "@/components/reui/frame"
|
} from "@/components/reui/frame"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TrashIcon, RefreshCwIcon,
|
type ServerTileItem,
|
||||||
|
} from "@/components/server-tile-rail"
|
||||||
|
import {
|
||||||
|
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -174,72 +171,231 @@ function mockResolve(raw: string, serverName: string): string {
|
|||||||
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
|
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── MOTD builders ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Terminal({server,isLive,backendUrl,canExecute,blockedReason}:{server:TermServer;isLive:boolean;backendUrl:string;canExecute:boolean;blockedReason?:string}) {
|
function mockMotd(server: TermServer): string {
|
||||||
const [lines,setLines]=useState<TermLine[]>([{id:0,kind:"info",text:isLive?"REST: каждая команда — отдельный запрос. Результат появится после ответа API.":"Демо: команды возвращают синтетические данные."}])
|
return [
|
||||||
const [input,setInput]=useState(""),[history,setHistory]=useState<string[]>([]),[histIdx,setHistIdx]=useState(-1),[executing,setExecuting]=useState(false),[error,setError]=useState<string|null>(null),[completed,setCompleted]=useState<string|null>(null)
|
"",
|
||||||
const nextId=useRef(1),lock=useRef(false),reader=useRef<{alive:boolean;controller:AbortController|null}>({alive:true,controller:null})
|
" MMM MMM KKK TTTTTTTTTTT KKK",
|
||||||
const inputRef=useRef<HTMLInputElement>(null),scrollRef=useRef<HTMLDivElement>(null),followOutput=useRef(true),pendingFocus=useRef(false)
|
" MMM MMMM MMM III KKK KKK RRR OOOOOO TTT TTT II KKK KKK",
|
||||||
useEffect(()=>{const state=reader.current;state.alive=true;return()=>{state.alive=false;state.controller?.abort()}},[])
|
" MMM MMM III KKK KKK RRR RRROOOOO TTT TTT II KKK KKK",
|
||||||
useEffect(()=>{if(followOutput.current)scrollRef.current?.scrollTo({top:scrollRef.current.scrollHeight,behavior:"auto"})},[lines])
|
"",
|
||||||
// Focus only after React has committed the re-enabled input.
|
` MikroTik RouterOS ${server.rosVersion ?? "7.14.3"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
|
||||||
useEffect(()=>{
|
"",
|
||||||
if(executing||!pendingFocus.current)return
|
"[?] Gives the list of available commands",
|
||||||
pendingFocus.current=false
|
"[Tab] Completes the command/word",
|
||||||
if(reader.current.alive&&!inputRef.current?.disabled)inputRef.current?.focus()
|
"[/] Move up to base level",
|
||||||
},[executing,lines])
|
"[..] Move up one level",
|
||||||
function append(kind:TermLine["kind"],text:string) {
|
"",
|
||||||
const added=text.split("\n").map(text=>({id:nextId.current++,kind,text}))
|
].join("\n")
|
||||||
setLines(previous=>{const merged=[...previous.filter(line=>line.id!==-1),...added];return merged.length>1000||previous.some(line=>line.id===-1)?[{id:-1,kind:"info",text:"Показаны последние 1000 строк; ранний вывод удалён."},...merged.slice(-1000)]:merged})
|
}
|
||||||
}
|
|
||||||
async function submit(){
|
function liveMotd(server: TermServer): string {
|
||||||
const command=input.trim();if(lock.current||!command||!canExecute||!server.enabled||blockedReason)return
|
return [
|
||||||
if(command.toLowerCase()==="clear"||command.toLowerCase()==="/clear"){setLines([]);setInput("");return}
|
"",
|
||||||
lock.current=true;setExecuting(true);setError(null);setCompleted(null);setHistIdx(-1)
|
` MikroTik RouterOS ${server.rosVersion ?? "7.x"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
|
||||||
append("prompt",server.name+" > "+command)
|
"",
|
||||||
setHistory(previous=>[command,...previous.filter(item=>item!==command)].slice(0,100))
|
` Connected to ${server.name} (${server.host})`,
|
||||||
const controller=new AbortController();reader.current.controller=controller
|
"",
|
||||||
let timedOut=false;const timer=window.setTimeout(()=>{timedOut=true;controller.abort()},60000)
|
"[?] Gives the list of available commands — type help or ?",
|
||||||
try {
|
"[Tab] Completes the command/word",
|
||||||
const output=isLive?readTerminalOutput(await requestJson<unknown>(backendUrl,"/api/servers/"+server.backendId+"/exec",{method:"POST",body:JSON.stringify({command}),signal:controller.signal})):mockResolve(command,server.name)
|
"[/] Move up to base level",
|
||||||
if(!reader.current.alive)return
|
"",
|
||||||
if(output==="__CLEAR__")setLines([]);else append("output",output||"Пустой вывод.")
|
].join("\n")
|
||||||
setInput("");setCompleted((isLive?"Ответ API получен":"Результат демо")+" · "+new Date().toLocaleTimeString("ru-RU"));followOutput.current=true
|
}
|
||||||
} catch(err) {
|
|
||||||
if(!reader.current.alive)return
|
// ─── terminal component ───────────────────────────────────────────────────────
|
||||||
const message=timedOut?"Время ожидания истекло. Результат запроса не подтверждён.":err instanceof Error?err.message:"Не удалось получить вывод команды."
|
|
||||||
append("error",message);setError(message)
|
function Terminal({
|
||||||
} finally {
|
server,
|
||||||
window.clearTimeout(timer);lock.current=false
|
isLive,
|
||||||
if(reader.current.alive){pendingFocus.current=true;setExecuting(false);reader.current.controller=null}
|
backendUrl,
|
||||||
|
}: {
|
||||||
|
server: TermServer
|
||||||
|
isLive: boolean
|
||||||
|
backendUrl: string
|
||||||
|
}) {
|
||||||
|
const [lines, setLines] = useState<TermLine[]>([])
|
||||||
|
const [input, setInput] = useState("")
|
||||||
|
const [history, setHistory] = useState<string[]>([])
|
||||||
|
const [_histIdx, setHistIdx] = useState(-1)
|
||||||
|
const [_idCtr, setIdCtr] = useState(0)
|
||||||
|
const [executing, setExecuting] = useState(false)
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const nextId = useCallback((): number => {
|
||||||
|
let id = 0
|
||||||
|
setIdCtr(prev => { id = prev + 1; return prev + 1 })
|
||||||
|
return id
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Initialize MOTD on mount / server change
|
||||||
|
useEffect(() => {
|
||||||
|
const motd = isLive ? liveMotd(server) : mockMotd(server)
|
||||||
|
const init = motd.split("\n").map((text, i) => ({ id: i, kind: "output" as const, text }))
|
||||||
|
setLines(init)
|
||||||
|
setIdCtr(init.length)
|
||||||
|
setInput("")
|
||||||
|
setHistory([])
|
||||||
|
setHistIdx(-1)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [server.uid, isLive])
|
||||||
|
|
||||||
|
// Auto-scroll
|
||||||
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" })
|
||||||
|
}, [lines])
|
||||||
|
|
||||||
|
const prompt = `[admin@${server.identityName ?? server.name}] > `
|
||||||
|
|
||||||
|
const submit = useCallback(async () => {
|
||||||
|
if (executing) return
|
||||||
|
const cmd = input.trim()
|
||||||
|
|
||||||
|
setLines(prev => [...prev, { id: nextId(), kind: "prompt", text: prompt + input }])
|
||||||
|
setInput("")
|
||||||
|
setHistIdx(-1)
|
||||||
|
|
||||||
|
if (!cmd) return
|
||||||
|
|
||||||
|
setHistory(h => [cmd, ...h.filter(x => x !== cmd)].slice(0, 100))
|
||||||
|
|
||||||
|
// local always-available commands
|
||||||
|
const lower = cmd.toLowerCase()
|
||||||
|
if (lower === "clear" || lower === "/clear") { setLines([]); return }
|
||||||
|
if (lower === "quit" || lower === "exit") {
|
||||||
|
setLines(prev => [...prev, { id: nextId(), kind: "info", text: "Connection closed." }])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLive && server.backendId !== null) {
|
||||||
|
setExecuting(true)
|
||||||
|
try {
|
||||||
|
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 =>
|
||||||
|
setLines(prev => [...prev, { id: nextId(), kind, text: line }])
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
setLines(prev => [...prev, { id: nextId(), kind: "error", text: `network error: ${String(err)}` }])
|
||||||
|
} finally {
|
||||||
|
setExecuting(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// mock mode
|
||||||
|
const result = mockResolve(cmd, server.name)
|
||||||
|
if (result === "__CLEAR__") { setLines([]); return }
|
||||||
|
if (result) {
|
||||||
|
result.split("\n").forEach(line =>
|
||||||
|
setLines(prev => [...prev, { id: nextId(), kind: "output", text: line }])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [executing, input, nextId, prompt, isLive, server, backendUrl])
|
||||||
|
|
||||||
|
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault(); void submit()
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
setHistIdx(i => {
|
||||||
|
const next = Math.min(i + 1, history.length - 1)
|
||||||
|
if (history[next] !== undefined) setInput(history[next])
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
setHistIdx(i => {
|
||||||
|
const next = i - 1
|
||||||
|
if (next < 0) { setInput(""); return -1 }
|
||||||
|
if (history[next] !== undefined) setInput(history[next])
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} else if (e.key === "l" && e.ctrlKey) {
|
||||||
|
e.preventDefault(); setLines([])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
function handleKey(e:React.KeyboardEvent<HTMLInputElement>){
|
const lineColor = (kind: TermLine["kind"]) => {
|
||||||
if(e.key==="Enter"){e.preventDefault();void submit()}
|
if (kind === "error") return "text-red-400"
|
||||||
else if(e.key==="ArrowUp"){e.preventDefault();const index=Math.min(histIdx+1,history.length-1);setHistIdx(index);if(history[index]!==undefined)setInput(history[index])}
|
if (kind === "prompt") return "text-emerald-400"
|
||||||
else if(e.key==="ArrowDown"){e.preventDefault();const index=histIdx-1;setHistIdx(Math.max(-1,index));setInput(index<0?"":history[index]??"")}
|
if (kind === "info") return "text-sky-400"
|
||||||
else if(e.key.toLowerCase()==="l"&&e.ctrlKey){e.preventDefault();setLines([])}
|
return "text-[#c9d1d9]"
|
||||||
}
|
}
|
||||||
const blocked=blockedReason??(!server.enabled?"Устройство отключено в каталоге.":!canExecute?"Для выполнения требуется mm:servers:write.":null)
|
|
||||||
return <Frame spacing="sm" className="h-full min-h-0 min-w-0">
|
return (
|
||||||
<FramePanel className="flex flex-1 flex-col min-h-0 min-w-0 p-0">
|
<div
|
||||||
<FrameHeader className="border-b shrink-0 flex-row items-start justify-between gap-2">
|
className="flex flex-col bg-[#0d1117] rounded-lg border border-[#30363d] overflow-hidden font-mono text-xs h-full"
|
||||||
<div className="min-w-0"><FrameTitle className="[overflow-wrap:anywhere]">{server.name}</FrameTitle><FrameDescription className="[overflow-wrap:anywhere]">{server.host} · {isLive?"RouterOS REST":"Демо"} · версия {server.rosVersion??"неизвестна"}</FrameDescription></div>
|
onClick={() => inputRef.current?.focus()}
|
||||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="Очистить вывод терминала" onClick={()=>setLines([])}><TrashIcon aria-hidden="true" className="size-4"/></Button>
|
>
|
||||||
</FrameHeader>
|
{/* title bar */}
|
||||||
<div ref={scrollRef} role="region" aria-label="Вывод терминала" tabIndex={0} onScroll={e=>{const el=e.currentTarget;followOutput.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}} className="flex-1 min-h-0 min-w-0 overflow-auto p-3 font-mono text-xs focus-visible:outline-2 focus-visible:outline-ring">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-[#30363d] bg-[#161b22] shrink-0">
|
||||||
{lines.length?lines.map(line=><div key={line.id} className={cn("whitespace-pre-wrap [overflow-wrap:anywhere] leading-5",line.kind==="error"?"text-destructive":line.kind==="prompt"?"text-primary":line.kind==="info"?"text-muted-foreground":"text-foreground")}>{line.text}</div>):<p className="text-muted-foreground">Вывод очищен. Следующая команда добавит новый результат.</p>}
|
<CircleIcon className="size-3 text-red-500 fill-red-500" />
|
||||||
</div>
|
<CircleIcon className="size-3 text-amber-400 fill-amber-400" />
|
||||||
<FrameFooter className="shrink-0 border-t flex flex-col items-stretch gap-2">
|
<CircleIcon className="size-3 text-emerald-500 fill-emerald-500" />
|
||||||
<p role="status" className="text-xs text-muted-foreground">{executing?"Выполняем команду; ожидаем ответ…":completed??"Команда ещё не выполнена."}</p>
|
<span className="mx-auto text-[#8b949e] text-[11px]">
|
||||||
{error&&<p role="alert" className="text-xs text-destructive [overflow-wrap:anywhere]">{error} Команда сохранена для повторного выполнения.</p>}
|
{server.name} — {isLive ? "RouterOS REST" : "SSH Terminal (mock)"}
|
||||||
{blocked&&<p role="status" className="text-xs text-muted-foreground">{blocked}</p>}
|
</span>
|
||||||
<label className="text-xs" htmlFor="terminal-command">Команда · {server.name}</label>
|
{isLive && (
|
||||||
<div className="flex gap-2 min-w-0"><Input ref={inputRef} id="terminal-command" value={input} maxLength={1024} onChange={e=>setInput(e.target.value)} onKeyDown={handleKey} disabled={executing||!!blocked} className="flex-1 min-w-0 font-mono text-xs" autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false}/><Button type="button" size="sm" disabled={executing||!!blocked||!input.trim()} onClick={()=>void submit()}>{executing?"Ожидание…":"Выполнить"}</Button></div>
|
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium border border-emerald-500/30 bg-emerald-500/10 text-emerald-400">
|
||||||
<div className="flex flex-col sm:flex-row gap-2 text-xs text-muted-foreground"><select aria-label="Быстрая команда" value="" disabled={executing||!!blocked} className="min-w-0 rounded-md border bg-background px-2 py-1" onChange={e=>{setInput(e.target.value);setHistIdx(-1);inputRef.current?.focus()}}><option value="">Подставить команду…</option>{QUICK_CMDS.map(item=><option key={item.cmd} value={item.cmd}>{item.label}</option>)}</select><p>↑↓ — история · Ctrl+L — очистить · до 1024 символов</p></div>
|
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
||||||
</FrameFooter>
|
</span>
|
||||||
</FramePanel>
|
)}
|
||||||
</Frame>
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setLines([]) }}
|
||||||
|
className="text-[#8b949e] hover:text-white transition-colors ml-1"
|
||||||
|
title="Очистить"
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* output */}
|
||||||
|
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 space-y-0.5 min-h-0">
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<div key={`${line.id}-${i}`} className={cn("whitespace-pre-wrap break-all leading-5", lineColor(line.kind))}>
|
||||||
|
{line.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* executing indicator */}
|
||||||
|
{executing && (
|
||||||
|
<div className="flex items-center gap-2 text-amber-400 opacity-80">
|
||||||
|
<Loader2Icon className="size-3 animate-spin" />
|
||||||
|
<span>executing…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* input line */}
|
||||||
|
{!executing && (
|
||||||
|
<div className="flex items-center gap-0">
|
||||||
|
<span className="text-emerald-400 select-none shrink-0">{prompt}</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKey}
|
||||||
|
className="terminal-input-active flex-1 bg-transparent outline-none text-[#c9d1d9] caret-emerald-400"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── quick commands ───────────────────────────────────────────────────────────
|
// ─── quick commands ───────────────────────────────────────────────────────────
|
||||||
@@ -267,47 +423,201 @@ function mockServersToTermServers(): TermServer[] {
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
host: s.host,
|
host: s.host,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
status: s.status==="unknown"?null:s.status,
|
status: s.status as TermServer["status"],
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
rosVersion: s.os ?? null,
|
rosVersion: s.os ?? null,
|
||||||
identityName: s.name,
|
identityName: s.name,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Backend ServerRead shape (only fields we need) */
|
||||||
|
interface BackendServer {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
country: string
|
||||||
|
status: "online" | "offline" | null
|
||||||
|
enabled: boolean
|
||||||
|
os: string | null
|
||||||
|
identityName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export default function TerminalPage(){
|
export default function TerminalPage() {
|
||||||
return <Suspense fallback={<p role="status" className="p-4">Загружаем терминал…</p>}><TerminalRoute/></Suspense>
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
}
|
const isLive = mode === "live"
|
||||||
function TerminalRoute(){
|
|
||||||
const {mode,backendUrl,prefsHydrated}=useDataSource(),params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
|
// Server list state
|
||||||
function reset(){const href=patchTerminalUrl(new URLSearchParams(params.toString()),null)+window.location.hash;window.history.replaceState(null,"",href)}
|
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
||||||
if(url.error)return <Alert variant="destructive"><AlertTitle>Некорректная ссылка терминала</AlertTitle><AlertDescription>{url.error}<Button type="button" variant="outline" onClick={reset}>Сбросить контекст терминала</Button></AlertDescription></Alert>
|
const [serversLoading, setServersLoading] = useState(false)
|
||||||
if(!prefsHydrated)return <p role="status" className="p-4">Загружаем источник…</p>
|
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
|
||||||
return <TerminalContent key={JSON.stringify([mode,backendUrl])}/>
|
|
||||||
}
|
// Load servers from backend when in live mode
|
||||||
function TerminalContent(){
|
useEffect(() => {
|
||||||
const {mode,backendUrl}=useDataSource(),isLive=mode==="live",params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
|
if (!isLive || !prefsHydrated) return
|
||||||
const [liveServers,setLiveServers]=useState<TermServer[]>([]),[loading,setLoading]=useState(false),[error,setError]=useState<string|null>(null),[loaded,setLoaded]=useState(false),[readAt,setReadAt]=useState<string|null>(null),[refresh,setRefresh]=useState(0)
|
let cancelled = false
|
||||||
const canRead=can("mm:servers:read"),canExecute=!isLive||can("mm:servers:write")
|
queueMicrotask(() => {
|
||||||
useEffect(()=>{
|
if (cancelled) return
|
||||||
if(!isLive||!canRead)return
|
setServersLoading(true)
|
||||||
let cancelled=false;const controller=new AbortController()
|
void requestJson<BackendServer[]>(backendUrl, "/api/servers")
|
||||||
queueMicrotask(()=>{if(cancelled)return;setLoading(true);void requestJson<unknown>(backendUrl,"/api/servers",{signal:controller.signal}).then(raw=>{
|
.then((data) => {
|
||||||
const rows=terminalServersSchema.parse(raw);if(cancelled)return
|
if (cancelled) return
|
||||||
setLiveServers(rows.map(row=>({uid:String(row.id),backendId:row.id,name:row.name||row.host,host:row.host,country:row.country,status:row.status,enabled:row.enabled,rosVersion:row.os??null,identityName:row.identityName??null})));setLoaded(true);setReadAt(new Date().toLocaleTimeString("ru-RU"));setError(null)
|
setLiveServers(data.map(s => ({
|
||||||
}).catch(err=>{if(!cancelled)setError(err instanceof Error?(err.name==="ZodError"?"Некорректный каталог устройств.":err.message):"Не удалось загрузить устройства.")}).finally(()=>{if(!cancelled)setLoading(false)})})
|
uid: String(s.id),
|
||||||
return()=>{cancelled=true;controller.abort()}
|
backendId: s.id,
|
||||||
},[isLive,backendUrl,canRead,refresh])
|
name: s.name || s.host,
|
||||||
const termServers=isLive?liveServers:mockServersToTermServers()
|
host: s.host,
|
||||||
const matches=url.server?termServers.filter(server=>server.uid===url.server):url.host?termServers.filter(server=>server.host===url.host):[]
|
country: s.country || "",
|
||||||
const selected=matches.length===1?matches[0]:null
|
status: s.status,
|
||||||
const unavailable=(loaded||!isLive)&&(url.server||url.host)&&!selected
|
enabled: s.enabled,
|
||||||
const items=termServers.map(server=>({id:server.uid,name:server.name,host:server.host,country:server.country||undefined,status:server.status??"unknown" as const,enabled:server.enabled,selectable:true,title:[server.name,server.host].join(" · ")}))
|
rosVersion: s.os,
|
||||||
function select(id:string){const href=patchTerminalUrl(new URLSearchParams(params.toString()),id||null)+window.location.hash;window.history.pushState(null,"",href)}
|
identityName: s.identityName,
|
||||||
const blocked=!!error||loading||isLive&&!loaded
|
})))
|
||||||
return <ServerRailLayout items={items} selectedId={selected?.uid??url.server??""} onSelect={select} preserveSelection showAll={false} showCount={false} showType={false} loading={loading} contentClassName="min-h-0 min-w-0 overflow-hidden p-2 md:p-3"
|
setServersLoading(false)
|
||||||
header={<PageHeader crumbs={[{label:"Инструменты"},{label:"Терминал"}]} actions={<><ServerRailMobileButton/>{isLive&&<Button type="button" variant="outline" size="sm" disabled={loading||!canRead} onClick={()=>setRefresh(value=>value+1)}><RefreshCwIcon aria-hidden="true" className="size-4"/>Обновить устройства</Button>}</>}/>}
|
})
|
||||||
banner={<div className="px-3 py-2 space-y-2"><p role="status" className="text-xs text-muted-foreground">{isLive?(readAt?"Каталог · ответ "+readAt+" (время браузера)":"Каталог ещё не подтверждён."):"Демо: команды не отправляются на устройство."} REST выполняет только поддерживаемые диагностические команды.</p>{!canRead&&isLive&&<Alert variant="destructive"><AlertTitle>Нет доступа к каталогу</AlertTitle><AlertDescription>Требуется mm:servers:read.</AlertDescription></Alert>}{error&&<Alert variant="destructive"><AlertTitle>{loaded?"Каталог не обновлён":"Устройства пока неизвестны"}</AlertTitle><AlertDescription>{error} {loaded?"Сохранён предыдущий каталог; выполнение приостановлено до успешного обновления.":"Повторите загрузку устройств."}</AlertDescription></Alert>}{unavailable&&<Alert variant="destructive"><AlertTitle>Устройство терминала недоступно</AlertTitle><AlertDescription>{matches.length>1?"Host неоднозначен: выберите точное устройство.":"Указанный server/host отсутствует в текущем каталоге."}<Button type="button" variant="outline" onClick={()=>select("")}>Сбросить выбор устройства</Button></AlertDescription></Alert>}</div>}>
|
.catch(() => { if (!cancelled) setServersLoading(false) })
|
||||||
{selected?<Terminal key={JSON.stringify([selected.uid,selected.host])} server={selected} isLive={isLive} backendUrl={backendUrl} canExecute={canExecute} blockedReason={blocked?"Каталог не подтверждён; выполнение приостановлено до успешного обновления.":undefined}/>:<Frame spacing="sm" className="h-full min-h-0"><FramePanel className="h-full flex flex-col gap-2 items-center justify-center p-4 text-center"><FrameTitle>{loading?"Загружаем устройства…":loaded&&!termServers.length?"Устройств пока нет":"Выберите устройство для терминала"}</FrameTitle><FrameDescription>Выбор сохраняется в URL. Команда отправляется только после нажатия «Выполнить».</FrameDescription></FramePanel></Frame>}
|
})
|
||||||
</ServerRailLayout>
|
return () => { cancelled = true }
|
||||||
|
}, [isLive, backendUrl, refreshKey, prefsHydrated])
|
||||||
|
|
||||||
|
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
|
||||||
|
|
||||||
|
// Select first available server by default
|
||||||
|
const [selectedUid, setSelectedUid] = useState<string>("")
|
||||||
|
|
||||||
|
const defaultUid = useMemo(() => {
|
||||||
|
const first = termServers.find(s => s.status !== "offline" && s.enabled)
|
||||||
|
?? termServers[0]
|
||||||
|
return first?.uid ?? ""
|
||||||
|
}, [termServers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!selectedUid && defaultUid) setSelectedUid(defaultUid)
|
||||||
|
})
|
||||||
|
}, [defaultUid, selectedUid])
|
||||||
|
|
||||||
|
// Reset selection when switching modes
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => setSelectedUid(""))
|
||||||
|
}, [isLive])
|
||||||
|
|
||||||
|
const selected = termServers.find(s => s.uid === selectedUid) ?? termServers[0]
|
||||||
|
|
||||||
|
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
|
||||||
|
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set
|
||||||
|
nativeSetter?.call(el, cmd)
|
||||||
|
el.dispatchEvent(new Event("input", { bubbles: true }))
|
||||||
|
el.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
server={selected}
|
||||||
|
isLive={isLive}
|
||||||
|
backendUrl={backendUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
|
||||||
|
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ServerRailLayout>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
+364
-401
File diff suppressed because it is too large.
Load diff
+503
-597
File diff suppressed because it is too large.
Load diff
Loaded 100 of 3697 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user