Compare commits

...
22 Commits
Author SHA1 Message Date
DenozordecandCursor 0e9349e508 feat(auth): интегрировать SSO auth-portal
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m52s
Docker images / frontend-image (push) Successful in 2m23s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 8s
JWT на backend, handoff/callback на UI, RBAC mm:*, AUTH_* в compose.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 20:38:28 +07:00
DenozordecandCursor 0208aa4d7c chore(deploy): добавить Traefik-стеки и общий compose с CDNManager
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 3m23s
Docker images / frontend-image (push) Successful in 2m1s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 6s
Единый стек CDN+MM, вариант рядом с CDN Traefik и CLI-скрипт запуска.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 19:07:49 +07:00
Denozordec 42a2e18047 docs: обновить раздел о прод-развёртывании Docker и изменить ссылки на репозиторий
Docker images / prepare-release (push) Successful in 23s
Docker images / backend-image (push) Failing after 1m30s
Docker images / frontend-image (push) Successful in 3m45s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 50s
Docker images / publish-release (push) Skipped
2026-09-04 18:54:26 +07:00
DenozordecandCursor 9df3971f6c chore(reui): обновить ReUI agent skill до актуальной версии
Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Successful in 5m58s
Docker images / frontend-image (push) Successful in 2m55s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 53s
Docker images / publish-release (push) Successful in 6s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 20:03:55 +07:00
DenozordecandCursor 7ed269cf2c ci: переключить registry на git.shx.one
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 4m25s
Docker images / frontend-image (push) Successful in 2m10s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 6s
Обновить Gitea Actions, compose, updater и ссылки на образы.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 16:43:40 +07:00
Denozordec 7f81c304ae fix(config): добавить ключ лицензии REUI в настройки локальной конфигурации
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Failing after 2m40s
Docker images / frontend-image (push) Successful in 1m49s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Skipped
2026-08-17 15:42:10 +07:00
DenozordecandCursor 5f29cfaf0f fix(ui): выровнять chrome Frame и убрать Card-оболочки
Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 15:36:56 +07:00
Denozordec d2de8d3188 fix(ui): улучшить функциональность панели данных и заменить устаревшие компоненты
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 2m7s
Docker images / updater-image (push) Successful in 41s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-06-30 22:36:00 +07:00
Denozordec d3a2d38b37 fix(ui): заменить таблицы на карточки данных и улучшить функциональность поиска
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 1m50s
Docker images / updater-image (push) Successful in 44s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-06-30 22:22:51 +07:00
Denozordec a1a9124f3d fix(config): добавить новые зависимости и обновить конфигурацию компонентов
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-06-30 21:30:40 +07:00
Denozordec 009011a917 chore(docs): обновить инструкции по локальному запуску проекта
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 3m42s
Docker images / frontend-image (push) Successful in 2m40s
Docker images / updater-image (push) Successful in 53s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-06-30 20:07:20 +07:00
Denozordec 399871f4f9 fix(config): улучшить обработку URL для бэкенда и увеличить таймаут запросов
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 1m39s
Docker images / updater-image (push) Successful in 50s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-05-12 23:19:51 +07:00
Denozordec 158fc36294 fix(api): увеличить лимит размера тела запроса и улучшить обработку URL для API
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 1m40s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 23:11:23 +07:00
Denozordec 7dc4836c71 fix(dashboard): улучшить логику загрузки данных на странице панели управления
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 1m47s
Docker images / updater-image (push) Successful in 54s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:47:30 +07:00
Denozordec efc3812e12 fix(updater): обновить URL для проверки состояния сервиса 2026-05-12 22:46:16 +07:00
Denozordec 8d5fd84962 fix(data-collection): добавить возможность отключения переключателей и улучшить обработку настроек
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:37:55 +07:00
Denozordec f69e65b014 fix(docker): добавить переменную окружения для внутреннего URL бэкенда
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m34s
Docker images / frontend-image (push) Successful in 1m38s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 22:21:57 +07:00
Denozordec 49d14a00af fix(data-collection): улучшить обработку ошибок и загрузку данных с API
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:11:49 +07:00
Denozordec 9ab2418a5f feat(backup): добавить таблицу для резервных копий и функции для работы с ними
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m35s
Docker images / frontend-image (push) Successful in 1m42s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 21:56:13 +07:00
Denozordec 63aa9d424b chore(backups): удалить устаревшие резервные копии Gateway
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m38s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 21:55:41 +07:00
Denozordec 43ab17b253 feat(scheduler): добавить расписание бэкапов и автообновление сертификатов
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 2m11s
Docker images / updater-image (push) Successful in 50s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-05-12 21:46:11 +07:00
Denozordec 4b8cf83ee9 fix(backend): улучшить управление блокировками и очистку устаревших директорий
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m34s
Docker images / frontend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 45s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 21:28:08 +07:00
267 changed files with 31257 additions and 163371 deletions
+67
View File
@@ -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
+43
View File
@@ -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.
+60
View File
@@ -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).
+408
View File
@@ -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.
+45
View File
@@ -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.
+39
View File
@@ -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.
+22
View File
@@ -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.
+35
View File
@@ -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.
+26
View File
@@ -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.
+54
View File
@@ -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.
+58
View File
@@ -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 -1
View File
@@ -11,7 +11,7 @@ const outputDir = process.env.RELEASE_OUTPUT_DIR
? join(repoRoot, process.env.RELEASE_OUTPUT_DIR)
: join(repoRoot, ".ci", "release")
const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? ""
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shts.su").replace(/\/$/, "")
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shx.one").replace(/\/$/, "")
const MANIFEST_COMMIT_LIMIT = Number(process.env.RELEASE_MANIFEST_COMMIT_LIMIT ?? 6)
const CONVENTIONAL_RE =
+144
View File
@@ -0,0 +1,144 @@
import re
from pathlib import Path
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
text = path.read_text(encoding="utf-8")
text = text.replace(
'import { Fragment, useEffect, useMemo, useState } from "react"\n'
'import { PageHeader } from "@/components/page-header"\n'
'import { StatusBadge } from "@/components/status-badge"',
'import { useEffect, useMemo, useState } from "react"\n'
'import { PageHeader } from "@/components/page-header"\n'
'import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"\n'
'import { DataPageToolbar } from "@/components/data-page-toolbar"\n'
'import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"',
1,
)
text = text.replace(
"""import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu"
""",
"""import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
""",
1,
)
text = text.replace(
"""import {
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
ChevronRightIcon, ChevronDownIcon,
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
HomeIcon, ServerIcon, NetworkIcon,
} from "lucide-react\"""",
"""import {
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
EyeIcon, EyeOffIcon,
ChevronRightIcon, ChevronDownIcon,
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
ShieldIcon, WifiIcon,
HomeIcon, ServerIcon, NetworkIcon,
} from "lucide-react\"""",
1,
)
text = re.sub(
r"// ─── RouterOS version utilities.*?// ─── Countries ─",
"// ─── Countries ─",
text,
count=1,
flags=re.S,
)
text = re.sub(
r"// ─── Type config ─.*?// ─── Shared small components ─",
"// ─── Shared small components ─",
text,
count=1,
flags=re.S,
)
text = re.sub(
r"function Field\(\{ label, hint, required, children \}:.*?^}\n\n// ─── Country field",
"// ─── Country field",
text,
count=1,
flags=re.S | re.M,
)
text = text.replace(
"const [expandedId, setExpandedId] = useState<string | null>(null)",
"const [sheetStep, setSheetStep] = useState(1)",
1,
)
text = text.replace(
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setOpen(true)',
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setSheetStep(1)\n setOpen(true)',
1,
)
text = text.replace(
'setTestState("idle"); setTestMsg(""); setOpen(true)',
'setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)',
1,
)
text = re.sub(r"<Field\b", "<FormField", text)
text = re.sub(r"</Field>", "</FormField>", text)
text = re.sub(r"<Toggle\b", "<FormToggle", text)
new_table = """ {/* Table */}
<Card>
<DataPageToolbar
segmented={{
value: typeFilter,
onChange: setTypeFilter,
options: tabs.map((tab) => ({
value: tab.value,
label: tab.label,
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
})),
}}
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, хосту…"
countLabel={`${filtered.length} серверов`}
/>
<ServersDataGrid
servers={filtered}
isLive={isLive}
pollingIds={pollingIds}
onPoll={handlePoll}
onEdit={openEdit}
onDelete={handleDelete}
onToggleStatus={handleToggleStatus}
/>
</Card>
"""
text = re.sub(
r" \{/\* Table \*/\}\n <Card>.*?</Card>\n",
new_table,
text,
count=1,
flags=re.S,
)
path.write_text(text, encoding="utf-8")
print("patched", path)
+144
View File
@@ -0,0 +1,144 @@
import re
from pathlib import Path
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
text = path.read_text(encoding="utf-8")
# Wrap sheet form in stepper
text = text.replace(
""" <div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
{/* 1. Основные */}
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>""",
""" <Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Основные</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">WAN</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">API</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={4}>
<StepperTrigger>
<StepperIndicator>4</StepperIndicator>
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-4">""",
1,
)
text = text.replace(
""" </div>
{/* 2. WAN-аплинки (только для home-router) */}
{isHomeRouter && (
<div className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>""",
""" </StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>""",
1,
)
text = text.replace(
""" </div>
)}
{/* 3. Подключение (API) */}
<div className="flex flex-col gap-4">
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
""" </StepperContent>
<StepperContent value={3} className="flex flex-col gap-4">
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
1,
)
text = text.replace(
""" </div>
{/* 4. Дополнительно */}
<div className="flex flex-col gap-4">
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
""" </StepperContent>
<StepperContent value={4} className="flex flex-col gap-4">
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
1,
)
text = text.replace(
""" </div>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1" onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
</SheetFooter>""",
""" </StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)}
{sheetStep < 4 ? (
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : (
<Button className="ml-auto" onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
)}
</SheetFooter>""",
1,
)
# WAN step 2: show message when not home router
text = text.replace(
""" <StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>
<WanUplinkEditor""",
""" <StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>
{!isHomeRouter ? (
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
) : (
<WanUplinkEditor""",
1,
)
text = text.replace(
""" />
</StepperContent>
<StepperContent value={3}""",
""" />
)}
</StepperContent>
<StepperContent value={3}""",
1,
)
path.write_text(text, encoding="utf-8")
print("stepper patched")
+6 -3
View File
@@ -12,9 +12,9 @@
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
"WebFetch(domain:git.shts.su)",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
"WebFetch(domain:git.shx.one)",
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
"Bash(node -e ' *)",
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
@@ -63,5 +63,8 @@
"Bash(xargs grep *)",
"Bash(npx shadcn@latest add popover --yes)"
]
},
"env": {
"REUI_LICENSE_KEY": "REUI-EAF4-EA40-6C5C-5D86"
}
}
+67
View File
@@ -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
+43
View File
@@ -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.
+60
View File
@@ -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).
+408
View File
@@ -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.
+45
View File
@@ -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.
+39
View File
@@ -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.
+22
View File
@@ -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.
+35
View File
@@ -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.
+26
View File
@@ -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.
+54
View File
@@ -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.
+58
View File
@@ -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
+16
View File
@@ -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
+30 -5
View File
@@ -5,15 +5,40 @@ alwaysApply: true
# Локальный запуск проекта
Для запуска всего проекта в dev-режиме поднимать два процесса:
## Требования
- **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
```
- Frontend: `http://localhost:3000`
- Backend: `http://localhost:8000`
- Health check backend: `http://localhost:8000/health`
| Сервис | URL | Проверка |
|--------|-----|----------|
| Frontend (Next.js 16, Turbopack) | http://localhost:3000 | открыть в браузере |
| Backend (Fastify) | http://localhost:8000 | `GET /health` |
Если пользователь просит “запусти проект”, “запусти фронт и бэк” или похожую команду, сначала проверь уже запущенные терминалы, затем запускай эти две команды отдельными long-running процессами.
Проверка 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`, раздел «Запуск».
+1 -1
View File
@@ -18,7 +18,7 @@ alwaysApply: false
## Эталон UI
- **`app/(main)/servers`** — главный эталон layout, сетки, типографики, композиции и взаимодействий. Не вводить новый UI-паттерн, если на `/servers` уже есть эквивалент.
- **`app/(main)/servers`** — эталон **поведения** CRUD. Оболочка ops: **Frame + DataGrid**, не Card-shell. Не копировать `DataPageCard` на новые экраны.
## Качество и совместимость
+15 -15
View File
@@ -1,11 +1,11 @@
---
description: "[Multi-agent] Роль UI GUARDIAN — сверка UI только с эталоном /servers"
description: "[Multi-agent] Роль UI GUARDIAN — сверка UI с CRUD /servers и оболочкой Frame+DataGrid"
alwaysApply: false
---
# Агент: UI GUARDIAN (хранитель UI)
Ты отвечаешь **только** за **визуальную и UX-согласованность** с эталоном **`/servers`**.
Ты отвечаешь **только** за **визуальную и UX-согласованность**.
## Язык
@@ -13,31 +13,31 @@ alwaysApply: false
## Эталон
- Каталог **`app/(main)/servers`** и связанные компоненты страницы — **единственный** эталон для:
- структуры layout (области страницы, карточки, секции);
- сетки и отступов;
- типографики (заголовки, подписи, плотность текста);
- композиции shadcn-компонентов;
- паттернов взаимодействия (кнопки, таблицы, диалоги, формы — как на `/servers`).
Два слоя (не смешивать):
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-решение в изменениях с тем, как сделано на `/servers`.
- Требовать **переиспользование** существующих компонентов/обёрток из этой зоны, если они покрывают задачу.
- Сравнить **каждое** спорное UI-решение: CRUD — с `/servers`; оболочка — с Frame+DataGrid.
- Требовать **переиспользование** существующих компонентов/обёрток, если они покрывают задачу.
## Правила
- **Нельзя** принимать UI, который **системно расходится** с `/servers` без явной технической необходимости (которую нужно назвать).
- **Нельзя** принимать новый Card page-shell без явной технической необходимости (назвать её).
- **Нельзя** предлагать «новый стиль» ради вариации — только выравнивание с эталоном или расширение существующих примитивов.
## Формат вывода (строго)
1. **Несоответствия** эталону (конкретно: что именно отличается).
1. **Несоответствия** эталону (конкретно: CRUD vs Frame-shell).
2. **Обязательные правки** (что поменять).
3. **Что переиспользовать** с `/servers`: файлы/компоненты и паттерн.
3. **Что переиспользовать**: файлы/компоненты и паттерн.
Если расхождений нет — явно: **«Расхождений с эталоном /servers не выявлено»** и кратко перечисли проверенные аспекты.
Если расхождений нет — явно: **«Расхождений с эталоном CRUD /servers и Frame+DataGrid не выявлено»** и кратко перечисли проверенные аспекты.
## Граница ответственности
- Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности с /servers** и **reuse** UI.
- Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности** и **reuse** UI.
+8 -7
View File
@@ -16,11 +16,12 @@ alwaysApply: true
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
- Не выдумывать API и «недокументированные» паттерны; предпочитать стабильные, описанные в доках решения.
## 2. Эталон UI/UX: страница `/servers` (критично)
## 2. Эталон UI/UX: `/servers` (CRUD) + Frame+DataGrid (оболочка)
- **`app/(main)/servers`** (и связанные компоненты) — **главный эталон** дизайна и поведения.
- Выравнивать: layout, отступы, сетку, типографику, структуру компонентов, паттерны взаимодействия.
- Переиспользовать оттуда же компоненты и паттерны; **не вводить новый UI-паттерн**, если эквивалент уже есть на `/servers`.
- **`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. Большой репозиторий (критично)
@@ -50,7 +51,7 @@ alwaysApply: true
В ответе явно указать:
- **Почему** это согласуется с Next.js, shadcn/ui и эталоном **`/servers`**.
- **Почему** это согласуется с Next.js, shadcn/ui, CRUD `/servers` и оболочкой Frame+DataGrid.
- **Server vs Client Component** и обоснование.
- Что **переиспользовано** из проекта (паттерны/компоненты).
- Какие **файлы проанализированы**.
@@ -68,7 +69,7 @@ alwaysApply: true
1. **Шаг 0:** список проанализированных файлов.
2. **Шаг 1:** что изменено.
3. **Шаг 2:** почему (Next.js + shadcn/ui + `/servers` + паттерны кодовой базы).
3. **Шаг 2:** почему (Next.js + shadcn/ui + CRUD `/servers` + Frame+DataGrid + паттерны кодовой базы).
4. **Шаг 3:** анализ влияния (что ещё затронуто).
5. **Шаг 4:** код (диффы или несколько файлов).
6. **Шаг 5:** проверка dev/консоли (ошибки, предупреждения, исправления).
@@ -80,4 +81,4 @@ alwaysApply: true
## Цель
Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, выравнивание с `/servers`, безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности.
Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, CRUD как на `/servers`, оболочка Frame+DataGrid (не Card-shell), безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности.
+73
View File
@@ -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
+67
View File
@@ -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
+43
View File
@@ -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.
+60
View File
@@ -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).
+408
View File
@@ -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.
+45
View File
@@ -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.
+39
View File
@@ -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.
+22
View File
@@ -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.
+35
View File
@@ -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.
+26
View File
@@ -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.
+54
View File
@@ -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.
+58
View File
@@ -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
+6 -5
View File
@@ -7,8 +7,8 @@ on:
workflow_dispatch:
env:
REGISTRY: git.shts.su
GITEA_SERVER_URL: https://git.shts.su
REGISTRY: git.shx.one
GITEA_SERVER_URL: https://git.shx.one
jobs:
prepare-release:
@@ -76,7 +76,7 @@ jobs:
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shts.su
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
@@ -161,7 +161,7 @@ jobs:
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shts.su
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
@@ -177,6 +177,7 @@ jobs:
NEXT_PUBLIC_BACKEND_URL=same-origin
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
tags: |
@@ -214,7 +215,7 @@ jobs:
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shts.su
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
+6
View File
@@ -49,3 +49,9 @@ Thumbs.db
tmp/
temp/
.ci/docker/
.claude/settings.local.json
# Local MCP configs (may contain REUI license Bearer)
.cursor/mcp.json
.mcp.json
opencode.json
+67
View File
@@ -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
+43
View File
@@ -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.
+60
View File
@@ -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).
+408
View File
@@ -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.
+45
View File
@@ -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.
+39
View File
@@ -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.
+22
View File
@@ -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.
+35
View File
@@ -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.
+26
View File
@@ -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.
+54
View File
@@ -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.
+58
View File
@@ -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
+10
View File
@@ -10,3 +10,13 @@
4. Локальный hook `.githooks/commit-msg` отклоняет subject без кириллицы; подключение — `npm install` / `npm run prepare`.
Полные правила: `.cursor/rules/commit-messages-ru.mdc`, semver — `.cursor/rules/release-versioning.mdc`.
## Локальный запуск
Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`.
## ReUI
Skill: `.claude/skills/reui` / `.cursor/skills/reui` — версия `668fb463eb` (20 free components).
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 -` из корня проекта.
+3
View File
@@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
ARG NEXT_PUBLIC_APP_VERSION=dev
ARG NEXT_PUBLIC_RELEASE_URL=
ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
ENV NEXT_PUBLIC_AUTH_PORTAL_URL=$NEXT_PUBLIC_AUTH_PORTAL_URL
COPY packages/contracts packages/contracts
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
COPY app app
@@ -40,6 +42,7 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
ENV BACKEND_INTERNAL_URL=http://backend:8000
COPY --from=build /app/public ./public
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
+81 -26
View File
@@ -23,13 +23,13 @@
| Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend |
| Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` |
| Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — |
| CI | `.gitea/workflows/docker.yml` | Gitea Actions, Buildx | — | push в `git.shts.su` |
| CI | `.gitea/workflows/docker.yml` | Gitea Actions, Buildx | — | push в `git.shx.one` |
Имена образов в registry для репозитория `denozord/MikrotikManager-3`:
- `git.shts.su/denozord/mikrotikmanager-backend`
- `git.shts.su/denozord/mikrotikmanager-frontend`
- `git.shts.su/denozord/mikrotikmanager-updater`
- `git.shx.one/denozord/mikrotikmanager-backend`
- `git.shx.one/denozord/mikrotikmanager-frontend`
- `git.shx.one/denozord/mikrotikmanager-updater`
Для другого owner/repo подставьте имя по правилу CI (см. [CI/CD](#cicd-gitea-actions)).
@@ -54,7 +54,7 @@ flowchart TB
subgraph ci [Gitea Actions]
WF[".gitea/workflows/docker.yml"]
WF --> Reg["git.shts.su registry"]
WF --> Reg["git.shx.one registry"]
end
subgraph prod [Прод-сервер]
@@ -192,9 +192,9 @@ npm --prefix backend run db:studio
| Job | Build context | Dockerfile | Имя образа |
|-----|---------------|------------|------------|
| `backend-image` | `.ci/docker/backend` (staging в CI) | `backend/Dockerfile` | `git.shts.su/<owner>/<stem>-backend` |
| `frontend-image` | `.ci/docker/frontend` | `Dockerfile.frontend` | `git.shts.su/<owner>/<stem>-frontend` |
| `updater-image` | `deploy/updater` | `deploy/updater/Dockerfile` | `git.shts.su/<owner>/<stem>-updater` |
| `backend-image` | `.ci/docker/backend` (staging в CI) | `backend/Dockerfile` | `git.shx.one/<owner>/<stem>-backend` |
| `frontend-image` | `.ci/docker/frontend` | `Dockerfile.frontend` | `git.shx.one/<owner>/<stem>-frontend` |
| `updater-image` | `deploy/updater` | `deploy/updater/Dockerfile` | `git.shx.one/<owner>/<stem>-updater` |
- `<owner>` — первая часть `gitea.repository`, lower case.
- `<stem>` — имя репозитория lower case без суффикса `-<цифры>` в конце (например `MikrotikManager-3``mikrotikmanager`).
@@ -212,22 +212,77 @@ npm --prefix backend run db:studio
- **`feat` / `feat!` / `BREAKING CHANGE`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
- Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:<sha>`.
- Первый релиз без тега `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.shts.su` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
- UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта).
- Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump.
- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc); subject и body — **на русском**, префикс Conventional Commits — на английском.
## Прод-развёртывание Docker
Эталон: `deploy/docker-compose.yml`. Рабочий каталог для команд compose — `deploy/` (или укажите `-f deploy/docker-compose.yml` из корня репозитория).
Эталон без reverse-proxy: `deploy/docker-compose.yml` (порты `3000` / `8000` на хост).
Стек с Traefik + HTTPS (Let's Encrypt DNS-01 / Cloudflare), по аналогии с CDNManager: [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml) + [`deploy/env.traefik.example`](deploy/env.traefik.example). На сервере публикуются только `:80`/`:443`; frontend получает HTTPS, `/api` и `/health` проксируются на backend внутри сети `mmapp`. Домен по умолчанию: `mm.shnt.top`.
### CDN Manager + MikrotikManager (один Traefik)
Полный стек: Traefik + `cdn.shnt.top` + `mm.shnt.top` в одном Compose.
| Файл | Назначение |
|------|------------|
| [`deploy/docker-compose.cdn-mm.yml`](deploy/docker-compose.cdn-mm.yml) | Traefik + CDN Manager + MM backend/frontend/updater |
| [`deploy/env.cdn-mm.example`](deploy/env.cdn-mm.example) | общий `.env` |
```bash
mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
cp deploy/env.cdn-mm.example /opt/cdn-mm/.env
cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
# заполнить CF_DNS_API_TOKEN, CLOUDFLARE_API_TOKEN, AUTH_JWT_SECRET, CORS_ORIGIN, …
docker login git.shx.one
cd /opt/cdn-mm && docker compose pull && docker compose up -d
curl -fsS https://cdn.shnt.top/health
curl -fsS https://mm.shnt.top/health
```
Данные: `./data/cdn` (CDN), `./data/mm` (MM). Сеть Traefik: `edge`. Не запускайте параллельно standalone `docker-compose.traefik.yml` CDNManager или MM на тех же 80/443.
**Если на сервере уже крутится CDNManager Traefik** (`cdnmanager-traefik`, сеть `cdnmanager`) — **не** поднимайте второй Traefik. Варианты:
| Способ | Файл |
|--------|------|
| Compose без своего Traefik | [`deploy/docker-compose.traefik-cdn.yml`](deploy/docker-compose.traefik-cdn.yml) |
| Plain `docker` CLI (скрипт) | [`deploy/run-beside-cdn-traefik.sh`](deploy/run-beside-cdn-traefik.sh) |
Frontend вешается в сеть `cdnmanager` с Traefik-labels; backend/updater остаются в `mmapp`. Сертификат для `MM_DOMAIN` выпускает уже работающий Traefik CDNManager (тот же `letsencrypt` / Cloudflare DNS-01).
```bash
# Compose (рекомендуется)
mkdir -p /opt/mmapp/{data,state,updater}
cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
cp deploy/env.traefik.example /opt/mmapp/.env # MM_DOMAIN + CORS_ORIGIN
cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
cd /opt/mmapp && docker compose pull && docker compose up -d
# Или одной CLI-командой (скрипт сам сделает network/pull/run/connect):
curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
chmod +x /tmp/run-beside-cdn-traefik.sh
sudo MM_DOMAIN=mm.shnt.top CORS_ORIGIN=https://mm.shnt.top /tmp/run-beside-cdn-traefik.sh
```
DNS: `A`/`AAAA` для `mm.shnt.top` → IP VPS, Cloudflare **DNS only**. Проверка: `curl -fsS https://mm.shnt.top/health`.
SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) (app id `mm`).
Рабочий каталог для команд compose — `deploy/` (или `-f deploy/docker-compose.yml` / `-f deploy/docker-compose.traefik.yml` / `-f deploy/docker-compose.traefik-cdn.yml` / `-f deploy/docker-compose.cdn-mm.yml` из корня).
### Прод-контейнеры
| Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` |
|--------|------------------|----------------|----------------------|------|-----------|
| backend | `mmapp-backend` | `git.shts.su/denozord/mikrotikmanager-backend:latest` | `8000:8000` | `backend-data``/app/data` | `unless-stopped` |
| frontend | `mmapp-frontend` | `git.shts.su/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
| updater | `mmapp-updater` | `git.shts.su/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state``/state`, `targets.json``/etc/updater/targets.json:ro` | `unless-stopped` |
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | `backend-data``/app/data` | `unless-stopped` |
| frontend | `mmapp-frontend` | `git.shx.one/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
| updater | `mmapp-updater` | `git.shx.one/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state``/state`, `targets.json``/etc/updater/targets.json:ro` | `unless-stopped` |
Метки для updater на backend и frontend:
@@ -258,14 +313,14 @@ npm --prefix backend run db:studio
| `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса |
| `HEALTH_TIMEOUT_SECONDS` | `120` | ожидание HTTP health |
| `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` |
| `REGISTRY` | `git.shts.su` | registry для `docker login` |
| `REGISTRY` | `git.shx.one` | registry для `docker login` |
| `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) |
| `REGISTRY_PASSWORD` | пусто | пароль registry |
### Docker Compose
```bash
docker login git.shts.su
docker login git.shx.one
export CORS_ORIGIN=http://<хост>:3000
export REGISTRY_USERNAME=<user>
export REGISTRY_PASSWORD=<token>
@@ -307,8 +362,8 @@ docker run -d \
-v backend-data:/app/data \
--label mmapp.updater.managed=true \
--label mmapp.updater.target=backend \
--label mmapp.updater.image=git.shts.su/denozord/mikrotikmanager-backend:latest \
git.shts.su/denozord/mikrotikmanager-backend:latest
--label mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-backend:latest \
git.shx.one/denozord/mikrotikmanager-backend:latest
```
**Frontend:**
@@ -320,8 +375,8 @@ docker run -d \
-p 3000:3000 \
--label mmapp.updater.managed=true \
--label mmapp.updater.target=frontend \
--label mmapp.updater.image=git.shts.su/denozord/mikrotikmanager-frontend:latest \
git.shts.su/denozord/mikrotikmanager-frontend:latest
--label mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:latest \
git.shx.one/denozord/mikrotikmanager-frontend:latest
```
**Updater:**
@@ -331,7 +386,7 @@ docker volume create updater-state
docker run -d \
--name mmapp-updater \
--restart unless-stopped \
-e REGISTRY=git.shts.su \
-e REGISTRY=git.shx.one \
-e REGISTRY_USERNAME=<user> \
-e REGISTRY_PASSWORD=<token> \
-e POLL_INTERVAL_SECONDS=300 \
@@ -340,7 +395,7 @@ docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
-v updater-state:/state \
-v /path/to/targets.json:/etc/updater/targets.json:ro \
git.shts.su/denozord/mikrotikmanager-updater:latest
git.shx.one/denozord/mikrotikmanager-updater:latest
```
### Проверка после деплоя
@@ -399,7 +454,7 @@ bash deploy/updater/validate.sh
## Первичная настройка сервера
Пошагово на чистом Linux-хосте с доступом в интернет и к `git.shts.su`.
Пошагово на чистом Linux-хосте с доступом в интернет и к `git.shx.one`.
1. Установить Docker Engine и плагин Compose (официальная документация Docker для вашего дистрибутива).
2. Проверить:
@@ -413,7 +468,7 @@ docker compose version
4. Войти в registry:
```bash
docker login git.shts.su
docker login git.shx.one
```
5. Получить файлы деплоя: клонировать репозиторий или скопировать каталог `deploy/` и подготовить `deploy/updater/targets.json`.
@@ -443,8 +498,8 @@ Updater опрашивает registry с интервалом `POLL_INTERVAL_SEC
### Ручное обновление
```bash
docker pull git.shts.su/denozord/mikrotikmanager-backend:latest
docker pull git.shts.su/denozord/mikrotikmanager-frontend:latest
docker pull git.shx.one/denozord/mikrotikmanager-backend:latest
docker pull git.shx.one/denozord/mikrotikmanager-frontend:latest
```
Через compose (пересоздание при смене образа):
@@ -485,7 +540,7 @@ docker start mmapp-backend
1. Установить Docker Engine и Compose plugin; проверить `docker version` и `docker compose version`.
2. Открыть порты 3000 и 8000 (или обеспечить доступ клиентов к UI и API).
3. Выполнить `docker login git.shts.su`.
3. Выполнить `docker login git.shx.one`.
4. Склонировать репозиторий или скопировать `deploy/`.
5. Задать `CORS_ORIGIN` (origin фронтенда для браузера).
6. Задать `REGISTRY_USERNAME` и `REGISTRY_PASSWORD` для updater при приватном registry.
+73 -68
View File
@@ -3,11 +3,22 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useDataSource } from "@/lib/data-source"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { FormToggle } from "@/components/form-kit"
import { OpsPanel } from "@/components/ops-panel"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Separator } from "@/components/ui/separator"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter,
} from "@/components/ui/sheet"
@@ -685,23 +696,6 @@ const INIT_TG: TelegramConfig = {
// ─── small helpers ────────────────────────────────────────────────────────────
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<button role="switch" aria-checked={checked} aria-disabled={disabled} disabled={disabled}
onClick={() => { if (!disabled) onChange(!checked) }}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
disabled && "opacity-50 pointer-events-none",
checked ? "bg-primary" : "bg-muted-foreground/30",
)}>
<span className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
checked ? "translate-x-4" : "translate-x-0.5",
)} />
</button>
)
}
function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) {
return <p className={cn("text-sm font-medium mb-1.5 leading-none", className)}>{children}</p>
}
@@ -739,7 +733,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }
!rule.enabled && "opacity-55",
)}>
{/* toggle */}
<Toggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
<FormToggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
{/* severity dot */}
<SeverityDot severity={rule.severity} />
@@ -857,12 +851,9 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
}
return (
<Card>
<CardHeader className="pb-3 pt-4 px-4">
<CardTitle className="text-sm flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="text-base"></span> Telegram
</span>
<OpsPanel
title={<span className="flex items-center gap-2"><span className="text-base"></span> Telegram</span>}
headerRight={
<span className={cn(
"flex items-center gap-1.5 text-[11px] font-normal px-2 py-0.5 rounded-full",
cfg.connected
@@ -872,9 +863,9 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
<span className={cn("size-1.5 rounded-full", cfg.connected ? "bg-emerald-500" : "bg-muted-foreground")} />
{cfg.connected ? "Подключён" : "Не настроен"}
</span>
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-3">
}
contentClassName="px-4 pb-4 flex flex-col gap-3"
>
{/* bot token */}
<div>
@@ -959,8 +950,7 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
: testResult === "fail" ? "Ошибка отправки"
: "Отправить тестовое сообщение"}
</Button>
</CardContent>
</Card>
</OpsPanel>
)
}
@@ -968,11 +958,7 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
return (
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm">Журнал срабатываний</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<OpsPanel title="Журнал срабатываний" contentClassName="px-4 pb-4">
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">Нет срабатываний</p>
) : (
@@ -999,8 +985,7 @@ function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
))}
</div>
)}
</CardContent>
</Card>
</OpsPanel>
)
}
@@ -2379,6 +2364,7 @@ export default function AlertsPage() {
const [meta, setMeta] = useState<AlertsMeta | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [pendingPresetReplaceId, setPendingPresetReplaceId] = useState<string | null>(null)
const [sheetSnap, setSheetSnap] = useState<RuleFormSnapshot | null>(null)
const [sheetEditId, setSheetEditId] = useState<string | null>(null)
const [sheetKey, setSheetKey] = useState(0)
@@ -2677,9 +2663,6 @@ export default function AlertsPage() {
const applyPresetReplace = useCallback(
(presetId: string) => {
if (typeof window !== "undefined" && !window.confirm("Заменить все текущие правила выбранным пресетом?")) {
return
}
const def = ALERT_PRESETS.find((p) => p.id === presetId)
if (!def) return
const built = def.build(serverNamesForPresets)
@@ -2963,11 +2946,7 @@ export default function AlertsPage() {
<CollapsibleContent>
<div className="border-t border-border/60 px-4 pb-4 pt-3">
<div className="grid gap-5 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm">Пресеты</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 flex flex-col gap-3">
<OpsPanel title="Пресеты" contentClassName="px-4 pb-4 flex flex-col gap-3">
<p className="text-[10px] text-muted-foreground leading-snug">
Быстро добавить набор правил из каталога серверов (демо или live meta).
</p>
@@ -2995,19 +2974,18 @@ export default function AlertsPage() {
variant="outline"
className="h-7 text-xs"
disabled={rulesSaveBusy || (isLive && backendStatus === false)}
onClick={() => applyPresetReplace(p.id)}
onClick={() => setPendingPresetReplaceId(p.id)}
>
Заменить все
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-2 pt-4 px-4">
<CardTitle className="text-sm">Группы ANY / ALL</CardTitle>
<OpsPanel
title="Группы ANY / ALL"
headerRight={
<Button
type="button"
size="sm"
@@ -3018,8 +2996,9 @@ export default function AlertsPage() {
>
<PlusIcon className="size-3.5" />Группа
</Button>
</CardHeader>
<CardContent className="px-4 pb-4 flex flex-col gap-3">
}
contentClassName="px-4 pb-4 flex flex-col gap-3"
>
{groups.length === 0 ? (
<p className="text-[11px] text-muted-foreground">
Групп пока нет. Создайте группу и назначьте правилам её в форме правила движок отправит одно сообщение по логике ANY или ALL.
@@ -3090,8 +3069,7 @@ export default function AlertsPage() {
</div>
))
)}
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
</CollapsibleContent>
@@ -3101,13 +3079,14 @@ export default function AlertsPage() {
<div className="grid grid-cols-1 xl:grid-cols-[1fr_300px] gap-5 items-start">
{/* ── rules card ── */}
<Card>
<CardHeader className="pb-0 pt-4 px-4">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-sm flex items-center gap-2">
Правила оповещения
{rulesSaveBusy && <LoaderCircleIcon className="size-3.5 animate-spin text-muted-foreground" aria-hidden />}
</CardTitle>
<OpsPanel
title={
<span className="flex items-center gap-2">
Правила оповещения
{rulesSaveBusy && <LoaderCircleIcon className="size-3.5 animate-spin text-muted-foreground" aria-hidden />}
</span>
}
headerRight={
<Input
placeholder="Поиск…"
value={search}
@@ -3116,10 +3095,9 @@ export default function AlertsPage() {
name="alert-rules-search"
className="h-7 text-xs max-w-[200px]"
/>
</div>
</CardHeader>
<CardContent className="px-0 pb-0 pt-2">
}
contentClassName="px-0 pb-0 pt-2"
>
{filteredRules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground/40">
<BellOffIcon className="size-8 mb-2 opacity-40" />
@@ -3154,8 +3132,7 @@ export default function AlertsPage() {
<PlusIcon className="size-3" />Добавить правило
</button>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* ── right sidebar ── */}
<div className="flex flex-col gap-5">
@@ -3188,6 +3165,34 @@ export default function AlertsPage() {
onTestRuleTelegram={isLive ? testRuleTelegramFromForm : undefined}
testTelegramDisabled={isLive && backendStatus === false}
/>
<AlertDialog
open={pendingPresetReplaceId != null}
onOpenChange={(open) => {
if (!open) setPendingPresetReplaceId(null)
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Заменить все правила?</AlertDialogTitle>
<AlertDialogDescription>
Текущие правила будут заменены выбранным пресетом. Это нельзя отменить.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
if (pendingPresetReplaceId) applyPresetReplace(pendingPresetReplaceId)
setPendingPresetReplaceId(null)
}}
>
Заменить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+46 -53
View File
@@ -1,18 +1,24 @@
"use client"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { AsnsDataGrid } from "@/components/data-grids/asns-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog"
import { asns as mockAsns } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
export default function AsnsPage() {
const { mode } = useDataSource()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("")
const useEvoCatalog = mode === "live" && enabled
@@ -22,13 +28,26 @@ export default function AsnsPage() {
return snapshot?.asns ?? []
}, [useEvoCatalog, loading, snapshot])
const filtered = useMemo(() => {
if (!search) return rows
const q = search.toLowerCase()
return rows.filter(
(r) =>
r.asn.toLowerCase().includes(q) ||
r.org.toLowerCase().includes(q) ||
String(r.prefixes).includes(q),
)
}, [rows, search])
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
</>
@@ -55,57 +74,31 @@ export default function AsnsPage() {
</p>
)}
</div>
<DataTable
data={rows}
searchPlaceholder="Поиск по ASN, имени, префиксам…"
searchKeys={["asn", "org", "prefixes"]}
columns={[
{
key: "asn",
label: "ASN",
render: (d) => <span className="font-mono font-semibold">{d.asn}</span>,
},
{
key: "org",
label: "Имя / организация",
render: (d) => (
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={d.org}>
{d.org}
</span>
),
},
{
key: "prefixes",
label: "Префиксов",
render: (d) => <span className="font-mono tabular-nums">{d.prefixes.toLocaleString("ru")}</span>,
},
{
key: "filter",
label: "Фильтр",
render: (d) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
</span>
),
},
{
key: "updated",
label: "Обновлён",
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
},
{
key: "enabled",
label: "Статус",
render: (d) => (
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
{d.enabled ? "Активен" : "Отключён"}
</span>
),
},
]}
/>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по ASN, имени, префиксам…"
countLabel={`${filtered.length} ASN`}
/>
<AsnsDataGrid
asns={filtered}
isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog}
/>
</DataPageCard>
</div>
</div>
<FileImportDialog
open={importOpen}
onOpenChange={setImportOpen}
title="Импорт ASN"
description="Загрузите CSV или JSON со списком автономных систем"
accept=".csv,.json,text/csv,application/json"
onImport={async (files) => {
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
}}
/>
</div>
)
}
+248 -235
View File
@@ -2,9 +2,16 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { StatusBadge } from "@/components/status-badge"
import type { Backup, Server } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -20,54 +27,19 @@ import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers"
import { createBackupsAsync, deleteBackup, getBackupJob, listBackups, type BackupItem } from "@/shared/api/backups"
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
import { toast } from "sonner"
// ─── small UI helpers ─────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}>
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
</button>
)
}
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 pb-1">
<span className="text-muted-foreground">{icon}</span>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{label}</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{o.label}
</button>
))}
</div>
)
}
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -116,6 +88,7 @@ export default function BackupsPage() {
const [opBusy, setOpBusy] = useState(false)
const [opError, setOpError] = useState<string | null>(null)
const [backupJobId, setBackupJobId] = useState<string | null>(null)
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
// Schedule
const [schedule, setSchedule] = useState(defaultSchedule)
@@ -140,13 +113,38 @@ export default function BackupsPage() {
}
function handleSave() {
toast.success("Настройки сохранены")
if (scheduleSaveBusy) return
setScheduleSaveBusy(true)
setOpError(null)
void (async () => {
try {
await putBackupScheduleSettings(backendUrl, {
enabled: schedule.enabled,
frequency: schedule.frequency,
hour: schedule.hour,
minute: schedule.minute,
weekDay: schedule.weekDay,
monthDay: schedule.monthDay,
keepCount: schedule.keepCount,
format: schedule.format,
serverIds: [...selectedServers],
})
toast.success("Настройки сохранены")
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка сохранения расписания")
} finally {
setScheduleSaveBusy(false)
}
})()
}
// Manual backup sheet
const [manualOpen, setManualOpen] = useState(false)
const [manualStep, setManualStep] = useState(1)
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
const [manualNotes, setManualNotes] = useState("")
const [restoreOpen, setRestoreOpen] = useState(false)
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
function toggleManualServer(id: string) {
setManualServers((prev) => {
const next = new Set(prev)
@@ -171,14 +169,28 @@ export default function BackupsPage() {
setLoading(true)
setOpError(null)
try {
const [serversRows, backupsRows] = await Promise.all([
const [serversRows, backupsRows, scheduleRow] = await Promise.all([
listServers(backendUrl),
listBackups(backendUrl),
getBackupScheduleSettings(backendUrl),
])
const mappedServers = serversRows.map((s) => toFrontendServer(s))
setLiveServers(mappedServers)
setBackupList(backupsRows.map(mapApiBackupToUi))
setSelectedServers(new Set(mappedServers.map((s) => s.id)))
setSchedule({
enabled: scheduleRow.enabled,
frequency: scheduleRow.frequency,
hour: scheduleRow.hour,
minute: scheduleRow.minute,
weekDay: scheduleRow.weekDay,
monthDay: scheduleRow.monthDay,
keepCount: scheduleRow.keepCount,
format: scheduleRow.format,
})
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 {
@@ -319,7 +331,7 @@ export default function BackupsPage() {
</Button>
<Button
size="sm"
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualStep(1); setManualOpen(true) }}
disabled={loading}
>
<PlusIcon className="size-4" />Новый бэкап
@@ -339,15 +351,17 @@ export default function BackupsPage() {
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-center gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
<span className="text-muted-foreground/40">{s.icon}</span>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -387,83 +401,29 @@ export default function BackupsPage() {
{/* ── История ──────────────────────────────────────────────────── */}
{tab === "history" && (
<Card>
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{([
{ value: "all", label: "Все", count: backupList.length },
{ value: "auto", label: "Авто", count: autoCount },
{ value: "manual", label: "Вручную", count: manualCount },
] as { value: KindFilter; label: string; count: number }[]).map((t) => (
<button key={t.value} onClick={() => setKindFilter(t.value)}
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${kindFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{t.label}
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
</button>
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} бэкапов</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Файл</th>
<th className="text-left font-medium px-4 py-3">Сервер</th>
<th className="text-left font-medium px-4 py-3">Размер</th>
<th className="text-left font-medium px-4 py-3">Тип</th>
<th className="text-left font-medium px-4 py-3">Заметки</th>
<th className="text-left font-medium px-4 py-3">Создан</th>
<th className="w-28 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.length === 0 && (
<tr><td colSpan={7} className="px-5 py-10 text-center text-sm text-muted-foreground">Нет бэкапов</td></tr>
)}
{filtered.map((b) => (
<tr key={b.id} className="hover:bg-muted/40 transition-colors group">
<td className="px-5 py-3 font-mono text-xs font-medium">{b.filename}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{b.server}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{b.size}</td>
<td className="px-4 py-3">
<span className={cn("text-xs px-2 py-0.5 rounded border font-medium",
b.kind === "manual"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: "bg-muted text-muted-foreground border-border"
)}>
{b.kind === "auto" ? "авто" : "вручную"}
</span>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[200px] truncate">{b.notes || "—"}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{b.created}</td>
<td className="px-3 py-3">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="size-7"
title="Скачать"
onClick={() => void handleDownload(b.id, b.filename)}
>
<DownloadIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7" title="Восстановить">
<RefreshCwIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive"
title="Удалить" onClick={() => void handleDelete(b.id)}>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<DataPageToolbar
segmented={{
value: kindFilter,
onChange: setKindFilter,
options: [
{ value: "all", label: "Все", count: backupList.length },
{ value: "auto", label: "Авто", count: autoCount },
{ value: "manual", label: "Вручную", count: manualCount },
],
}}
countLabel={`${filtered.length} бэкапов`}
/>
<BackupsDataGrid
backups={filtered}
onDownload={handleDownload}
onRestore={(b) => {
setRestoreTarget(b)
setRestoreOpen(true)
}}
onDelete={handleDelete}
/>
</DataPageCard>
)}
{/* ── Настройки ────────────────────────────────────────────────── */}
@@ -471,20 +431,18 @@ export default function BackupsPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Расписание */}
<Card>
<CardContent className="px-5 py-5 flex flex-col gap-5">
<SectionTitle icon={<ClockIcon className="size-3.5" />}>Расписание</SectionTitle>
<OpsPanel title="Расписание" contentClassName="px-5 py-5 flex flex-col gap-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Автоматический бэкап</p>
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
</div>
<Toggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
<FormToggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
</div>
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
<Field label="Частота">
<FormField label="Частота">
<SegmentedControl
value={schedule.frequency}
onChange={(v) => setSched("frequency", v)}
@@ -494,10 +452,10 @@ export default function BackupsPage() {
{ value: "monthly", label: "Ежемесячно" },
]}
/>
</Field>
</FormField>
{schedule.frequency === "weekly" && (
<Field label="День недели">
<FormField label="День недели">
<div className="flex gap-1">
{WEEK_DAYS.map((d, i) => (
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
@@ -511,18 +469,18 @@ export default function BackupsPage() {
</button>
))}
</div>
</Field>
</FormField>
)}
{schedule.frequency === "monthly" && (
<Field label="День месяца" hint="128">
<FormField label="День месяца" hint="128">
<Input type="number" min={1} max={28} className="font-mono w-24"
value={schedule.monthDay}
onChange={(e) => setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} />
</Field>
</FormField>
)}
<Field label="Время запуска">
<FormField label="Время запуска">
<div className="flex items-center gap-2">
<div className="relative">
<Input type="number" min={0} max={23} className="font-mono w-20 text-center"
@@ -544,15 +502,15 @@ export default function BackupsPage() {
))}
</div>
</div>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-4">
<Field label="Хранить бэкапов" hint="На каждый сервер">
<FormField label="Хранить бэкапов" hint="На каждый сервер">
<Input type="number" min={1} max={90} className="font-mono"
value={schedule.keepCount}
onChange={(e) => setSched("keepCount", Math.max(1, Number(e.target.value)))} />
</Field>
<Field label="Формат файла">
</FormField>
<FormField label="Формат файла">
<SegmentedControl
value={schedule.format}
onChange={(v) => setSched("format", v)}
@@ -561,18 +519,15 @@ export default function BackupsPage() {
{ value: "backup", label: ".backup" },
]}
/>
</Field>
</FormField>
</div>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* Хранилище */}
<Card>
<CardContent className="px-5 py-5 flex flex-col gap-5">
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
<OpsPanel title="Хранилище" contentClassName="px-5 py-5 flex flex-col gap-5">
<Field label="Тип хранилища">
<FormField label="Тип хранилища">
<SegmentedControl
value={storage.type}
onChange={(v) => setStore("type", v)}
@@ -583,45 +538,45 @@ export default function BackupsPage() {
{ value: "smb", label: "SMB" },
]}
/>
</Field>
</FormField>
{storage.type === "local" && (
<Field label="Путь сохранения" hint="Директория на сервере приложения">
<FormField label="Путь сохранения" hint="Директория на сервере приложения">
<Input className="font-mono" placeholder="/var/backup/mikrotik"
value={storage.localPath}
onChange={(e) => setStore("localPath", e.target.value)} />
</Field>
</FormField>
)}
{storage.type !== "local" && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Field label="Хост">
<FormField label="Хост">
<Input className="font-mono" placeholder="192.168.1.100"
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
</Field>
</FormField>
</div>
<Field label="Порт">
<FormField label="Порт">
<Input className="font-mono"
placeholder={storage.type === "ftp" ? "21" : storage.type === "scp" ? "22" : "445"}
value={storage.port} onChange={(e) => setStore("port", e.target.value)} />
</Field>
</FormField>
</div>
{storage.type === "smb" && (
<Field label="Общая папка (Share)">
<FormField label="Общая папка (Share)">
<Input className="font-mono" placeholder="backups"
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
</Field>
</FormField>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Пользователь">
<FormField label="Пользователь">
<Input className="font-mono" placeholder="backup-user"
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
</Field>
<Field label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
</FormField>
<FormField label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
<div className="relative">
<Input
type={storage.showPassword ? "text" : "password"}
@@ -636,13 +591,13 @@ export default function BackupsPage() {
{storage.showPassword ? "скрыть" : "показ"}
</button>
</div>
</Field>
</FormField>
</div>
<Field label="Удалённый путь">
<FormField label="Удалённый путь">
<Input className="font-mono" placeholder="/mikrotik-backups"
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
</Field>
</FormField>
</>
)}
@@ -657,14 +612,13 @@ export default function BackupsPage() {
<p className="mt-1">Файлы: <span className="font-mono text-foreground">{storage.localPath || "/var/backup/mikrotik"}/{"<server-name>"}_{"{date}"}.{schedule.format}</span></p>
)}
</div>
</CardContent>
</Card>
</OpsPanel>
{/* Серверы */}
<Card className="lg:col-span-2">
<CardContent className="px-5 py-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<SectionTitle icon={<ServerIcon className="size-3.5" />}>Серверы для бэкапа</SectionTitle>
<OpsPanel
className="lg:col-span-2"
title="Серверы для бэкапа"
headerRight={
<div className="flex items-center gap-2 shrink-0">
<button type="button" onClick={() => setSelectedServers(new Set(liveServers.map(s => s.id)))}
className="text-xs text-primary hover:underline">Выбрать все</button>
@@ -672,7 +626,9 @@ export default function BackupsPage() {
<button type="button" onClick={() => setSelectedServers(new Set())}
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
</div>
</div>
}
contentClassName="px-5 py-5 flex flex-col gap-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{liveServers.map((s) => {
@@ -706,12 +662,11 @@ export default function BackupsPage() {
<p className="text-xs text-muted-foreground">
Выбрано {selectedServers.size} из {liveServers.length} серверов
</p>
</CardContent>
</Card>
</OpsPanel>
{/* Save button */}
<div className="lg:col-span-2 flex items-center gap-3">
<Button onClick={handleSave} className="gap-2">
<Button onClick={handleSave} className="gap-2" disabled={scheduleSaveBusy}>
Сохранить настройки
</Button>
</div>
@@ -722,71 +677,129 @@ export default function BackupsPage() {
</div>
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */}
<Sheet open={manualOpen} onOpenChange={setManualOpen}>
<Sheet open={manualOpen} onOpenChange={(v) => { setManualOpen(v); if (!v) setManualStep(1) }}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Новый бэкап</SheetTitle>
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium">Выберите серверы</p>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
className="text-xs text-primary hover:underline">Все</button>
<span className="text-border">·</span>
<button type="button" onClick={() => setManualServers(new Set())}
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
<Stepper value={manualStep} onValueChange={setManualStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Серверы</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">Заметка</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">Подтверждение</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-3">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium">Выберите серверы</p>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
className="text-xs text-primary hover:underline">Все</button>
<span className="text-border">·</span>
<button type="button" onClick={() => setManualServers(new Set())}
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
</div>
</div>
</div>
{liveServers.map((s) => {
const checked = manualServers.has(s.id)
return (
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40"
)}>
<div className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
checked ? "bg-primary border-primary" : "border-border"
)}>
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{s.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
<StatusBadge status={s.status} />
{liveServers.map((s) => {
const checked = manualServers.has(s.id)
return (
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40"
)}>
<div className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
checked ? "bg-primary border-primary" : "border-border"
)}>
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
</div>
{s.status === "offline" && (
<span className="text-xs text-muted-foreground">недоступен</span>
)}
</button>
)
})}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Заметка</label>
<Input placeholder="Например: перед обновлением BGP"
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{s.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
<StatusBadge status={s.status} />
</div>
</div>
{s.status === "offline" && (
<span className="text-xs text-muted-foreground">недоступен</span>
)}
</button>
)
})}
</StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<FormField label="Заметка">
<Input placeholder="Например: перед обновлением BGP"
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
</FormField>
</StepperContent>
<StepperContent value={3} className="flex flex-col gap-3 text-sm">
<p className="text-muted-foreground">
Будет создан бэкап для <strong className="text-foreground">{manualServers.size}</strong> серверов.
</p>
{manualNotes && (
<p className="text-muted-foreground">Заметка: {manualNotes}</p>
)}
</StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1"
disabled={manualServers.size === 0 || backupJobId !== null}
onClick={handleManualBackup}>
Снять бэкап ({manualServers.size})
</Button>
{manualStep > 1 && (
<Button variant="outline" className="flex-1" onClick={() => setManualStep((s) => s - 1)}>
Назад
</Button>
)}
{manualStep < 3 ? (
<Button
className="flex-1"
disabled={manualStep === 1 && manualServers.size === 0}
onClick={() => setManualStep((s) => s + 1)}
>
Далее
</Button>
) : (
<Button className="flex-1"
disabled={manualServers.size === 0 || backupJobId !== null}
onClick={handleManualBackup}>
Снять бэкап ({manualServers.size})
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
<FileImportDialog
open={restoreOpen}
onOpenChange={setRestoreOpen}
title={restoreTarget ? `Восстановление: ${restoreTarget.filename}` : "Восстановление бэкапа"}
description="Выберите файл конфигурации для загрузки на роутер"
accept=".backup,.rsc,.zip"
onImport={async (files) => {
toast.success(`Файл ${files[0]?.name} подготовлен к восстановлению на ${restoreTarget?.server ?? "сервер"}`)
}}
/>
</div>
)
}
+102 -323
View File
@@ -2,7 +2,16 @@
import { Fragment, useState, useMemo, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
import type { Filter } from "@/components/reui/filters"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
import { BGP_AS_NAMES } from "@/lib/bgp/types"
import { Frame, FramePanel } from "@/components/reui/frame"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
@@ -17,49 +26,12 @@ import { useDataSource } from "@/lib/data-source"
// ─── types ────────────────────────────────────────────────────────────────────
type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm"
type BgpType = "eBGP" | "iBGP"
type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
type BgpSession = BgpSessionRow
type BgpTab = "sessions" | "routers" | "analytics"
type StateFilter = "all" | BgpState
type TypeFilter = "all" | BgpType
interface BgpSession {
id: string
serverId: string
serverLabel: string
serverSite: string
peerIp: string
remoteAs: number
localAs: number
routerId: string
description: string
state: BgpState
type: BgpType
afi: BgpAfi
uptime: string | null
holdTime: number
keepalive: number
prefixesRx: number
prefixesTx: number
prefixesActive: number
inputMessages: number
outputMessages: number
capabilities: string[]
lastError: string | null
}
// ─── AS name lookup ───────────────────────────────────────────────────────────
const AS_NAMES: Record<number, string> = {
8359: "МТС / Tele2",
13238: "Яндекс",
12389: "Ростелеком",
24940: "Hetzner",
6777: "AMS-IX",
1299: "Telia",
65001: "iBGP internal",
}
const AS_NAMES = BGP_AS_NAMES
// ─── mock data ────────────────────────────────────────────────────────────────
const SESSIONS: BgpSession[] = [
@@ -240,13 +212,6 @@ function TypeBadge({ type }: { type: BgpType }) {
)
}
function CapChip({ cap }: { cap: string }) {
return (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
{cap}
</span>
)
}
function fmtNum(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
@@ -254,108 +219,6 @@ function fmtNum(n: number) {
return String(n)
}
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
const max = Math.max(rx, 1)
return (
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
{[
{ label: "Получено", val: rx, color: "bg-[var(--chart-rx)]", w: rx / max },
{ label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max },
{ label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", w: Math.min(tx / max, 1) },
].map(r => (
<div key={r.label} className="flex items-center gap-2">
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", r.color)}
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }} />
</div>
<span className="w-14 text-right tabular-nums">{fmtNum(r.val)}</span>
</div>
))}
</div>
)
}
// ─── RSC snippet ──────────────────────────────────────────────────────────────
function rscSnippet(s: BgpSession) {
return `/routing bgp connection\nadd name=peer-as${s.remoteAs} remote.address=${s.peerIp}/32 \\\n remote.as=${s.remoteAs} local.role=${s.type === "eBGP" ? "ebgp" : "ibgp"} \\\n output.filter-chain=export-filter input.filter=import-filter \\\n routing-table=main`
}
// ─── session expanded row ─────────────────────────────────────────────────────
function SessionDetail({ s }: { s: BgpSession }) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard.writeText(rscSnippet(s)).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 1800)
})
}
return (
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{[
{ label: "Router ID", value: s.routerId },
{ label: "Hold / KA", value: `${s.holdTime}s / ${s.keepalive}s` },
{ label: "AFI/SAFI", value: s.afi },
{ label: "Сообщения ↓/↑", value: `${fmtNum(s.inputMessages)} / ${fmtNum(s.outputMessages)}` },
].map(({ label, value }) => (
<div key={label}>
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
<p className="text-xs font-mono font-medium">{value}</p>
</div>
))}
</div>
{/* prefix bars */}
{s.state === "Established" && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">Префиксы</p>
<PrefixBar rx={s.prefixesRx} tx={s.prefixesTx} active={s.prefixesActive} />
</div>
)}
{/* capabilities */}
{s.capabilities.length > 0 && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">Capabilities</p>
<div className="flex flex-wrap gap-1.5">
{s.capabilities.map(c => <CapChip key={c} cap={c} />)}
</div>
</div>
)}
{/* last error */}
{s.lastError && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
<p className="text-xs font-mono text-red-500">{s.lastError}</p>
</div>
)}
{/* rsc export */}
<div className="mt-2">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">RouterOS Export</p>
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
{rscSnippet(s)}
</pre>
<button onClick={copy}
className={cn(
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
copied
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
)}>
<ClipboardCopyIcon className="size-3" />
{copied ? "Скопировано" : "Копировать"}
</button>
</div>
</div>
</div>
)
}
// ─── backend mapping ──────────────────────────────────────────────────────────
interface BackendBgpSession {
@@ -397,159 +260,82 @@ function backendToFrontend(b: BackendBgpSession): BgpSession {
// ─── sessions tab ─────────────────────────────────────────────────────────────
const STATE_FILTERS: Array<{ value: StateFilter; label: string }> = [
{ value: "all", label: "Все" },
{ value: "Established", label: "Established" },
{ value: "Active", label: "Active" },
{ value: "Idle", label: "Idle" },
{ value: "OpenSent", label: "OpenSent" },
]
const BGP_FILTER_ACCESSORS = {
state: (s: BgpSession) => s.state,
type: (s: BgpSession) => s.type,
afi: (s: BgpSession) => s.afi,
}
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
const [search, setSearch] = useState("")
const [search, setSearch] = useState("")
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [expandedId, setExpandedId] = useState<string | null>(null)
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
const q = search.toLowerCase()
const filtered = useMemo(() => sessions.filter(s => {
if (stateFilter !== "all" && s.state !== stateFilter) return false
if (typeFilter !== "all" && s.type !== typeFilter) return false
if (q && !s.peerIp.includes(q) && !s.description.toLowerCase().includes(q)
&& !s.serverLabel.includes(q) && !String(s.remoteAs).includes(q)
&& !(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)) return false
return true
}), [sessions, q, stateFilter, typeFilter])
const filtered = useMemo(() => {
const base = sessions.filter((s) => {
if (stateFilter !== "all" && s.state !== stateFilter) return false
if (typeFilter !== "all" && s.type !== typeFilter) return false
if (
q &&
!s.peerIp.includes(q) &&
!s.description.toLowerCase().includes(q) &&
!s.serverLabel.includes(q) &&
!String(s.remoteAs).includes(q) &&
!(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
) {
return false
}
return true
})
return applyReuiFilters(base, advancedFilters, BGP_FILTER_ACCESSORS)
}, [sessions, q, stateFilter, typeFilter, advancedFilters])
return (
<div className="flex flex-col gap-4">
{/* filter bar */}
<div className="flex items-center gap-2 flex-wrap">
{/* search */}
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none z-10" />
<Input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="IP, AS, описание…"
className="h-8 pl-8 pr-8 w-52 text-xs"
/>
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground z-10">
<XIcon className="size-3" />
</button>
)}
</div>
{/* state filter */}
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{STATE_FILTERS.map(f => (
<button key={f.value} onClick={() => setStateFilter(f.value)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors whitespace-nowrap",
stateFilter === f.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{f.label}
</button>
))}
</div>
{/* type filter */}
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{(["all", "eBGP", "iBGP"] as const).map(t => (
<button key={t} onClick={() => setTypeFilter(t)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors",
typeFilter === t ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{t === "all" ? "Все типы" : t}
</button>
))}
</div>
<span className="text-xs text-muted-foreground ml-auto">
{filtered.length} из {sessions.length}
</span>
</div>
{/* table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
<th className="w-8" />
{["Роутер", "Peer IP", "Remote AS", "Описание", "Тип", "Состояние", "Uptime", "Prefixes ↓", "Prefixes ↑"].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{filtered.map(s => {
const isOpen = expandedId === s.id
return (
<Fragment key={s.id}>
<tr
onClick={() => setExpandedId(isOpen ? null : s.id)}
className={cn(
"cursor-pointer transition-colors",
isOpen ? "bg-muted/30" : "hover:bg-muted/20",
)}>
<td className="pl-3 py-2.5">
{isOpen
? <ChevronDownIcon className="size-3.5 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 text-muted-foreground" />}
</td>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{s.serverLabel}</td>
<td className="px-3 py-2.5 font-mono">{s.peerIp}</td>
<td className="px-3 py-2.5 font-mono">
<div className="flex items-center gap-1.5">
<span>AS{s.remoteAs}</span>
{AS_NAMES[s.remoteAs] && (
<span className="text-muted-foreground text-[10px]">{AS_NAMES[s.remoteAs]}</span>
)}
</div>
</td>
<td className="px-3 py-2.5 text-muted-foreground max-w-[180px] truncate">{s.description}</td>
<td className="px-3 py-2.5"><TypeBadge type={s.type} /></td>
<td className="px-3 py-2.5"><StateBadge state={s.state} /></td>
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground">
{s.uptime ?? "—"}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
{s.prefixesRx > 0
? <span className="text-emerald-600 dark:text-emerald-400">{fmtNum(s.prefixesRx)}</span>
: <span className="text-muted-foreground"></span>}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
{s.prefixesTx > 0
? <span className="text-[var(--chart-tx)]">{fmtNum(s.prefixesTx)}</span>
: <span className="text-muted-foreground"></span>}
</td>
</tr>
{isOpen && (
<tr>
<td colSpan={10} className="p-0">
<SessionDetail s={s} />
</td>
</tr>
)}
</Fragment>
)
})}
{filtered.length === 0 && (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-muted-foreground">
Нет сессий по заданным фильтрам
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<DataPageToolbar
segmented={{
value: stateFilter,
onChange: setStateFilter,
options: [
{ value: "all", label: "Все", count: sessions.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: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
],
}}
filters={advancedFilters}
onFiltersChange={setAdvancedFilters}
filterFields={BGP_FILTER_FIELDS}
search={search}
onSearchChange={setSearch}
searchPlaceholder="IP, AS, описание…"
countLabel={`${filtered.length} из ${sessions.length}`}
actions={
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{(["all", "eBGP", "iBGP"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTypeFilter(t)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors",
typeFilter === t
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t === "all" ? "Все типы" : t}
</button>
))}
</div>
}
/>
<BgpSessionsDataGrid sessions={filtered} />
</DataPageCard>
</div>
)
}
@@ -574,7 +360,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0)
return (
<Card key={router.id} className="overflow-hidden gap-0 py-0">
<Frame key={router.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* header */}
<div className="flex items-center gap-3 px-4 py-3 border-b">
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
@@ -639,7 +426,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
</span>
</div>
)}
</Card>
</FramePanel>
</Frame>
)
})}
</div>
@@ -682,20 +470,18 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
</CardContent>
</Card>
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
{/* prefixes by peer — horizontal bar chart */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-4">Топ-8 пиров по полученным префиксам</p>
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
<div className="flex flex-col gap-3">
{topPeers.map((s, i) => {
const pct = (s.prefixesRx / maxRx) * 100
@@ -731,15 +517,12 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p>
)}
</div>
</CardContent>
</Card>
</OpsPanel>
{/* right column */}
<div className="flex flex-col gap-4">
{/* state distribution */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-3">Распределение состояний</p>
<OpsPanel title="Распределение состояний" contentClassName="pt-4 pb-4 px-5">
{stateCounts.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет данных</p>
) : (
@@ -760,13 +543,10 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
})}
</div>
)}
</CardContent>
</Card>
</OpsPanel>
{/* eBGP vs iBGP */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-3">eBGP vs iBGP</p>
<OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
{sessions.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет данных</p>
) : (
@@ -807,8 +587,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
</div>
</>
)}
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
</div>
@@ -954,12 +733,12 @@ export default function BgpPage() {
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
</CardContent>
</Card>
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
+176 -497
View File
@@ -1,12 +1,18 @@
"use client"
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { FileImportDialog } from "@/components/file-import-dialog"
import { routerCertificates, servers as mockServers } from "@/lib/data"
import type { CertStatus, Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -33,119 +39,25 @@ import {
} from "@/shared/api/certificates"
import { toast } from "sonner"
import {
SearchIcon,
ShieldCheckIcon,
ShieldAlertIcon,
ShieldOffIcon,
BadgeCheckIcon,
AlertTriangleIcon,
AlertCircleIcon,
CalendarIcon,
KeyRoundIcon,
ServerIcon,
PlusIcon,
ChevronDownIcon,
ChevronRightIcon,
RefreshCwIcon,
UploadIcon,
} from "lucide-react"
const STATUS_CONFIG: Record<
CertStatus,
{
label: string
icon: ReactNode
badge: string
row: string
}
> = {
valid: {
label: "Действителен",
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
row: "",
},
expired: {
label: "Истёк",
icon: <ShieldOffIcon className="size-4 text-red-500" />,
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
row: "bg-red-500/5",
},
revoked: {
label: "Отозван",
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
row: "bg-amber-500/5",
},
}
const CERT_TABLE_GRID_CLASS =
"grid grid-cols-[1.25rem_minmax(0,1.35fr)_minmax(0,0.85fr)_minmax(0,1fr)_9.5rem_minmax(0,8.5rem)_6rem] gap-3"
function daysLeftColor(days: number): string {
if (days < 0) return "text-red-500"
if (days <= 7) return "text-red-500"
if (days <= 30) return "text-amber-500"
return "text-emerald-600 dark:text-emerald-400"
}
function daysLeftBar(days: number, total = 365): number {
if (days <= 0) return 0
return Math.min(100, Math.round((days / total) * 100))
}
function Field({
label,
hint,
required,
children,
}: {
label: string
hint?: string
required?: boolean
children: ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
checked ? "bg-primary" : "bg-input",
)}
>
<span
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)}
/>
</button>
)
}
function SectionTitle({ children }: { children: ReactNode }) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
return {
@@ -165,203 +77,6 @@ function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
}
}
function CertPartDaysBar({ cert, pct }: { cert: CertificateDto; pct: number }) {
return (
<div
className={cn(
"h-full rounded-full transition-all",
cert.daysLeft < 0
? "bg-red-500"
: cert.daysLeft <= 7
? "bg-red-500"
: cert.daysLeft <= 30
? "bg-amber-500"
: "bg-emerald-500",
)}
style={{ width: `${pct}%` }}
/>
)
}
function CertPartDays({ cert, pct }: { cert: CertificateDto; pct: number }) {
return (
<>
<div className="flex items-center justify-between text-xs mb-1">
<span className={cn("font-mono font-medium", daysLeftColor(cert.daysLeft))}>
{cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`}
</span>
<span className="text-muted-foreground text-[10px]">{cert.validUntil}</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<CertPartDaysBar cert={cert} pct={pct} />
</div>
</>
)
}
function CertPartDetailDates({ cert }: { cert: CertificateDto }) {
return (
<div>
<p className="text-muted-foreground mb-1">Действителен с</p>
<p className="font-mono">{cert.validFrom}</p>
</div>
)
}
function CertPartDetailSans({ cert }: { cert: CertificateDto }) {
return (
<div>
<p className="text-muted-foreground mb-1">SAN / Alt Names</p>
<div className="flex flex-wrap gap-1">
{cert.sans.length > 0
? cert.sans.map((s) => (
<span key={s} className="font-mono bg-muted px-1.5 py-0.5 rounded">
{s}
</span>
))
: <span className="text-muted-foreground"></span>}
</div>
</div>
)
}
function CertPartDetailTrusted({ cert }: { cert: CertificateDto }) {
return (
<div>
<p className="text-muted-foreground mb-1">Trusted</p>
<p className={cert.trusted ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"}>
{cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
</p>
</div>
)
}
function CertListRow({
cert,
cfg,
pct,
server,
expanded,
onToggle,
}: {
cert: CertificateDto
cfg: (typeof STATUS_CONFIG)[CertStatus]
pct: number
server?: Server
expanded: boolean
onToggle: () => void
}) {
return (
<div className={cn("border-b last:border-b-0", cfg.row)}>
<div
className={cn(
CERT_TABLE_GRID_CLASS,
"px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer",
)}
onClick={onToggle}
>
<button
type="button"
className="text-muted-foreground"
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
>
{expanded ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
</button>
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="shrink-0">{cfg.icon}</span>
<span className="font-medium text-sm truncate" title={cert.name}>
{cert.name}
</span>
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate" title={cert.commonName}>
{cert.commonName}
</p>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
{server
? (
<>
<Flag code={server.country} size={12} />
<span className="font-mono truncate">{server.name}</span>
</>
)
: (
<>
<ServerIcon className="size-3.5" />
<span className="font-mono truncate">{cert.serverName ?? cert.serverId}</span>
</>
)}
</div>
<p className="text-xs text-muted-foreground truncate min-w-0" title={cert.issuedBy}>
{cert.issuedBy}
</p>
<div className="min-w-0">
<CertPartDays cert={cert} pct={pct} />
</div>
<div className="flex flex-wrap gap-1 min-w-0 overflow-hidden">
{cert.usage.map((u) => (
<span
key={u}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border"
>
{u}
</span>
))}
</div>
<span
className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap shrink-0 justify-self-end",
cfg.badge,
)}
>
{cfg.label}
</span>
</div>
{expanded && (
<div className="px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
<div>
<p className="text-muted-foreground mb-1">Key size</p>
<p className="font-mono font-medium">{cert.keySize} bit</p>
</div>
<CertPartDetailDates cert={cert} />
<CertPartDetailSans cert={cert} />
<CertPartDetailTrusted cert={cert} />
</div>
)}
</div>
)
}
function CertRow({
cert,
server,
expanded,
onToggle,
}: {
cert: CertificateDto
server?: Server
expanded: boolean
onToggle: () => void
}) {
const cfg = STATUS_CONFIG[cert.status]
const pct = daysLeftBar(cert.daysLeft)
return (
<CertListRow
cert={cert}
cfg={cfg}
pct={pct}
server={server}
expanded={expanded}
onToggle={onToggle}
/>
)
}
function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
return (
<div className="flex items-start gap-3 rounded-lg bg-red-500/5 border border-red-500/20 px-4 py-3 text-sm">
@@ -427,15 +142,17 @@ function CertPartKpi({
icon: <AlertCircleIcon className="size-4 text-red-500" />,
},
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
)
@@ -465,12 +182,11 @@ function CertPartAcmeSettings({
onSave: () => void
}) {
return (
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-3">
<p className="text-sm font-medium">ACME · Cloudflare DNS-01</p>
<p className="text-xs text-muted-foreground">
Публичные Let&apos;s Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+.
</p>
<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"
@@ -519,108 +235,17 @@ function CertPartAcmeSettings({
Сохранить настройки
</Button>
</div>
</CardContent>
</Card>
)
}
function CertPartTableToolbar({
search,
setSearch,
statusFilter,
setStatusFilter,
filteredCount,
}: {
search: string
setSearch: (v: string) => void
statusFilter: CertStatus | "all"
setStatusFilter: (v: CertStatus | "all") => void
filteredCount: number
}) {
return (
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, CN, эмитенту…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{(["all", "valid", "expired", "revoked"] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setStatusFilter(s)}
className={cn(
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
statusFilter === s
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{s === "all"
? "Все"
: s === "valid"
? "Действующие"
: s === "expired"
? "Истёкшие"
: "Отозванные"}
</button>
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filteredCount} сертификатов</span>
</div>
)
}
function CertPartTableHeaderDates() {
return (
<div className="flex items-center gap-1">
<CalendarIcon className="size-3" />
Срок
</div>
)
}
function CertPartTableHeaderUsage() {
return (
<div className="flex items-center gap-1">
<KeyRoundIcon className="size-3" />
Использование
</div>
)
}
function CertPartTableHeader() {
return (
<div
className={cn(
CERT_TABLE_GRID_CLASS,
"px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20",
)}
>
<span />
<span>Имя / CN</span>
<span>Сервер</span>
<span>Выпущен</span>
<CertPartTableHeaderDates />
<CertPartTableHeaderUsage />
<span>Статус</span>
</div>
</OpsPanel>
)
}
function CertPartReference() {
return (
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-1">
RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве.
</p>
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7 · /certificate справка CLI</p>
<OpsPanel
title="RouterOS 7 · /certificate — справка CLI"
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве."
contentClassName="px-5 py-4"
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
@@ -660,8 +285,7 @@ function CertPartReference() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
)
}
@@ -679,6 +303,7 @@ function CertPartIssueForm({
setIssueTrustWww,
issueTrustApi,
setIssueTrustApi,
step,
}: {
serverList: Server[]
issueServerId: string
@@ -693,12 +318,15 @@ function CertPartIssueForm({
setIssueTrustWww: (v: boolean) => void
issueTrustApi: boolean
setIssueTrustApi: (v: boolean) => void
step?: 1 | 2 | 3 | 4
}) {
const showAll = step == null
return (
<div className="flex flex-col gap-5">
{(showAll || step === 1) && (
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
<select
value={issueServerId}
onChange={(e) => setIssueServerId(e.target.value)}
@@ -713,41 +341,45 @@ function CertPartIssueForm({
</option>
))}
</select>
</Field>
<Field label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
</FormField>
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
<Input
className="font-mono"
value={issueCertName}
onChange={(e) => setIssueCertName(e.target.value)}
placeholder="router-le"
/>
</Field>
</FormField>
</div>
)}
{(showAll || step === 2) && (
<div className="flex flex-col gap-4">
<SectionTitle>Домены</SectionTitle>
<Field label="Common Name" required hint="Основное имя в сертификате">
<FormField label="Common Name" required hint="Основное имя в сертификате">
<Input
className="font-mono"
value={issueCommonName}
onChange={(e) => setIssueCommonName(e.target.value)}
placeholder="vpn.example.com"
/>
</Field>
<Field label="SAN" hint="По одному имени в строке">
</FormField>
<FormField label="SAN" hint="По одному имени в строке">
<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"
value={issueSans}
onChange={(e) => setIssueSans(e.target.value)}
placeholder="www.example.com"
/>
</Field>
</FormField>
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Let&apos;s Encrypt · DNS-01 (Cloudflare)</p>
<p>TXT-запись создаётся в Cloudflare, сертификат импортируется на выбранный RouterOS.</p>
</div>
</div>
)}
{(showAll || step === 3) && (
<div className="flex flex-col gap-4">
<SectionTitle>Импорт на RouterOS</SectionTitle>
<div className="flex items-center justify-between">
@@ -755,16 +387,29 @@ function CertPartIssueForm({
<p className="text-sm font-medium">Trust store · www</p>
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
</div>
<Toggle checked={issueTrustWww} onChange={setIssueTrustWww} />
<FormToggle checked={issueTrustWww} onChange={setIssueTrustWww} />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Trust store · api</p>
<p className="text-xs text-muted-foreground">REST API и управление</p>
</div>
<Toggle checked={issueTrustApi} onChange={setIssueTrustApi} />
<FormToggle checked={issueTrustApi} onChange={setIssueTrustApi} />
</div>
</div>
)}
{(showAll || step === 4) && (
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm">
<p className="font-medium mb-2">Проверьте параметры</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Сервер: {serverList.find((s) => s.id === issueServerId)?.name ?? "—"}</li>
<li>Имя: {issueCertName || "—"}</li>
<li>CN: {issueCommonName || "—"}</li>
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
</ul>
</div>
)}
</div>
)
}
@@ -776,13 +421,14 @@ export default function CertificatesPage() {
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [certificates, setCertificates] = useState<CertificateDto[]>([])
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle")
const [loadError, setLoadError] = useState<string | null>(null)
const [serverList, setServerList] = useState<Server[]>([])
const [issueOpen, setIssueOpen] = useState(false)
const [issueStep, setIssueStep] = useState(1)
const [importOpen, setImportOpen] = useState(false)
const [issueBusy, setIssueBusy] = useState(false)
const [issueServerId, setIssueServerId] = useState("")
const [issueCertName, setIssueCertName] = useState("")
@@ -888,15 +534,6 @@ export default function CertificatesPage() {
})
}, [displayCerts, search, statusFilter])
function toggleExpand(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleRefresh() {
if (!liveReady) return
try {
@@ -1004,7 +641,11 @@ export default function CertificatesPage() {
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
Обновить
</Button>
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => setIssueOpen(true)}>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}>
<PlusIcon className="size-4" />
Выпустить сертификат
</Button>
@@ -1053,44 +694,35 @@ export default function CertificatesPage() {
/>
)}
<Card>
<CertPartTableToolbar
<DataPageCard>
<DataPageToolbar
search={search}
setSearch={setSearch}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
filteredCount={filtered.length}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, CN, эмитенту…"
segmented={{
value: statusFilter,
onChange: setStatusFilter,
options: [
{ value: "all", label: "Все" },
{ value: "valid", label: "Действующие" },
{ value: "expired", label: "Истёкшие" },
{ value: "revoked", label: "Отозванные" },
],
}}
countLabel={`${filtered.length} сертификатов`}
/>
<div className="overflow-x-auto">
<div className="min-w-[48rem]">
<CertPartTableHeader />
{prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">Загрузка сертификатов</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">Сертификаты не найдены</p>
</div>
) : (
filtered.map((cert) => (
<CertRow
key={cert.id}
cert={cert}
server={serverById.get(cert.serverId)}
expanded={expandedIds.has(cert.id)}
onToggle={() => toggleExpand(cert.id)}
/>
))
)}
</div>
</div>
</Card>
<CertificatesDataGrid
certificates={filtered}
serverMap={serverById}
isLoading={prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0}
/>
</DataPageCard>
<CertPartReference />
</div>
</div>
<Sheet open={issueOpen} onOpenChange={setIssueOpen}>
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Выпуск сертификата</SheetTitle>
@@ -1099,40 +731,87 @@ export default function CertificatesPage() {
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5">
<CertPartIssueForm
serverList={serverList}
issueServerId={issueServerId}
setIssueServerId={setIssueServerId}
issueCertName={issueCertName}
setIssueCertName={setIssueCertName}
issueCommonName={issueCommonName}
setIssueCommonName={setIssueCommonName}
issueSans={issueSans}
setIssueSans={setIssueSans}
issueTrustWww={issueTrustWww}
setIssueTrustWww={setIssueTrustWww}
issueTrustApi={issueTrustApi}
setIssueTrustApi={setIssueTrustApi}
/>
</div>
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
{[
{ step: 1, title: "Основные" },
{ step: 2, title: "Домены" },
{ step: 3, title: "Импорт" },
{ step: 4, title: "Проверка" },
].map(({ step, title }, i, arr) => (
<StepperItem key={step} step={step}>
<StepperTrigger>
<StepperIndicator>{step}</StepperIndicator>
<StepperTitle className="sr-only">{title}</StepperTitle>
</StepperTrigger>
{i < arr.length - 1 && <StepperSeparator />}
</StepperItem>
))}
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
{[1, 2, 3, 4].map((s) => (
<StepperContent key={s} value={s}>
<CertPartIssueForm
step={s as 1 | 2 | 3 | 4}
serverList={serverList}
issueServerId={issueServerId}
setIssueServerId={setIssueServerId}
issueCertName={issueCertName}
setIssueCertName={setIssueCertName}
issueCommonName={issueCommonName}
setIssueCommonName={setIssueCommonName}
issueSans={issueSans}
setIssueSans={setIssueSans}
issueTrustWww={issueTrustWww}
setIssueTrustWww={setIssueTrustWww}
issueTrustApi={issueTrustApi}
setIssueTrustApi={setIssueTrustApi}
/>
</StepperContent>
))}
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={!liveReady || issueBusy}
onClick={() => {
void handleIssue()
}}
>
{issueBusy ? "Выпуск…" : "Выпустить"}
</Button>
{issueStep > 1 && (
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
Назад
</Button>
)}
{issueStep < 4 ? (
<Button
className="flex-1"
disabled={issueStep === 1 && (!issueServerId || !issueCertName)}
onClick={() => setIssueStep((s) => s + 1)}
>
Далее
</Button>
) : (
<Button
className="flex-1"
disabled={!liveReady || issueBusy}
onClick={() => { void handleIssue() }}
>
{issueBusy ? "Выпуск…" : "Выпустить"}
</Button>
)}
</SheetFooter>
</SheetContent>
</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} готов к импорту на роутер`)
}}
/>
</div>
)
}
+50 -150
View File
@@ -2,14 +2,21 @@
import { useState, useMemo, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
} from "@/components/ui/card"
CommunitiesDataGrid,
type CommunityRow,
TYPE_LABELS,
ACTION_LABELS,
ACTION_COLOR,
} from "@/components/data-grids/communities-data-grid"
import { Frame, FramePanel } from "@/components/reui/frame"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
PlusIcon, SearchIcon, TagIcon, ServerIcon, FilterIcon,
ChevronRightIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
PlusIcon, SearchIcon, TagIcon, FilterIcon,
CopyIcon, CheckIcon, TrashIcon, PencilIcon,
LoaderCircleIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
@@ -19,21 +26,8 @@ import { useEvoBGP } from "@/lib/evobgp-context"
// ─── types ────────────────────────────────────────────────────────────────────
type CommType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
interface Community {
id: string
value: string // e.g. "65001:100"
name: string
description: string
type: CommType
filterIds: string[] // which filters use this community
serverCount: number
prefixCount: number
action: "permit" | "deny" | "local-pref" | "metric"
actionValue?: number // e.g. local-pref value
enabled: boolean
}
type Community = CommunityRow
type CommType = CommunityRow["type"]
// ─── mock data ────────────────────────────────────────────────────────────────
@@ -100,28 +94,6 @@ const COMMUNITIES: Community[] = [
},
]
const TYPE_LABELS: Record<CommType, string> = {
"standard": "Стандартный",
"no-export": "No-export",
"no-advertise":"No-advertise",
"local-as": "Local-AS",
"custom": "Кастомный",
}
const ACTION_LABELS: Record<Community["action"], string> = {
"permit": "Permit",
"deny": "Deny",
"local-pref": "Local-pref",
"metric": "MED/Metric",
}
const ACTION_COLOR: Record<Community["action"], string> = {
"permit": "text-emerald-500",
"deny": "text-red-500",
"local-pref": "text-blue-500",
"metric": "text-amber-500",
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function CommunitiesPage() {
@@ -196,12 +168,12 @@ export default function CommunitiesPage() {
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
].map(({ label, value }) => (
<Card key={label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
</CardContent>
</Card>
<Frame key={label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{value}</p>
</FramePanel>
</Frame>
))}
</div>
@@ -225,112 +197,41 @@ export default function CommunitiesPage() {
</div>
{/* list */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-muted-foreground">
<th className="text-left font-medium px-4 py-2.5">Community</th>
<th className="text-left font-medium px-4 py-2.5">Имя / описание</th>
<th className="text-left font-medium px-4 py-2.5">Тип</th>
<th className="text-left font-medium px-4 py-2.5">Действие</th>
<th className="text-right font-medium px-4 py-2.5">Маршрутов</th>
<th className="text-right font-medium px-4 py-2.5">Серверов</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{filtered.map(c => (
<tr
key={c.id}
onClick={() => setSelected(c)}
className={cn(
"border-b last:border-0 cursor-pointer hover:bg-muted/40 transition-colors",
selected?.id === c.id && "bg-primary/5",
!c.enabled && "opacity-50",
)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className="font-mono text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
{c.value}
</span>
<button
onClick={e => { e.stopPropagation(); handleCopy(c.value) }}
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
>
{copied === c.value
? <CheckIcon className="size-3" />
: <CopyIcon className="size-3" />
}
</button>
</div>
</td>
<td className="px-4 py-2.5">
<p className="font-medium text-xs">{c.name}</p>
<p className="text-xs text-muted-foreground line-clamp-1">{c.description}</p>
</td>
<td className="px-4 py-2.5">
<span className="text-xs text-muted-foreground">{TYPE_LABELS[c.type]}</span>
</td>
<td className="px-4 py-2.5">
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
</span>
</td>
<td className="px-4 py-2.5 text-right font-mono text-xs tabular-nums">
{c.prefixCount.toLocaleString("ru-RU")}
</td>
<td className="px-4 py-2.5 text-right tabular-nums">
<div className="flex items-center justify-end gap-1">
<ServerIcon className="size-3 text-muted-foreground" />
<span className="font-mono text-xs">{c.serverCount.toLocaleString("ru-RU")}</span>
</div>
</td>
<td className="px-4 py-2.5">
<ChevronRightIcon className="size-4 text-muted-foreground/40" />
</td>
</tr>
))}
</tbody>
</table>
{filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
<TagIcon className="size-8 opacity-30" />
<p className="text-sm">Ничего не найдено</p>
</div>
)}
</div>
</Card>
<DataPageCard>
<CommunitiesDataGrid
communities={filtered}
selectedId={selected?.id}
copiedValue={copied}
onSelect={setSelected}
onCopy={handleCopy}
/>
</DataPageCard>
</div>
{/* ── detail panel ── */}
{selected ? (
<Card className="h-fit sticky top-0">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
selected.enabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-muted text-muted-foreground"
)}>
{selected.enabled ? "Активен" : "Выключен"}
</span>
</div>
<CardTitle className="text-sm font-mono">{selected.value}</CardTitle>
<CardDescription className="text-xs mt-0.5">{selected.name}</CardDescription>
</div>
<OpsPanel
className="h-fit sticky top-0"
title={<span className="font-mono">{selected.value}</span>}
description={selected.name}
headerRight={
<div className="flex gap-1 shrink-0">
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon 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>
}
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
>
<div className="flex items-center gap-2">
<span className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
selected.enabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-muted text-muted-foreground"
)}>
{selected.enabled ? "Активен" : "Выключен"}
</span>
</div>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-xs text-muted-foreground">{selected.description}</p>
<div className="space-y-2">
@@ -368,15 +269,14 @@ export default function CommunitiesPage() {
Скопировать значение
</Button>
</div>
</CardContent>
</Card>
</OpsPanel>
) : (
<Card className="h-fit">
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
<Frame dense className="w-full h-fit">
<FramePanel className="flex flex-col items-center justify-center py-12 gap-3 text-center">
<TagIcon className="size-8 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
</CardContent>
</Card>
</FramePanel>
</Frame>
)}
</div>
</div>
+21 -20
View File
@@ -5,7 +5,9 @@ import { PageHeader } from "@/components/page-header"
import { routerContainers, servers } from "@/lib/data"
import type { RouterContainer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
@@ -169,7 +171,8 @@ function ContainerCard({
const cfg = statusConfig(container.status)
return (
<Card className="overflow-hidden">
<Frame dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
<div className="flex items-center gap-2 min-w-0">
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
@@ -201,7 +204,7 @@ function ContainerCard({
</DropdownMenu>
</div>
<CardContent className="px-4 py-3 flex flex-col gap-3">
<div className="px-4 py-3 flex flex-col gap-3">
{/* image */}
<div className="flex items-center gap-2">
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
@@ -273,8 +276,9 @@ function ContainerCard({
{container.comment && (
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p>
)}
</CardContent>
</Card>
</div>
</FramePanel>
</Frame>
)
}
@@ -323,15 +327,17 @@ export default function ContainersPage() {
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -397,11 +403,7 @@ export default function ContainersPage() {
)}
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7.4+ · /container быстрые команды
</p>
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
@@ -457,8 +459,7 @@ export default function ContainersPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
+104 -219
View File
@@ -1,10 +1,12 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
import { usePathname } from "next/navigation"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { OpsPanel } from "@/components/ops-panel"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { StatusDot } from "@/components/status-dot"
import { StatusBadge } from "@/components/status-badge"
import { Sparkline } from "@/components/sparkline"
@@ -22,8 +24,10 @@ import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data"
import type { GreTunnel } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
import { AlertCircleIcon, AlertTriangleIcon, BellIcon, FilterIcon, GitMergeIcon, InfoIcon, DownloadIcon, ServerIcon } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { DataPageCard } from "@/components/data-page-card"
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
@@ -44,39 +48,48 @@ function makeApiFetch(backendUrl: string) {
}
function StatCard({
label, value, unit, delta, deltaDir, spark, sparkColor,
label, value, unit, delta, deltaDir, spark, sparkColor, icon,
}: {
label: string; value: string; unit?: string; delta?: string
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
icon?: ReactNode
}) {
return (
<Card className="relative overflow-hidden">
<CardContent className="pt-5 pb-4 px-5">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<div className="flex items-baseline gap-1.5 mt-1">
<span className="text-3xl font-semibold tracking-tight tabular-nums">{value}</span>
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
<Frame className="h-full">
<FramePanel className="relative isolate flex h-full flex-col overflow-hidden">
<div className="relative z-10 flex items-start gap-3">
{icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className="size-10.5 text-muted-foreground"
>
{icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{label}</p>
<div className="flex items-baseline gap-1.5">
<span className="text-2xl leading-none font-bold tabular-nums tracking-tight">{value}</span>
{unit ? <span className="text-muted-foreground text-sm">{unit}</span> : null}
</div>
{delta ? (
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
{delta}
</p>
) : null}
</div>
</div>
{delta && (
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
{delta}
</p>
)}
{spark && spark.length > 1 && (
{spark && spark.length > 1 ? (
<div className="absolute right-4 bottom-4 opacity-60">
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
</div>
)}
</CardContent>
</Card>
) : null}
</FramePanel>
</Frame>
)
}
function formatLossPct(loss: number): string {
if (!Number.isFinite(loss)) return "—"
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
}
function fmtIntRu(n: number): string {
return n.toLocaleString("ru-RU")
}
@@ -114,22 +127,6 @@ function readMockDashboardStarIds(): Set<string> {
}
}
/** Совпадает с эталоном uptime / servers */
function TypeChip({ type }: { type: ServerType }) {
return (
<span className={cn(
"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"}
</span>
)
}
interface BackendServerRow {
id: number
name: string
@@ -241,52 +238,6 @@ function mapBackendToServer(s: BackendServerRow): Server {
}
}
function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) {
const srv = catalog.find(s => s.id === probe.srcServerId)
const iface = (probe.srcInterface ?? "").trim() || "auto"
if (!srv) {
return (
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
<span className="mt-1 shrink-0 inline-flex">
<StatusDot status="offline" />
</span>
<div className="min-w-0">
<p className="text-[13px] font-medium text-muted-foreground truncate">
Сервер <span className="font-mono tabular-nums">{probe.srcServerId}</span>
</p>
<p className="text-[11px] font-mono text-muted-foreground truncate mt-0.5" title={iface}>
{iface}
</p>
</div>
</div>
)
}
return (
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
<span className="mt-1 shrink-0 inline-flex">
<StatusDot status={srv.status} pulse={srv.status === "online"} />
</span>
<div className="flex gap-2 min-w-0 flex-1">
<Flag code={srv.country} size={16} className="shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[13px] font-medium leading-tight truncate">{srv.name}</span>
<TypeChip type={srv.type} />
</div>
<p className="text-[11px] text-muted-foreground mt-0.5 truncate" title={`Интерфейс: ${iface}`}>
<span className="font-mono tabular-nums">{iface}</span>
{srv.site && srv.site !== "—" && (
<span className="text-muted-foreground/90"> · {srv.site}</span>
)}
</p>
</div>
</div>
</div>
)
}
export default function DashboardPage() {
const pathname = usePathname()
const { mode, backendUrl, prefsHydrated } = useDataSource()
@@ -691,7 +642,8 @@ export default function DashboardPage() {
}
}
const loadingBlock = probesLoading && liveProbes === null
const liveDataPending = liveServersResolved === null || liveProbes === null
const loadingBlock = liveDataPending || (probesLoading && liveProbes === null)
const srvList = liveServersResolved ?? []
const totalSrv = srvList.length
const onlineSrv = srvList.filter((s) => s.status === "online").length
@@ -783,6 +735,7 @@ export default function DashboardPage() {
deltaDir={dashboardKpi.servers.deltaDir}
spark={dashboardKpi.servers.spark}
sparkColor={dashboardKpi.servers.sparkColor}
icon={<ServerIcon aria-hidden />}
/>
<StatCard
label="Активные фильтры"
@@ -792,6 +745,7 @@ export default function DashboardPage() {
deltaDir={dashboardKpi.filters.deltaDir}
spark={dashboardKpi.filters.spark}
sparkColor={dashboardKpi.filters.sparkColor}
icon={<FilterIcon aria-hidden />}
/>
<StatCard
label="BGP-префиксы"
@@ -801,6 +755,7 @@ export default function DashboardPage() {
deltaDir={dashboardKpi.bgp.deltaDir}
spark={dashboardKpi.bgp.spark}
sparkColor={dashboardKpi.bgp.sparkColor}
icon={<GitMergeIcon aria-hidden />}
/>
<StatCard
label="Активные алерты"
@@ -810,26 +765,23 @@ export default function DashboardPage() {
deltaDir={dashboardKpi.alerts.deltaDir}
spark={dashboardKpi.alerts.spark}
sparkColor={dashboardKpi.alerts.sparkColor}
icon={<BellIcon aria-hidden />}
/>
</div>
{/* Latency chart + Events */}
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Задержка до серверов</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">
{latencyChartBlock.kind === "mock" && latencyChartBlock.subtitle}
{latencyChartBlock.kind === "live" && latencyChartBlock.subtitle}
{latencyChartBlock.kind === "loading" && "Загрузка…"}
{latencyChartBlock.kind === "empty" && "Нет серии RTT для графика"}
</p>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 px-3">
<OpsPanel
title="Задержка до серверов"
description={
latencyChartBlock.kind === "mock" || latencyChartBlock.kind === "live"
? latencyChartBlock.subtitle
: latencyChartBlock.kind === "loading"
? "Загрузка…"
: "Нет серии RTT для графика"
}
contentClassName="px-3 pb-3"
>
{latencyChartBlock.kind === "loading" && (
<div
className="w-full rounded-md bg-muted/50 animate-pulse"
@@ -847,23 +799,20 @@ export default function DashboardPage() {
labels={latencyChartBlock.labels}
/>
)}
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Последние события</CardTitle>
<OpsPanel
title="Последние события"
description="Система и BGP-активность"
headerRight={
<Link
href="/alerts"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-xs h-7")}
>
Все
</Link>
</div>
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
</CardHeader>
<CardContent className="pt-0 px-0">
}
>
<div className="divide-y divide-border">
{eventsLoading && recentEvents.length === 0 && (
<div className="px-5 py-6 text-sm text-muted-foreground">Загрузка событий...</div>
@@ -889,48 +838,41 @@ export default function DashboardPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
{/* Bandwidth + Server status */}
<div className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Пропускная способность</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">Суммарный RX / TX по всем серверам</p>
</div>
<OpsPanel
title="Пропускная способность"
description="Суммарный RX / TX по всем серверам"
headerRight={
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-foreground/80 rounded inline-block" />RX 318 Мбит/с</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-[var(--chart-tx)] rounded inline-block" />TX 244 Мбит/с</span>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 px-3">
}
contentClassName="px-3 pb-3"
>
<BandwidthChart rx={traffic.rx} tx={traffic.tx} />
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<CardTitle className="text-base">Состояние серверов</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5 truncate" title={serverStatusModel.subtitle}>
{serverStatusModel.subtitle}
</p>
</div>
<OpsPanel
title="Состояние серверов"
description={
<span className="truncate" title={serverStatusModel.subtitle}>
{serverStatusModel.subtitle}
</span>
}
headerRight={
<Link
href="/servers"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs shrink-0")}
>
Управление
</Link>
</div>
</CardHeader>
<CardContent className="pt-0 px-0">
}
>
<div className="divide-y divide-border">
{serverStatusModel.kind === "loading" && (
<>
@@ -980,8 +922,7 @@ export default function DashboardPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
<div className="grid grid-cols-1 gap-4">
@@ -997,91 +938,35 @@ export default function DashboardPage() {
</div>
{/* Ping probes table */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Активные пробы</CardTitle>
<p className={cn(
"text-sm mt-0.5",
probesError && isLive ? "text-destructive" : "text-muted-foreground",
)}
>
{probesSubtitle}
</p>
</div>
<OpsPanel
title="Активные пробы"
description={
<span className={probesError && isLive ? "text-destructive" : undefined}>
{probesSubtitle}
</span>
}
headerRight={
<Link
href="/uptime"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}
>
Открыть монитор
</Link>
</div>
</CardHeader>
<CardContent className="pt-0 px-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-2.5 w-[min(280px,32vw)]">Источник</th>
<th className="text-left font-medium px-4 py-2.5">Проба</th>
<th className="text-left font-medium px-4 py-2.5">Цель</th>
<th className="text-left font-medium px-4 py-2.5">Фильтр</th>
<th className="text-right font-medium px-4 py-2.5">RTT</th>
<th className="text-right font-medium px-4 py-2.5">Потери</th>
<th className="text-left font-medium px-4 py-2.5 w-36">60с</th>
<th className="text-left font-medium px-4 py-2.5">Статус</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{isLive && probesLoading && liveProbes === null && (
<tr>
<td colSpan={8} className="px-5 py-6">
<div className="h-10 rounded-md bg-muted/50 animate-pulse max-w-md mx-auto" />
</td>
</tr>
)}
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.map((p) => {
const sparkColor = p.status === "down" ? "hsl(0 84% 60%)" : p.status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)"
return (
<tr key={p.id} className="hover:bg-muted/40 transition-colors">
<td className="px-5 py-2.5 align-top">
<ProbeSourceCell probe={p} catalog={probeServerCatalog} />
</td>
<td className="px-4 py-2.5 font-medium">{p.name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{p.target}</td>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{p.filter}
</span>
</td>
<td className="px-4 py-2.5 font-mono text-right">{p.rtt == null ? "—" : `${p.rtt} мс`}</td>
<td className={`px-4 py-2.5 font-mono text-right ${p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}>
{formatLossPct(p.loss)}
</td>
<td className="px-4 py-2.5">
<Sparkline data={p.series} width={120} height={24} color={sparkColor} />
</td>
<td className="px-4 py-2.5">
<StatusBadge status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"} />
</td>
</tr>
)
})}
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.length === 0 && (
<tr>
<td colSpan={8} className="px-5 py-8 text-center text-sm text-muted-foreground">
{isLive && probesError
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."}
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
}
>
<DataPageCard className="rounded-none border-0 shadow-none">
<DashboardActiveProbesDataGrid
probes={activeProbesTable}
catalog={probeServerCatalog}
isLoading={isLive && probesLoading && liveProbes === null}
emptyDescription={
isLive && probesError
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."
}
/>
</DataPageCard>
</OpsPanel>
</div>
</div>
File diff suppressed because it is too large Load Diff
+46 -56
View File
@@ -1,18 +1,24 @@
"use client"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog"
import { domains as mockDomains } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
export default function DomainsPage() {
const { mode } = useDataSource()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("")
const useEvoCatalog = mode === "live" && enabled
@@ -22,13 +28,26 @@ export default function DomainsPage() {
return snapshot?.domains ?? []
}, [useEvoCatalog, loading, snapshot])
const filtered = useMemo(() => {
if (!search) return rows
const q = search.toLowerCase()
return rows.filter(
(r) =>
r.domain.toLowerCase().includes(q) ||
r.asn.toLowerCase().includes(q) ||
r.filter.toLowerCase().includes(q),
)
}, [rows, search])
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
</>
@@ -55,60 +74,31 @@ export default function DomainsPage() {
</p>
)}
</div>
<DataTable
data={rows}
searchPlaceholder="Поиск по домену…"
searchKeys={["domain", "asn", "filter"]}
columns={[
{
key: "domain",
label: "Домен",
render: (d) => <span className="font-medium">{d.domain}</span>,
},
{
key: "resolvedIp",
label: "Resolved IP",
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.resolvedIp}</span>,
},
{
key: "asn",
label: "ASN",
render: (d) => <span className="font-mono text-xs">{d.asn}</span>,
},
{
key: "purpose",
label: "Назначение",
render: (d) => (
<span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>
),
},
{
key: "filter",
label: "Фильтр",
render: (d) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
</span>
),
},
{
key: "updated",
label: "Обновлён",
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
},
{
key: "enabled",
label: "Статус",
render: (d) => (
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
{d.enabled ? "Активен" : "Отключён"}
</span>
),
},
]}
/>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по домену…"
countLabel={`${filtered.length} доменов`}
/>
<DomainsDataGrid
domains={filtered}
isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog}
/>
</DataPageCard>
</div>
</div>
<FileImportDialog
open={importOpen}
onOpenChange={setImportOpen}
title="Импорт доменов"
description="Загрузите CSV или JSON со списком доменов"
accept=".csv,.json,text/csv,application/json"
onImport={async (files) => {
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
}}
/>
</div>
)
}
+53 -328
View File
@@ -2,9 +2,14 @@
import { useMemo, useState, useCallback, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { EmptyState } from "@/components/empty-state"
import { StatusDot } from "@/components/status-dot"
import { Flag } from "@/components/flag"
import { Card } from "@/components/ui/card"
import {
FiltersDataGrid,
type RecursiveRouteLite,
} from "@/components/data-grids/filters-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
@@ -35,42 +40,6 @@ import { toast } from "sonner"
type FilterRouterSyncStatus = "synced" | "drift" | "missing"
function RouterSyncMarker({
status,
}: {
status: FilterRouterSyncStatus | null | "skip"
}) {
if (status === "skip") {
return <span className="size-3.5 shrink-0 block" aria-hidden />
}
const icon =
status === "synced"
? <CheckCircle2Icon className="size-3.5 text-emerald-600 dark:text-emerald-500 shrink-0" />
: status === "drift"
? <AlertTriangleIcon className="size-3.5 text-amber-500 shrink-0" />
: status === "missing"
? <XCircleIcon className="size-3.5 text-destructive shrink-0" />
: <CircleDashedIcon className="size-3.5 text-muted-foreground/35 shrink-0" />
const title =
status === "synced"
? "Совпадает с цепочкой bgp-in на MikroTik"
: status === "drift"
? "В БД и на роутере разное действие (gateway, blackhole или out-interface)"
: status === "missing"
? "Эта community не найдена в правиле bgp-in на роутере"
: "Не проверено — нажмите «Сверить с роутером»"
return (
<Tooltip>
<TooltipTrigger className="inline-flex cursor-default border-0 bg-transparent p-0">
{icon}
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{title}
</TooltipContent>
</Tooltip>
)
}
function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` }
function innerIpToGateway(ip: string) { return ip.split("/")[0] }
@@ -137,16 +106,6 @@ function dedupeRecursiveRoutesByDstAddress(routes: RecursiveRouteLite[]): Recurs
})
}
interface RecursiveRouteLite {
id: string
dstAddress: string
gateway: string
distance: number
routingTable: string
comment: string
disabled: boolean
}
const COMMUNITY_NAMES: Record<string, string> = {
"65001:100": "youtube-bypass",
"65001:200": "streaming-eu",
@@ -478,171 +437,6 @@ function CommunityInput({
)
}
// ── filter rule row ────────────────────────────────────────────────────────────
function FilterRow({
rule, index, isLast, onEdit, onDelete, onMoveUp, onMoveDown, tunnelsList, serversList,
communityNameMap,
recursiveRoutes,
routerSyncStatus,
}: {
rule: FilterRule; index: number; isLast: boolean
onEdit: () => void; onDelete: () => void; onMoveUp: () => void; onMoveDown: () => void
tunnelsList: GreTunnel[]
serversList: Server[]
communityNameMap: Record<string, string>
recursiveRoutes: RecursiveRouteLite[]
routerSyncStatus?: FilterRouterSyncStatus | null | "skip"
}) {
const [confirmDel, setConfirmDel] = useState(false)
const isBlackhole = rule.action === "blackhole"
const isRecRef = !isBlackhole && isRecursiveGatewayRef(rule.gatewayTunnelId)
const recRowByRef = isRecRef ? recursiveRoutes.find(r => r.id === rule.gatewayTunnelId.slice(4)) : undefined
const recRowByHop =
!isBlackhole && !(rule.gatewayTunnelId ?? "").trim() && rule.gateway.trim()
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
: undefined
const recRow = recRowByRef ?? recRowByHop
const treatAsRecursive =
!isBlackhole && (isRecRef || !!recRowByHop)
const tunnel = !isBlackhole && !treatAsRecursive
? tunnelsList.find(t => t.id === rule.gatewayTunnelId)
: undefined
const remoteSrv = tunnel ? serversList.find(s => s.host === tunnel.remoteAddress) : undefined
const communityName = communityNameMap[rule.community] ?? rule.communityName
return (
<div className={cn(
"group grid items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors",
"grid-cols-[20px_20px_22px_1fr_1fr_1fr_64px]",
isBlackhole && "bg-red-500/[0.03]",
)}>
{/* priority */}
<div className="flex items-center justify-center text-[11px] font-mono text-muted-foreground/40 select-none">
{isLast
? <StarIcon className="size-3 text-amber-400 fill-amber-400" aria-label="Наивысший приоритет" />
: <span>{index + 1}</span>
}
</div>
{/* reorder */}
<div className="flex flex-col gap-px opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={onMoveUp} disabled={index === 0}
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20 transition-colors">
<ChevronUpIcon className="size-3" />
</button>
<button onClick={onMoveDown} disabled={isLast}
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20 transition-colors">
<ChevronDownIcon className="size-3" />
</button>
</div>
{/* MikroTik sync marker */}
<div className="flex items-center justify-center">
<RouterSyncMarker
status={routerSyncStatus === undefined ? "skip" : routerSyncStatus}
/>
</div>
{/* community */}
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border font-mono",
isBlackhole
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
)}>
{rule.community}
</span>
{isBlackhole && (
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 uppercase tracking-wide">
blackhole
</span>
)}
</div>
{communityName && (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">{communityName}</p>
)}
</div>
{/* gateway / tunnel — or blackhole target */}
<div className="min-w-0 flex flex-col gap-0.5">
{isBlackhole ? (
<div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full shrink-0 bg-red-500 animate-pulse" />
<span className="font-mono text-xs font-medium text-red-600 dark:text-red-400">type=blackhole</span>
</div>
) : (
<>
<div className="flex items-center gap-1.5 flex-wrap">
<span className={cn(
"size-1.5 rounded-full shrink-0",
treatAsRecursive
? "bg-sky-500"
: tunnel?.status === "up"
? "bg-[var(--status-online)]"
: tunnel?.status === "degraded"
? "bg-[var(--status-degraded)]"
: "bg-[var(--status-offline)]",
)} />
{treatAsRecursive ? (
<>
<RouteIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-xs font-medium">
{recRow ? gatewayFromRecursiveDst(recRow.dstAddress) : rule.gateway}
</span>
<span className="text-[10px] font-medium text-muted-foreground border border-border rounded px-1 uppercase tracking-wide">
recursive
</span>
</>
) : (
<>
{remoteSrv && <Flag code={remoteSrv.country} />}
<span className="font-mono text-xs font-medium">{rule.gateway}</span>
</>
)}
</div>
{treatAsRecursive ? (
recRow ? (
<p className="text-[11px] text-muted-foreground truncate pl-3">{recRow.dstAddress}</p>
) : isRecRef ? (
<p className="text-[11px] text-amber-600 dark:text-amber-400 truncate pl-3">
рекурсивный маршрут (нет строки в списке синхронизируйте «Рекурсивные маршруты»)
</p>
) : null
) : tunnel ? (
<p className="text-[11px] text-muted-foreground truncate pl-3">{tunnel.name}</p>
) : null}
</>
)}
</div>
{/* description */}
<p className="text-xs text-muted-foreground truncate">{rule.description || "—"}</p>
{/* actions */}
<div className="flex items-center gap-0.5 justify-end opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onEdit}>
<PencilIcon className="size-3.5" />
</Button>
<Button size="sm" variant="ghost"
className={cn("size-7 p-0 transition-colors",
confirmDel
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
: "text-muted-foreground hover:text-destructive",
)}
onClick={() => { if (!confirmDel) setConfirmDel(true); else onDelete() }}
onBlur={() => setConfirmDel(false)}>
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
</Button>
</div>
</div>
)
}
// ── rule sheet ─────────────────────────────────────────────────────────────────
interface RuleForm {
@@ -1496,8 +1290,6 @@ function CopyRulesSheet({
// ── page ───────────────────────────────────────────────────────────────────────
type SortKey = "community" | "gateway" | "description"
interface BackendServer {
id: number
name: string
@@ -1529,11 +1321,6 @@ function makeApiFetch(backendUrl: string) {
}
}
function SortIndicator({ k, sortKey, sortAsc }: { k: SortKey; sortKey: SortKey; sortAsc: boolean }) {
if (sortKey !== k) return <ArrowUpDownIcon className="size-3 opacity-30" />
return sortAsc ? <ArrowUpIcon className="size-3" /> : <ArrowDownIcon className="size-3" />
}
export default function FiltersPage() {
const { mode, backendUrl } = useDataSource()
const evo = useEvoBGP()
@@ -1601,8 +1388,6 @@ export default function FiltersPage() {
}, [isLive, apiFetch])
const [selectedServerId, setSelectedServerId] = useState("srv1")
const [search, setSearch] = useState("")
const [sortKey, setSortKey] = useState<SortKey>("community")
const [sortAsc, setSortAsc] = useState(true)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm())
@@ -1707,24 +1492,14 @@ export default function FiltersPage() {
const filteredRules = useMemo(() => {
const q = search.toLowerCase()
const list = q
? currentRules.filter(r =>
r.community.includes(q) ||
(communityNameMap[r.community] ?? "").toLowerCase().includes(q) ||
r.gateway.includes(q) ||
r.description.toLowerCase().includes(q)
)
: [...currentRules]
if (search) {
const mult = sortAsc ? 1 : -1
list.sort((a, b) => {
if (sortKey === "community") return mult * a.community.localeCompare(b.community)
if (sortKey === "gateway") return mult * a.gateway.localeCompare(b.gateway)
return mult * a.description.localeCompare(b.description)
})
}
return list
}, [currentRules, search, sortKey, sortAsc, communityNameMap])
if (!q) return currentRules
return currentRules.filter((r) =>
r.community.includes(q) ||
(communityNameMap[r.community] ?? "").toLowerCase().includes(q) ||
r.gateway.includes(q) ||
r.description.toLowerCase().includes(q),
)
}, [currentRules, search, communityNameMap])
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
setRouterCompare(rc => (rc && rc.serverId === serverId ? null : rc))
@@ -1842,10 +1617,6 @@ export default function FiltersPage() {
})
}
const toggleSort = (k: SortKey) => {
if (sortKey === k) setSortAsc(v => !v); else { setSortKey(k); setSortAsc(true) }
}
if (!selectedServer) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
@@ -2030,10 +1801,10 @@ export default function FiltersPage() {
)
})()}
<Card className="overflow-hidden py-0 gap-0">
<DataPageCard>
{/* selected server header */}
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
<StatusDot status={selectedServer.status} pulse={selectedServer.status === "online"} />
<Flag code={selectedServer.country} size={16} />
<span className="font-mono text-sm font-semibold">{selectedServer.name}</span>
@@ -2062,22 +1833,23 @@ export default function FiltersPage() {
{currentRules.length === 0 ? (
/* empty state */
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
<NetworkIcon className="size-8 text-muted-foreground/20" />
<div>
<p className="text-sm font-medium text-muted-foreground">Нет правил фильтрации</p>
<p className="text-xs text-muted-foreground/60 mt-1">
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
? "Добавьте правило: BGP community → GRE-шлюз"
: "Сначала добавьте GRE-туннели для этого сервера"}
</p>
</div>
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) && (
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />Добавить правило
</Button>
)}
</div>
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет правил фильтрации"
description={
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
? "Добавьте правило: BGP community → GRE-шлюз"
: "Сначала добавьте GRE-туннели для этого сервера"
}
action={
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) ? (
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />Добавить правило
</Button>
) : undefined
}
className="py-20"
/>
) : filteredRules.length === 0 ? (
/* no search results */
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
@@ -2085,80 +1857,33 @@ export default function FiltersPage() {
<p className="text-sm">Ничего не найдено</p>
</div>
) : (
<>
{/* table header with sort */}
<div className={cn(
"grid items-center gap-3 px-4 py-1.5 border-b bg-muted/30",
"grid-cols-[20px_20px_22px_1fr_1fr_1fr_64px]",
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
)}>
<span>#</span>
<span />
<Tooltip>
<TooltipTrigger className="cursor-help text-center font-mono normal-case tracking-normal border-0 bg-transparent p-0 w-full">
MT
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
Совпадение с MikroTik (bgp-in): нажмите «Сверить с роутером»
</TooltipContent>
</Tooltip>
<button onClick={() => toggleSort("community")}
className="flex items-center gap-1 hover:text-foreground text-left transition-colors">
Community <SortIndicator k="community" sortKey={sortKey} sortAsc={sortAsc} />
</button>
<button onClick={() => toggleSort("gateway")}
className="flex items-center gap-1 hover:text-foreground text-left transition-colors">
Gateway <SortIndicator k="gateway" sortKey={sortKey} sortAsc={sortAsc} />
</button>
<button onClick={() => toggleSort("description")}
className="flex items-center gap-1 hover:text-foreground text-left transition-colors">
Описание <SortIndicator k="description" sortKey={sortKey} sortAsc={sortAsc} />
</button>
<span />
</div>
{/* rows */}
<div className="divide-y divide-border/60">
{(search ? filteredRules : currentRules).map((rule, i, arr) => (
<FilterRow
key={rule.id}
rule={rule}
index={i}
isLast={i === arr.length - 1}
tunnelsList={allTunnels}
serversList={allServers}
communityNameMap={communityNameMap}
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
routerSyncStatus={
!isLive
? undefined
: !routerCompare || routerCompare.serverId !== selectedServerId
? null
: routerCompare.byCommunity[rule.community.trim()] ?? null
}
onEdit={() => openEdit(rule)}
onDelete={() => handleDelete(rule.id)}
onMoveUp={() => handleMoveUp(currentRules.findIndex(r => r.id === rule.id))}
onMoveDown={() => handleMoveDown(currentRules.findIndex(r => r.id === rule.id))}
/>
))}
</div>
{/* footer hint */}
<div className="px-4 py-2 text-[11px] text-muted-foreground/40 flex items-center gap-1.5 border-t">
<StarIcon className="size-3 text-amber-400 fill-amber-400 shrink-0" />
Последнее правило имеет наивысший приоритет в RouterOS
</div>
</>
<FiltersDataGrid
rules={search ? filteredRules : currentRules}
tunnelsList={allTunnels}
serversList={allServers}
communityNameMap={communityNameMap}
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
routerSyncByCommunity={
!isLive || !routerCompare || routerCompare.serverId !== selectedServerId
? null
: routerCompare.byCommunity
}
isLive={isLive}
enableSorting={!!search}
onEdit={openEdit}
onDelete={handleDelete}
onMoveUp={(id) => handleMoveUp(currentRules.findIndex((r) => r.id === id))}
onMoveDown={(id) => handleMoveDown(currentRules.findIndex((r) => r.id === id))}
/>
)}
{/* add rule shortcut */}
<button onClick={openCreate}
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" />
Добавить правило для {selectedServer.name}
</button>
</Card>
</DataPageCard>
</div>
</div>
+134 -340
View File
@@ -2,10 +2,26 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { PageHeader } from "@/components/page-header"
import {
FirewallRulesDataGrid,
ActionBadge,
ChainBadge,
} from "@/components/data-grids/firewall-rules-data-grid"
import { FirewallScenarioRulesDataGrid } from "@/components/data-grids/firewall-scenario-rules-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbarFrame } from "@/components/data-page-toolbar"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { firewallRules, type FirewallRule } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
@@ -22,7 +38,7 @@ import {
CheckIcon, PowerIcon, CheckCircleIcon,
PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
PackageIcon, SlidersHorizontalIcon,
SlidersHorizontalIcon,
} from "lucide-react"
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -341,62 +357,7 @@ function fmtHits(n: number): string {
return String(n)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" role="switch" aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
checked ? "bg-primary" : "bg-input",
)}>
<span className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)} />
</button>
)
}
function ActionBadge({ action }: { action: string }) {
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
return (
<span className={cn("text-[11px] font-mono font-medium px-2 py-0.5 rounded border whitespace-nowrap", cls)}>
{action}
</span>
)
}
function ChainBadge({ chain }: { chain: string }) {
const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground"
return (
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded", cls)}>
{chain}
</span>
)
}
function Field({ label, hint, required, children }: {
label: string; hint?: string; required?: boolean; children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
// ActionBadge, ChainBadge — из firewall-rules-data-grid
function NativeSelect({ value, onChange, children, className }: {
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
@@ -507,67 +468,67 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
<div className="flex flex-col gap-4">
<SectionTitle>Цепочка и действие</SectionTitle>
<div className="grid grid-cols-2 gap-3">
<Field label="Цепочка" required>
<FormField label="Цепочка" required>
<NativeSelect value={form.chain} onChange={(v) => set("chain", v)}>
{chainsForGroup.map((c) => <option key={c} value={c}>{c}</option>)}
</NativeSelect>
</Field>
<Field label="Действие" required>
</FormField>
<FormField label="Действие" required>
<NativeSelect value={form.action} onChange={(v) => set("action", v)}>
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
</NativeSelect>
</Field>
</FormField>
</div>
</div>
{/* Matching */}
<div className="flex flex-col gap-4">
<SectionTitle>Условие совпадения</SectionTitle>
<Field label="Протокол">
<FormField label="Протокол">
<NativeSelect value={form.proto} onChange={(v) => set("proto", v)}>
{["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) =>
<option key={p} value={p}>{p}</option>
)}
</NativeSelect>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Src-address / Address-list" hint="IP, CIDR или имя address-list">
<FormField label="Src-address / Address-list" hint="IP, CIDR или имя address-list">
<Input className="font-mono h-8" placeholder="10.0.0.0/8"
value={form.srcAddrList} onChange={(e) => set("srcAddrList", e.target.value)} />
</Field>
<Field label="Dst-address / Address-list">
</FormField>
<FormField label="Dst-address / Address-list">
<Input className="font-mono h-8" placeholder="0.0.0.0/0"
value={form.dstAddrList} onChange={(e) => set("dstAddrList", e.target.value)} />
</Field>
</FormField>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Src-port" hint="TCP/UDP, напр. 1024-65535">
<FormField label="Src-port" hint="TCP/UDP, напр. 1024-65535">
<Input className="font-mono h-8" placeholder="—"
value={form.srcPort} onChange={(e) => set("srcPort", e.target.value)} />
</Field>
<Field label="Dst-port">
</FormField>
<FormField label="Dst-port">
<Input className="font-mono h-8" placeholder="443"
value={form.dstPort} onChange={(e) => set("dstPort", e.target.value)} />
</Field>
</FormField>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="In-interface" hint="Входящий интерфейс">
<FormField label="In-interface" hint="Входящий интерфейс">
<Input className="font-mono h-8" placeholder="wan-msk"
value={form.inIface} onChange={(e) => set("inIface", e.target.value)} />
</Field>
<Field label="Out-interface">
</FormField>
<FormField label="Out-interface">
<Input className="font-mono h-8" placeholder="lan"
value={form.outIface} onChange={(e) => set("outIface", e.target.value)} />
</Field>
</FormField>
</div>
<Field label="Connection-state" hint="Через запятую: new, established, related, invalid">
<FormField label="Connection-state" hint="Через запятую: new, established, related, invalid">
<Input className="font-mono h-8" placeholder="new,established"
value={form.connState} onChange={(e) => set("connState", e.target.value)} />
</Field>
</FormField>
</div>
{/* Log + Comment */}
@@ -578,18 +539,18 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
<p className="text-sm font-medium">Log</p>
<p className="text-xs text-muted-foreground">Записывать совпадения в системный лог</p>
</div>
<Toggle checked={form.log} onChange={(v) => set("log", v)} />
<FormToggle checked={form.log} onChange={(v) => set("log", v)} />
</div>
{form.log && (
<Field label="Log-prefix" hint="Метка в логе, например FW-DROP">
<FormField label="Log-prefix" hint="Метка в логе, например FW-DROP">
<Input className="font-mono h-8" placeholder="FW-RULE"
value={form.logPrefix} onChange={(e) => set("logPrefix", e.target.value)} />
</Field>
</FormField>
)}
<Field label="Комментарий">
<FormField label="Комментарий">
<Input className="h-8" placeholder="Описание правила"
value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</Field>
</FormField>
</div>
{/* Enabled */}
@@ -598,7 +559,7 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
<p className="text-sm font-medium">Правило включено</p>
<p className="text-xs text-muted-foreground">Отключённые правила сохраняются, но не применяются</p>
</div>
<Toggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
<FormToggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
</div>
{/* CLI preview */}
@@ -766,7 +727,8 @@ function AddressListsTab({ entries, onAdd }: {
{/* groups */}
<div className="flex flex-col gap-3">
{Object.entries(grouped).map(([listName, listEntries]) => (
<Card key={listName}>
<Frame key={listName} dense className="w-full">
<FramePanel className="p-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/20">
<div className="flex items-center gap-2">
<ListFilterIcon className="size-3.5 text-muted-foreground" />
@@ -806,7 +768,8 @@ function AddressListsTab({ entries, onAdd }: {
</div>
))}
</div>
</Card>
</FramePanel>
</Frame>
))}
</div>
</div>
@@ -1039,14 +1002,14 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
<div className="flex flex-col gap-3">
<SectionTitle>Название</SectionTitle>
<div className="grid grid-cols-2 gap-3">
<Field label="Название сценария" required>
<FormField label="Название сценария" required>
<Input className="h-8" placeholder="Блокировка Tor Exit"
value={name} onChange={e => setName(e.target.value)} />
</Field>
<Field label="Описание">
</FormField>
<FormField label="Описание">
<Input className="h-8" placeholder="Краткое описание"
value={desc} onChange={e => setDesc(e.target.value)} />
</Field>
</FormField>
</div>
</div>
@@ -1054,7 +1017,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
<div className="flex flex-col gap-3">
<SectionTitle>Тестовый пакет по умолчанию</SectionTitle>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<Field label="Направление / цепочка">
<FormField label="Направление / цепочка">
<NativeSelect value={pkt.chain} onChange={v => setP("chain", v)}>
<optgroup label="Полный маршрут">
<option value="forward">forward транзит</option>
@@ -1067,40 +1030,40 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
.map(c => <option key={c} value={c}>{c}</option>)}
</optgroup>
</NativeSelect>
</Field>
<Field label="Протокол">
</FormField>
<FormField label="Протокол">
<NativeSelect value={pkt.proto} onChange={v => setP("proto", v)}>
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
</NativeSelect>
</Field>
<Field label="Conn-state">
</FormField>
<FormField label="Conn-state">
<Input className="font-mono h-8" value={pkt.connState}
placeholder="new" onChange={e => setP("connState", e.target.value)} />
</Field>
<Field label="Src IP">
</FormField>
<FormField label="Src IP">
<Input className="font-mono h-8" value={pkt.srcAddr}
onChange={e => setP("srcAddr", e.target.value)} />
</Field>
<Field label="Dst IP">
</FormField>
<FormField label="Dst IP">
<Input className="font-mono h-8" value={pkt.dstAddr}
onChange={e => setP("dstAddr", e.target.value)} />
</Field>
<Field label="Dst Port">
</FormField>
<FormField label="Dst Port">
<Input className="font-mono h-8" value={pkt.dstPort}
placeholder="443" onChange={e => setP("dstPort", e.target.value)} />
</Field>
<Field label="In-interface">
</FormField>
<FormField label="In-interface">
<Input className="font-mono h-8" value={pkt.inIface}
placeholder="lan" onChange={e => setP("inIface", e.target.value)} />
</Field>
<Field label="Out-interface">
</FormField>
<FormField label="Out-interface">
<Input className="font-mono h-8" value={pkt.outIface}
placeholder="wan-msk" onChange={e => setP("outIface", e.target.value)} />
</Field>
<Field label="Dst addr-list">
</FormField>
<FormField label="Dst addr-list">
<Input className="font-mono h-8" value={pkt.dstAddrList}
placeholder="" onChange={e => setP("dstAddrList", e.target.value)} />
</Field>
</FormField>
</div>
</div>
@@ -1170,7 +1133,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Toggle checked={addForm.enabled} onChange={v => setAF("enabled", v)} />
<FormToggle checked={addForm.enabled} onChange={v => setAF("enabled", v)} />
<span className="text-xs text-muted-foreground">Включено</span>
</div>
<Button size="sm" onClick={addRule}><PlusIcon className="size-4" />Добавить</Button>
@@ -1180,57 +1143,13 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
{/* Rules table */}
{rules.length > 0 ? (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/30 text-muted-foreground">
<th className="text-left px-3 py-2 w-6">#</th>
<th className="text-left px-3 py-2">Цепочка</th>
<th className="text-left px-3 py-2">Действие</th>
<th className="text-left px-3 py-2">Src</th>
<th className="text-left px-3 py-2">Dst</th>
<th className="text-left px-3 py-2">Порт</th>
<th className="text-left px-3 py-2">Iface</th>
<th className="text-left px-3 py-2">Комментарий</th>
<th className="w-24 px-2 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{rules.map((r, i) => (
<tr key={r.id} className={cn(
"hover:bg-muted/20 transition-colors",
!r.enabled && "opacity-40",
)}>
<td className="px-3 py-1.5 text-muted-foreground tabular-nums">{i + 1}</td>
<td className="px-3 py-1.5"><ChainBadge chain={r.chain} /></td>
<td className="px-3 py-1.5"><ActionBadge action={r.action} /></td>
<td className="px-3 py-1.5 font-mono text-muted-foreground max-w-[90px] truncate">{r.src || "any"}</td>
<td className="px-3 py-1.5 font-mono text-muted-foreground max-w-[90px] truncate">{r.dst || "any"}</td>
<td className="px-3 py-1.5 font-mono text-muted-foreground">{r.port || "—"}</td>
<td className="px-3 py-1.5 font-mono text-muted-foreground">{r.iface || "—"}</td>
<td className="px-3 py-1.5 text-muted-foreground/70 max-w-[110px] truncate">{r.comment || "—"}</td>
<td className="px-2 py-1.5">
<div className="flex items-center gap-0.5 justify-end">
<button type="button" onClick={() => toggleEnabled(r.id)}
title={r.enabled ? "Отключить" : "Включить"}
className="p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors">
<PowerIcon className="size-3.5" />
</button>
<button type="button" onClick={() => moveRule(r.id, -1)} disabled={i === 0}
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"></button>
<button type="button" onClick={() => moveRule(r.id, 1)} disabled={i === rules.length - 1}
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"></button>
<button type="button" onClick={() => removeRule(r.id)}
className="p-0.5 ml-0.5 text-muted-foreground/40 hover:text-red-500 transition-colors">
<Trash2Icon className="size-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<FirewallScenarioRulesDataGrid
rules={rules}
onToggleEnabled={toggleEnabled}
onMoveUp={(id) => moveRule(id, -1)}
onMoveDown={(id) => moveRule(id, 1)}
onRemove={removeRule}
/>
) : (
!addOpen && (
<div className="text-center py-6 text-sm text-muted-foreground border rounded-lg border-dashed">
@@ -1394,14 +1313,7 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
<div className="flex flex-col gap-4">
{/* ── Presets + Scenarios card ─────────────────────────────────────────── */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-4">
{/* Built-in traffic presets */}
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2.5">
Пресеты трафика
</p>
<OpsPanel title="Пресеты трафика" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
{SIM_PRESETS.map(p => (
<button key={p.id} type="button" onClick={() => applyPreset(p)}
@@ -1420,7 +1332,6 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
</button>
))}
</div>
</div>
<div className="h-px bg-border" />
@@ -1485,17 +1396,11 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
</div>
)}
</div>
</CardContent>
</Card>
</OpsPanel>
{/* ── Packet editor ───────────────────────────────────────────────────── */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<PackageIcon className="size-3.5" />Параметры пакета
</p>
<OpsPanel title="Параметры пакета" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center justify-end flex-wrap gap-2">
<div className="flex items-center gap-3">
<span className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border",
@@ -1624,8 +1529,7 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
)}
</div>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* ── Verdict banner ──────────────────────────────────────────────────── */}
{verdict && simState === "done" && verdictColors && (
@@ -1653,7 +1557,8 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
{/* ── Trace list ──────────────────────────────────────────────────────── */}
{steps.length > 0 && (
<Card>
<Frame dense className="w-full">
<FramePanel className="p-0">
<div className="px-5 py-3 border-b flex items-center justify-between">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Трассировка · цепочка: <span className="text-foreground normal-case font-mono">{packet.chain}</span>
@@ -1693,7 +1598,8 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
)
})}
</div>
</Card>
</FramePanel>
</Frame>
)}
{/* ── Empty state ─────────────────────────────────────────────────────── */}
@@ -1721,104 +1627,6 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
)
}
// ─── Rules Table ──────────────────────────────────────────────────────────────
function RulesTable({ rules, onToggle, onEdit }: {
rules: FirewallRule[]
onToggle: (id: string) => void
onEdit: (r: FirewallRule) => void
}) {
if (rules.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<ShieldOffIcon className="size-10 mb-3 opacity-30" />
<p className="text-sm font-medium">Правила не найдены</p>
<p className="text-xs mt-1">Попробуйте изменить фильтр или добавьте новое правило</p>
</div>
)
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3 w-8">#</th>
<th className="text-left font-medium px-4 py-3">Цепочка</th>
<th className="text-left font-medium px-4 py-3">Действие</th>
<th className="text-left font-medium px-4 py-3">Источник</th>
<th className="text-left font-medium px-4 py-3">Назначение</th>
<th className="text-left font-medium px-4 py-3">Протокол</th>
<th className="text-left font-medium px-4 py-3">Порт</th>
<th className="text-left font-medium px-4 py-3">Интерфейс</th>
<th className="text-right font-medium px-4 py-3">Пакетов</th>
<th className="text-left font-medium px-4 py-3 w-12">Вкл</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{rules.map((r, i) => (
<tr key={r.id}
className={cn("hover:bg-muted/40 transition-colors", !r.enabled && "opacity-40")}>
<td className="px-5 py-2.5 font-mono text-xs text-muted-foreground">{i + 1}</td>
<td className="px-4 py-2.5"><ChainBadge chain={r.chain} /></td>
<td className="px-4 py-2.5"><ActionBadge action={r.action} /></td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground max-w-[140px] truncate">
{r.src || "any"}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground max-w-[140px] truncate">
{r.dst || "any"}
</td>
<td className="px-4 py-2.5 text-xs font-mono">{r.proto}</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.port || "—"}</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.iface || "—"}</td>
<td className="px-4 py-2.5 text-right">
<span className={cn(
"text-xs font-mono tabular-nums",
r.hits > 1_000_000 ? "text-emerald-600 dark:text-emerald-400 font-semibold"
: r.hits > 10_000 ? "text-foreground"
: "text-muted-foreground",
)}>
{fmtHits(r.hits)}
</span>
</td>
<td className="px-4 py-2.5">
<Toggle checked={r.enabled} onChange={() => onToggle(r.id)} />
</td>
<td className="px-3 py-2.5">
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onEdit(r)}>
<PencilIcon className="size-4" />Редактировать
</DropdownMenuItem>
<DropdownMenuItem>
<CopyIcon className="size-4" />Дублировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onToggle(r.id)}>
<PowerIcon className="size-4" />
{r.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" />Удалить правило
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function FirewallPage() {
const [rules, setRules] = useState<FirewallRule[]>(firewallRules)
@@ -1928,15 +1736,17 @@ export default function FirewallPage() {
{ label: "Блокирующих", value: dropRules, icon: <ShieldOffIcon className="size-4 text-red-500" /> },
{ label: "Срабатываний", value: fmtHits(totalHits), icon: <ListFilterIcon className="size-4 text-sky-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -1970,64 +1780,49 @@ export default function FirewallPage() {
) : chainGroup === "simulator" ? (
<SimulatorTab rules={rules} />
) : (
<Card>
{/* toolbar */}
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
{/* IP family selector */}
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{(["all", "ip", "ip6"] as IpFamily[]).map((f) => (
<button key={f} onClick={() => setIpFamily(f)}
className={cn(
"flex items-center gap-1.5 rounded px-3 py-1 text-xs transition-colors whitespace-nowrap",
ipFamily === f
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}>
{IP_FAMILY_LABELS[f]}
</button>
))}
</div>
{/* chain sub-tabs */}
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{[{ value: "all", label: "Все" }, ...chainsInGroup.map((c) => ({ value: c, label: c }))].map((t) => (
<button key={t.value} onClick={() => setChainFilter(t.value)}
className={cn(
"flex items-center gap-1.5 rounded px-3 py-1 text-xs transition-colors",
chainFilter === t.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}>
{t.label}
<span className="opacity-50">{chainCounts[t.value] ?? 0}</span>
</button>
))}
</div>
{/* search */}
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
<DataPageCard>
<DataPageToolbarFrame>
<SegmentedControl
value={ipFamily}
onChange={setIpFamily}
options={(["all", "ip", "ip6"] as IpFamily[]).map((f) => ({
value: f,
label: IP_FAMILY_LABELS[f],
}))}
/>
<SegmentedControl
value={chainFilter}
onChange={setChainFilter}
options={[
{ value: "all", label: "Все", count: chainCounts.all ?? 0 },
...chainsInGroup.map((c) => ({
value: c,
label: c,
count: chainCounts[c] ?? 0,
})),
]}
/>
<InputGroup className="min-w-[220px] max-w-sm">
<InputGroupAddon>
<SearchIcon className="size-3.5" />
</InputGroupAddon>
<InputGroupInput
placeholder="Поиск по адресу, действию…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</InputGroup>
<span className="text-sm text-muted-foreground ml-auto">
{filteredRules.length} правил
</span>
</DataPageToolbarFrame>
<span className="text-sm text-muted-foreground ml-auto">{filteredRules.length} правил</span>
</div>
<RulesTable rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
</Card>
<FirewallRulesDataGrid rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
</DataPageCard>
)}
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7.20+ · /ip firewall + /ipv6 firewall цепочки и новые матчеры
</p>
<OpsPanel title="RouterOS 7.20+ · /ip firewall + /ipv6 firewall — цепочки и новые матчеры" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 text-xs">
{[
{
@@ -2068,8 +1863,7 @@ export default function FirewallPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
+97 -296
View File
@@ -2,13 +2,20 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar, DataPageToolbarFrame } from "@/components/data-page-toolbar"
import { GreTunnelsDataGrid } from "@/components/data-grids/gre-tunnels-data-grid"
import { GrePoolsDataGrid } from "@/components/data-grids/gre-pools-data-grid"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -21,7 +28,7 @@ import {
} from "@/components/ui/dropdown-menu"
import { Flag } from "@/components/flag"
import {
PlusIcon, SearchIcon, RefreshCwIcon, MoreHorizontalIcon,
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
@@ -138,58 +145,6 @@ function IpsecBadge({ secured }: { secured: boolean }) {
)
}
function Field({ label, hint, required, children }: {
label: string; hint?: string; required?: boolean; children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}
>
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
</button>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{o.label}
</button>
))}
</div>
)
}
// ─── Live API (как на /filters) ─────────────────────────────────────────────
interface BackendServer {
@@ -477,15 +432,17 @@ export default function GrePage() {
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -501,196 +458,43 @@ export default function GrePage() {
{/* ── Tunnels ── */}
{pageTab === "tunnels" && (
<Card>
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{tunnelTabs.map((t) => (
<button key={t.value} onClick={() => setTabFilter(t.value)}
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${tabFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{t.label}
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
</button>
))}
</div>
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, IP…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
</div>
<DataPageCard>
<DataPageToolbar
segmented={{
value: tabFilter,
onChange: setTabFilter,
options: tunnelTabs.map((t) => ({
value: t.value,
label: t.label,
count: t.count,
})),
}}
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, IP…"
countLabel={`${filtered.length} туннелей`}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Интерфейс / Сервер</th>
<th className="text-left font-medium px-4 py-3">Эндпоинты</th>
<th className="text-left font-medium px-4 py-3">Внутренний IP</th>
<th className="text-left font-medium px-4 py-3">Пул</th>
<th className="text-left font-medium px-4 py-3">IPsec</th>
<th className="text-left font-medium px-4 py-3">Шифрование</th>
<th className="text-center font-medium px-4 py-3">MTU</th>
<th className="text-left font-medium px-4 py-3">Keepalive</th>
<th className="text-left font-medium px-4 py-3">Статус</th>
<th className="w-20 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((t, index) => {
const srv = serverById[t.serverId]
const pool = poolById[t.poolId]
return (
<tr key={`${t.id}:${t.serverId}:${t.name}:${index}`} className="hover:bg-muted/40 transition-colors">
<td className="px-5 py-3">
<p className="font-medium font-mono text-[13px]">{t.name}</p>
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
{srv && <Flag code={srv.country} />}
{srv?.name ?? t.serverId}
</p>
</td>
<td className="px-4 py-3">
<p className="font-mono text-xs">
{t.localAddress === "0.0.0.0" ? <span className="text-muted-foreground">авто</span> : t.localAddress}
</p>
<p className="font-mono text-xs text-muted-foreground"> {t.remoteAddress}</p>
</td>
<td className="px-4 py-3">
<p className="font-mono text-xs">{t.localInnerIp}</p>
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
</td>
<td className="px-4 py-3">
<span className="text-xs text-muted-foreground font-mono">{pool?.name ?? "—"}</span>
</td>
<td className="px-4 py-3"><IpsecBadge secured={!!t.ipsec} /></td>
<td className="px-4 py-3">
{t.ipsec ? (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-mono">{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}</span>
<span className="text-xs text-muted-foreground font-mono">
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}{t.ipsec.pfs && " · PFS"}
</span>
</div>
) : <span className="text-xs text-muted-foreground"></span>}
</td>
<td className="px-4 py-3 text-center font-mono text-xs">{t.mtu}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
</td>
<td className="px-4 py-3"><TunnelStatus status={t.status} /></td>
{/* actions */}
<td className="px-3 py-3">
<div className="flex items-center gap-1 justify-end">
{/* Code preview button */}
<Button
variant="ghost" size="icon" className="size-7"
title="Предпросмотр кода RouterOS"
onClick={() => setCodePreviewTunnel(t)}
>
<CodeXmlIcon className="size-3.5" />
</Button>
{/* Actions dropdown */}
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setCodePreviewTunnel(t)}>
<CodeXmlIcon className="size-4" /> Просмотр кода
</DropdownMenuItem>
<DropdownMenuItem>
<PencilIcon className="size-4" /> Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{t.enabled ? "Выключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" /> Удалить туннель
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
<GreTunnelsDataGrid
tunnels={filtered}
servers={displayServers}
pools={displayPools}
onCodePreview={setCodePreviewTunnel}
/>
</DataPageCard>
)}
{/* ── IP Pools ── */}
{pageTab === "pools" && (
<Card>
<div className="flex items-center justify-between px-5 py-3 border-b">
<DataPageCard>
<DataPageToolbarFrame className="justify-between">
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
<PlusIcon className="size-4" />Добавить пул
<PlusIcon className="size-4" />
Добавить пул
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Имя пула</th>
<th className="text-left font-medium px-4 py-3">Диапазон CIDR</th>
<th className="text-right font-medium px-4 py-3">Назначено /30</th>
<th className="text-right font-medium px-4 py-3">Доступно /30</th>
<th className="text-left font-medium px-4 py-3">Использование</th>
<th className="text-left font-medium px-4 py-3">Назначение</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{displayPools.map((pool) => {
const pct = pool.total > 0 ? Math.round((pool.allocated / pool.total) * 100) : 0
return (
<tr key={pool.id} className="hover:bg-muted/40 transition-colors">
<td className="px-5 py-3 font-mono text-[13px] font-medium">{pool.name}</td>
<td className="px-4 py-3 font-mono text-xs">{pool.cidr}</td>
<td className="px-4 py-3 text-right tabular-nums">{pool.allocated}</td>
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{pool.total - pool.allocated}</td>
<td className="px-4 py-3 min-w-[140px]">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${pct > 80 ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
</div>
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{pool.comment}</td>
<td className="px-3 py-3">
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem><PencilIcon className="size-4" /> Редактировать</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" /> Удалить пул</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</DataPageToolbarFrame>
<GrePoolsDataGrid pools={displayPools} />
<div className="border-t px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
@@ -715,13 +519,11 @@ export default function GrePage() {
})}
</div>
</div>
</Card>
</DataPageCard>
)}
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7.20+ параметры GRE-интерфейса</p>
<OpsPanel title="RouterOS 7.20+ — параметры GRE-интерфейса" contentClassName="px-5 py-4">
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-xs font-mono">
{[
["/interface gre add", ""],
@@ -744,8 +546,7 @@ export default function GrePage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
@@ -838,51 +639,51 @@ export default function GrePage() {
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
<FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
</Field>
<Field label="Сервер (MikroTik)" required>
</FormField>
<FormField label="Сервер (MikroTik)" required>
<select value={tForm.serverId} onChange={(e) => setT("serverId", 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">
<option value="" disabled>Выбрать сервер</option>
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
</select>
</Field>
<Field label="Комментарий">
</FormField>
<FormField label="Комментарий">
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
</Field>
</FormField>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Включён</span>
<Toggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
<FormToggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
</div>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Эндпоинты</SectionTitle>
<Field label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
<FormField label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
</Field>
<Field label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
</FormField>
<FormField label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
<Input className="font-mono" placeholder="203.0.113.1" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
</Field>
</FormField>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Внутренний IP</SectionTitle>
<Field label="IP-пул" required hint="Из какого пула выделяется /30-блок">
<FormField label="IP-пул" required hint="Из какого пула выделяется /30-блок">
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать пул</option>
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) свободно {p.total - p.allocated} блоков</option>)}
</select>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Локальный IP" required hint="/ip address на этом конце">
<FormField label="Локальный IP" required hint="/ip address на этом конце">
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
</Field>
<Field label="Удалённый IP" required hint="/ip address на другом конце">
</FormField>
<FormField label="Удалённый IP" required hint="/ip address на другом конце">
<Input className="font-mono" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
</Field>
</FormField>
</div>
</div>
@@ -893,12 +694,12 @@ export default function GrePage() {
<p className="text-sm font-medium">Включить IPsec</p>
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
</div>
<Toggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
<FormToggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
</div>
{tForm.ipsecEnabled && (
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
<Field label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
<FormField label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
<div className="relative">
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
@@ -907,38 +708,38 @@ export default function GrePage() {
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</button>
</div>
</Field>
<Field label="IKE-версия">
</FormField>
<FormField label="IKE-версия">
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Шифрование">
<FormField label="Шифрование">
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
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">
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
<Field label="Хеш-алгоритм">
</FormField>
<FormField label="Хеш-алгоритм">
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
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">
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
</FormField>
</div>
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
<FormField label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
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">
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Срок жизни SA" hint="Формат: 1d 00:00:00">
<FormField label="Срок жизни SA" hint="Формат: 1d 00:00:00">
<Input className="font-mono" value={tForm.ipsecLifetime} onChange={(e) => setT("ipsecLifetime", e.target.value)} />
</Field>
</FormField>
<div className="flex items-center justify-between pt-6">
<span className="text-sm font-medium">PFS</span>
<Toggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
<FormToggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
</div>
</div>
</div>
@@ -955,23 +756,23 @@ export default function GrePage() {
{tForm.showAdvanced && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-3">
<Field label="MTU" hint="По умолч. 1476">
<FormField label="MTU" hint="По умолч. 1476">
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
</Field>
<Field label="Keepalive, с" hint="0 = откл.">
</FormField>
<FormField label="Keepalive, с" hint="0 = откл.">
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
</Field>
<Field label="Попытки">
</FormField>
<FormField label="Попытки">
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
</Field>
</FormField>
</div>
<Field label="DSCP">
<FormField label="DSCP">
<select value={tForm.dscp} onChange={(e) => setT("dscp", 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">
<option value="inherit">inherit</option>
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
</select>
</Field>
</FormField>
{[
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" },
@@ -981,7 +782,7 @@ export default function GrePage() {
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
<Toggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
<FormToggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
</div>
))}
</div>
@@ -1006,18 +807,18 @@ export default function GrePage() {
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Параметры пула</SectionTitle>
<Field label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
<FormField label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
</Field>
<Field label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
</FormField>
<FormField label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
</Field>
<Field label="Назначение / Комментарий">
</FormField>
<FormField label="Назначение / Комментарий">
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
</Field>
</FormField>
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
+47 -55
View File
@@ -1,20 +1,25 @@
"use client"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { IpRangesDataGrid } from "@/components/data-grids/ip-ranges-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog"
import { ipRanges as mockIpRanges } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
export default function IpRangesPage() {
const { mode } = useDataSource()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("")
/** При включённом EvoBGP в live локальные моки не показываем — только каталог API (или пусто при загрузке/ошибке). */
const useEvoCatalog = mode === "live" && enabled
const rows = useMemo(() => {
@@ -23,13 +28,27 @@ export default function IpRangesPage() {
return snapshot?.ipRanges ?? []
}, [useEvoCatalog, loading, snapshot])
const filtered = useMemo(() => {
if (!search) return rows
const q = search.toLowerCase()
return rows.filter(
(r) =>
r.cidr.toLowerCase().includes(q) ||
r.asn.toLowerCase().includes(q) ||
r.country.toLowerCase().includes(q) ||
r.filter.toLowerCase().includes(q),
)
}, [rows, search])
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
</>
@@ -56,58 +75,31 @@ export default function IpRangesPage() {
</p>
)}
</div>
<DataTable
data={rows}
searchPlaceholder="Поиск по CIDR, ASN…"
searchKeys={["cidr", "asn", "country", "filter"]}
columns={[
{
key: "cidr",
label: "CIDR",
render: (d) => <span className="font-mono font-medium">{d.cidr}</span>,
},
{
key: "asn",
label: "ASN",
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.asn}</span>,
},
{
key: "country",
label: "Страна",
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
},
{
key: "purpose",
label: "Назначение",
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>,
},
{
key: "filter",
label: "Фильтр",
render: (d) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
</span>
),
},
{
key: "updated",
label: "Обновлён",
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
},
{
key: "enabled",
label: "Статус",
render: (d) => (
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
{d.enabled ? "Активен" : "Отключён"}
</span>
),
},
]}
/>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по CIDR, ASN…"
countLabel={`${filtered.length} диапазонов`}
/>
<IpRangesDataGrid
ipRanges={filtered}
isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog}
/>
</DataPageCard>
</div>
</div>
<FileImportDialog
open={importOpen}
onOpenChange={setImportOpen}
title="Импорт IP-диапазонов"
description="Загрузите CSV или JSON со списком CIDR-блоков"
accept=".csv,.json,text/csv,application/json"
onImport={async (files) => {
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
}}
/>
</div>
)
}
+25 -11
View File
@@ -1,21 +1,35 @@
import type { CSSProperties, ReactNode } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { AuthGuard } from "@/components/auth-guard"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { CommandPalette } from "@/components/command-palette"
import { ReleaseNotesModal } from "@/components/release-notes-modal"
import { DataSourceProvider } from "@/lib/data-source"
import { EvoBGPProvider } from "@/lib/evobgp-context"
export default function MainLayout({ children }: { children: React.ReactNode }) {
const SKIP_TO_CONTENT_CLASS =
"bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2"
export default function MainLayout({ children }: { children: ReactNode }) {
return (
<DataSourceProvider>
<EvoBGPProvider>
<SidebarProvider>
<AppSidebar />
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
<CommandPalette />
<ReleaseNotesModal />
</SidebarProvider>
</EvoBGPProvider>
</DataSourceProvider>
<AuthGuard>
<DataSourceProvider>
<EvoBGPProvider>
<SidebarProvider
style={{ "--sidebar-width": "240px" } as CSSProperties}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content" className="h-svh overflow-hidden">
{children}
</SidebarInset>
<CommandPalette />
<ReleaseNotesModal />
</SidebarProvider>
</EvoBGPProvider>
</DataSourceProvider>
</AuthGuard>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function MainLoading() {
return (
<div className="flex flex-col gap-5 p-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
<Skeleton className="h-10 w-full max-w-md rounded-lg" />
<Skeleton className="h-96 w-full rounded-xl" />
</div>
)
}
+41 -172
View File
@@ -2,7 +2,11 @@
import { useMemo, useState, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { DataPageCard } from "@/components/data-page-card"
import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid"
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { toast } from "sonner"
@@ -375,13 +379,6 @@ function stateClass(state: OspfNeighbor["state"] | BfdSession["state"]) {
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
}
function routeTypeClass(type: OspfRoute["type"]) {
if (type === "O") return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
if (type === "O IA") return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25"
if (type === "O E1") return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/25"
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/25"
}
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
return (
<span className={cn(
@@ -812,7 +809,8 @@ function InterfacesTab({
{grouped.map(router => {
const totalIfaces = router.areas.reduce((s, a) => s + a.items.length, 0)
return (
<Card key={router.routerKey} className="overflow-hidden gap-0 py-0">
<Frame key={router.routerKey} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
<div className="flex items-center gap-3 px-4 py-3 border-b">
<NetworkIcon className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
@@ -876,7 +874,8 @@ function InterfacesTab({
</div>
</div>
))}
</Card>
</FramePanel>
</Frame>
)
})}
</div>
@@ -926,12 +925,12 @@ function NeighborsTab({
{ label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" },
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums", s.color)}>{s.value}</p>
</CardContent>
</Card>
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
@@ -978,60 +977,14 @@ function NeighborsTab({
</div>
)}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
{["Роутер", "Интерфейс", "Сосед (Router ID)", "Область", "Состояние", "Cost", "Uptime", "Prio"].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{neighbors.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-sm text-muted-foreground">
Нет OSPF-соседей
</td>
</tr>
) : neighbors.map(n => {
const isHighlighted = selectedId === n.localRouter || selectedId === n.remoteRouter
return (
<tr key={n.id}
onMouseEnter={() => setHighlightId(n.localRouter)}
onMouseLeave={() => setHighlightId(null)}
onClick={() => setSelectedId(prev => prev === n.localRouter ? null : n.localRouter)}
className={cn(
"transition-colors cursor-pointer",
isHighlighted ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30",
)}>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{n.localLabel}</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground whitespace-nowrap">{n.localIface}</td>
<td className="px-3 py-2.5">
<div className="flex flex-col">
<span className="font-mono">{n.remoteLabel !== n.remoteRouterId ? n.remoteLabel : n.remoteRouterId}</span>
{n.remoteLabel !== n.remoteRouterId && (
<span className="text-[10px] font-mono text-muted-foreground">{n.remoteRouterId}</span>
)}
</div>
</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground">{n.area}</td>
<td className="px-3 py-2.5">
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", stateClass(n.state))}>
{n.state}
</span>
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{n.cost}</td>
<td className="px-3 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums">{n.uptime}</td>
<td className="px-3 py-2.5 text-center font-mono">{n.priority}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<OspfNeighborsDataGrid
neighbors={neighbors}
selectedRouterId={selectedId}
onSelect={setSelectedId}
onHighlight={setHighlightId}
/>
</DataPageCard>
</div>
)
}
@@ -1052,36 +1005,9 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) {
</span>
))}
</div>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
{["Назначение", "Тип", "Cost", "Следующий хоп", "Интерфейс", "Роутер", "Область"].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{routes.map(r => (
<tr key={r.id} className="hover:bg-muted/30 transition-colors">
<td className="px-3 py-2.5 font-mono">{r.destination}</td>
<td className="px-3 py-2.5">
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", routeTypeClass(r.type))}>
{r.type}
</span>
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{r.cost}</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.nextHop}</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.via}</td>
<td className="px-3 py-2.5 font-mono">{r.serverLabel}</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.area}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<OspfRoutesDataGrid routes={routes} />
</DataPageCard>
<div className="flex items-center gap-5 flex-wrap px-1">
<span className="text-xs text-muted-foreground">Типы:</span>
{([
@@ -1123,12 +1049,12 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" },
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums", s.color)}>{s.value}</p>
</CardContent>
</Card>
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
@@ -1139,66 +1065,9 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
)}
{sessions.length > 0 && (
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
{[
"Роутер", "Интерфейс", "Локальный", "Удалённый",
"Состояние", "Uptime", "Tx / Rx", "Hold", "Mult",
"Пакеты Rx", "Пакеты Tx", "Переходы",
].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{sessions.map(b => (
<tr key={b.id} className="hover:bg-muted/30 transition-colors">
<td className="px-3 py-2.5 font-mono whitespace-nowrap">
<div className="flex flex-col gap-0.5">
<span>{b.serverLabel}</span>
{b.multihop && (
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">multihop</Chip>
)}
</div>
</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground whitespace-nowrap">{b.iface || "—"}</td>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{b.localAddr}</td>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{b.remoteAddr}</td>
<td className="px-3 py-2.5">
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", stateClass(b.state))}>
{b.state}
</span>
</td>
<td className="px-3 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums">
{b.uptime ?? "—"}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground whitespace-nowrap">
{fmtMs(b.interval)} / {fmtMs(b.rxInterval)}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground whitespace-nowrap">
{fmtMs(b.holdTime)}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{b.multiplier}</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right text-muted-foreground">
{b.packetsRx.toLocaleString()}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right text-muted-foreground">
{b.packetsTx.toLocaleString()}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-center">
<span className={cn(b.stateChanges > 3 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground")}>
{b.stateChanges}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<OspfBfdDataGrid sessions={sessions} />
</DataPageCard>
)}
{sessions.length > 0 && (
@@ -1483,12 +1352,12 @@ export default function OspfPage() {
{ label: "Интерфейсов", value: totalInterfaces },
{ label: "Зон (Area)", value: totalAreas },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums">{s.value}</p>
</CardContent>
</Card>
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
+42 -144
View File
@@ -2,7 +2,19 @@
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { FormToggle } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import {
ProbesScheduleDataGrid,
type SchedRule,
type SchedType,
} from "@/components/data-grids/probes-schedule-data-grid"
import {
ProbesSpeedProbesDataGrid,
type SpeedProbeApiRow,
} from "@/components/data-grids/probes-speed-probes-data-grid"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
@@ -40,13 +52,8 @@ interface DiagTest {
source?: "demo" | "live"
}
type SchedType = "ping" | "bandwidth" | "both"
// SchedRule imported from probes-schedule-data-grid
interface SchedRule {
id: string; srcId: string; tunnelId: string; type: SchedType
intervalMin: number; enabled: boolean
lastRun: string | null; nextRunMin: number | null
}
// ─── tool metadata ────────────────────────────────────────────────────────────
@@ -97,22 +104,7 @@ interface BackendServerRow {
latency?: number | null
}
interface SpeedProbeApiRow {
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
protocol: string
direction: string
durationSec: string
enabled: boolean
lastRunAt: string | null
lastTxAvgMbps: number | null
lastRxAvgMbps: number | null
lastStatus: string | null
lastError: string | null
}
// SpeedProbeApiRow imported from probes-speed-probes-data-grid
// ─── helpers ──────────────────────────────────────────────────────────────────
@@ -353,17 +345,6 @@ function NativeSelect({ value, onChange, children, className }: {
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-primary" : "bg-muted-foreground/30")}>
<span className={cn("inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
checked ? "translate-x-4" : "translate-x-0.5")} />
</button>
)
}
function OptionLabel({ children }: { children: React.ReactNode }) {
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
}
@@ -476,53 +457,9 @@ function ScheduleSpeedProbesLive({
return (
<div className="flex flex-col gap-3">
<Card className="overflow-hidden">
{rows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
<ClockIcon className="size-7 opacity-20" />
<p className="text-sm">Нет записей speed-test в мониторинге</p>
<p className="text-xs text-muted-foreground/70 max-w-md text-center">
Настраиваются через API <code className="text-[11px]">PUT /api/uptime/speed-probes</code> или связанный UI.
</p>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
<span>Источник</span>
<span>Назначение</span>
<span>Протокол</span>
<span>Сек</span>
<span>Вкл</span>
<span>Последний запуск</span>
</div>
<div className="divide-y divide-border/60">
{rows.map(r => (
<div key={r.id} className={cn(
"grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] gap-2 items-center px-4 py-2.5 text-xs",
!r.enabled && "opacity-50",
)}>
<span className="truncate font-mono">{name(r.srcServerId)}{r.srcInterface ? ` · ${r.srcInterface}` : ""}</span>
<span className="truncate font-mono">{name(r.dstServerId)}{r.dstInterface ? ` · ${r.dstInterface}` : ""}</span>
<span>{r.protocol.toUpperCase()}</span>
<span className="font-mono">{r.durationSec}s</span>
<span>{r.enabled ? "да" : "нет"}</span>
<span className="text-muted-foreground truncate">
{r.lastRunAt ?? "—"}
{r.lastStatus === "done" && r.lastTxAvgMbps != null && (
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
TX{r.lastTxAvgMbps.toFixed(1)} RX{(r.lastRxAvgMbps ?? 0).toFixed(1)} Mb/s
</span>
)}
{r.lastStatus === "error" && r.lastError && (
<span className="text-destructive ml-1 truncate">{r.lastError}</span>
)}
</span>
</div>
))}
</div>
</>
)}
</Card>
<DataPageCard>
<ProbesSpeedProbesDataGrid rows={rows} serverName={name} />
</DataPageCard>
<p className="text-[11px] text-muted-foreground">
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование через настройки мониторинга / API.
</p>
@@ -563,65 +500,25 @@ function ScheduleTab({
}
}, [addSrc, addTun, tunnelsForServer])
const typeLabel: Record<SchedType, string> = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" }
const tunnelName = (srcId: string, tunnelId: string) =>
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
return (
<div className="flex flex-col gap-3">
<Card className="overflow-hidden">
{rules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
<ClockIcon className="size-7 opacity-20" />
<p className="text-sm">Нет правил расписания</p>
</div>
) : (
<>
<div className="grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
<span />
<span>Туннель</span>
<span>Сервер</span>
<span>Тип</span>
<span>Интервал</span>
<span>Последний / следующий</span>
<span />
</div>
<div className="divide-y divide-border/60">
{rules.map(rule => {
const src = serverOptions.find(s => s.id === rule.srcId)
const tun = tunnelsForServer(rule.srcId).find(t => t.id === rule.tunnelId)
return (
<div key={rule.id} className={cn(
"grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2.5 hover:bg-muted/20 transition-colors",
!rule.enabled && "opacity-50",
)}>
<Toggle checked={rule.enabled}
onChange={v => setRules(p => p.map(r => r.id === rule.id ? { ...r, enabled: v } : r))} />
<code className="font-mono text-xs truncate">{tun?.name ?? rule.tunnelId}</code>
<span className="text-xs text-muted-foreground truncate">{src?.name ?? rule.srcId}</span>
<span className={cn("text-[10px] px-1.5 py-0.5 rounded border font-medium w-fit",
rule.type === "ping" ? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20"
: rule.type === "bandwidth" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
)}>{typeLabel[rule.type]}</span>
<span className="text-xs text-muted-foreground">каждые {rule.intervalMin} мин</span>
<div className="text-xs text-muted-foreground flex items-center gap-2 min-w-0">
{rule.lastRun && <span className="truncate">{rule.lastRun}</span>}
{rule.nextRunMin != null && rule.enabled && (
<span className="text-sky-600 dark:text-sky-400 shrink-0">· через {rule.nextRunMin} мин</span>
)}
</div>
<button onClick={() => setRules(p => p.filter(r => r.id !== rule.id))}
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 className="size-3.5" />
</button>
</div>
)
})}
</div>
</>
)}
</Card>
<DataPageCard>
<ProbesScheduleDataGrid
rules={rules}
serverOptions={serverOptions}
tunnelName={tunnelName}
onToggleEnabled={(id, enabled) =>
setRules((p) => p.map((r) => (r.id === id ? { ...r, enabled } : r)))
}
onDelete={(id) => setRules((p) => p.filter((r) => r.id !== id))}
/>
</DataPageCard>
{showAdd ? (
<Card className="overflow-hidden">
<Frame dense className="w-full 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">
<PlusIcon className="size-4 text-muted-foreground" />Новое правило
</div>
@@ -657,7 +554,8 @@ function ScheduleTab({
</Button>
<Button variant="outline" size="sm" onClick={() => setShowAdd(false)}>Отмена</Button>
</div>
</Card>
</FramePanel>
</Frame>
) : (
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
<PlusIcon className="size-4" />Добавить правило
@@ -1023,8 +921,7 @@ export default function ProbesPage() {
)}
{/* ── tool selector + config ── */}
<Card>
<CardContent className="pt-4 pb-4 px-4 flex flex-col gap-4">
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4">
{/* tool chips */}
<div className="flex gap-2 flex-wrap">
@@ -1115,7 +1012,7 @@ export default function ProbesPage() {
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
</p>
</div>
<Toggle checked={traceUseDns} onChange={setTraceUseDns} />
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} />
</div>
</>
)}
@@ -1167,8 +1064,7 @@ export default function ProbesPage() {
<CopyIcon className="size-3" />
</button>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* ── tabs ── */}
<div>
@@ -1207,7 +1103,8 @@ export default function ProbesPage() {
{tests.map(test => {
const { Icon, color, label } = TOOL_META[test.tool]
return (
<Card key={test.id} className="overflow-hidden">
<Frame key={test.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* header */}
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20">
<div className="flex items-center gap-2 min-w-0">
@@ -1254,7 +1151,8 @@ export default function ProbesPage() {
<div className="p-3">
<TerminalOutput test={test} />
</div>
</Card>
</FramePanel>
</Frame>
)
})}
</div>
+41 -206
View File
@@ -2,7 +2,14 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { Card } from "@/components/ui/card"
import {
RecursiveRoutesDataGrid,
type RecursiveRouteGroup,
inferCountry,
} from "@/components/data-grids/recursive-routes-data-grid"
import { FormField, SectionTitle } from "@/components/form-kit"
import { DataPageCard } from "@/components/data-page-card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
@@ -11,7 +18,7 @@ import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source"
import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, ChevronDownIcon, ChevronRightIcon, AlertCircleIcon, CheckIcon } from "lucide-react"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
import { requestJson } from "@/shared/api/http-client"
interface BackendServer {
@@ -34,25 +41,6 @@ interface GatewayOption {
status: "up" | "down"
}
const INFER_COUNTRIES = [
{ code: "RU", keys: ["MSK", "SPB", "RTK", "MTS", "VPSVILLE", "IHOR"] },
{ code: "SE", keys: ["SWE", "STO"] },
{ code: "FI", keys: ["HEL", "FIN"] },
{ code: "DE", keys: ["FRA", "GER", "DE"] },
{ code: "NL", keys: ["AMS", "NLD", "NL"] },
{ code: "SG", keys: ["SGP", "SIN", "SG"] },
{ code: "TR", keys: ["TUR", "TR"] },
{ code: "US", keys: ["USA", "US", "NYC", "LAX"] },
]
function inferCountry(name: string): string | null {
const upper = name.toUpperCase()
for (const c of INFER_COUNTRIES) {
if (c.keys.some(k => upper.includes(k))) return c.code
}
return null
}
const COUNTRY_OPTIONS = [
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
@@ -82,13 +70,7 @@ interface RecursiveRouteRow {
country: string
}
interface RouteGroup {
key: string
dstAddress: string
routingTable: string
comment: string
endpoints: RecursiveRouteRow[]
}
interface RouteGroup extends RecursiveRouteGroup {}
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
@@ -148,131 +130,6 @@ const emptyForm = (): RouteForm => ({
endpoints: [newEndpoint()],
})
function Field({ label, hint, required, children }: {
label: string
hint?: string
required?: boolean
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function RouteGroupRows({
group, expanded, onToggle, onEdit, onDelete,
}: {
group: RouteGroup
expanded: boolean
onToggle: () => void
onEdit: () => void
onDelete: () => void
}) {
const [confirmDel, setConfirmDel] = useState(false)
const bestDistance = Math.min(...group.endpoints.map(ep => ep.distance))
const sorted = [...group.endpoints].sort((a, b) => a.distance - b.distance)
return (
<>
<tr
className={cn(
"hover:bg-muted/40 transition-colors cursor-pointer group",
expanded && "bg-muted/30",
)}
onClick={onToggle}
>
<td className="px-5 py-3">
<div className="flex items-start gap-2">
{expanded
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
<div className="min-w-0">
<p className="font-medium truncate">{group.dstAddress}</p>
<p className="text-xs font-mono text-muted-foreground">{group.comment || "—"}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-0.5">
{sorted.map((ep, idx) => {
const code = ep.country || inferCountry(ep.gateway)
return (
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
<span className={cn(
"size-1.5 rounded-full shrink-0",
idx === 0 ? "bg-emerald-500" : "bg-sky-500",
)} />
{code ? <Flag code={code} size={14} className="shrink-0" /> : <span className="text-[10px] text-muted-foreground w-3.5 text-center shrink-0">?</span>}
<span className="font-semibold text-sky-600 dark:text-sky-400 truncate min-w-0">{ep.gateway}</span>
</div>
)
})}
</div>
</td>
<td className="px-4 py-3 text-xs tabular-nums">{group.endpoints.length}</td>
<td className="px-4 py-3 font-mono text-xs">d{bestDistance}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{group.routingTable || "main"}</td>
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground" onClick={onEdit}><PencilIcon className="size-3.5" /></Button>
<Button size="sm" variant="ghost" className={cn("size-7 p-0 transition-colors", confirmDel ? "text-destructive bg-destructive/10 hover:bg-destructive/20" : "text-muted-foreground hover:text-destructive")} onClick={() => { if (!confirmDel) setConfirmDel(true); else onDelete() }} onBlur={() => setConfirmDel(false)}>
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
</Button>
</div>
</td>
</tr>
{expanded && (
<tr className="bg-muted/20">
<td colSpan={6} className="px-8 py-5 border-b border-border/50">
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
<span className="text-muted-foreground">Route: <span className="font-mono text-foreground">{group.dstAddress}</span></span>
<span className="text-muted-foreground">Table: <span className="font-mono text-foreground">{group.routingTable || "main"}</span></span>
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
{sorted.map((ep, idx) => (
<div key={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{(ep.country || inferCountry(ep.gateway)) && (
<Flag code={ep.country || inferCountry(ep.gateway) || ""} size={16} />
)}
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">Endpoint {idx + 1}</span>
</div>
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
</div>
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
<div className="mt-1.5 text-[11px] text-muted-foreground flex items-center gap-3">
<span>scope: {ep.scope ?? "—"}</span>
<span>t.scope: {ep.targetScope ?? "—"}</span>
<span>check: {ep.checkGateway || "—"}</span>
</div>
</div>
))}
</div>
</div>
</td>
</tr>
)}
</>
)
}
function EndpointCountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [query, setQuery] = useState("")
const q = query.trim().toUpperCase()
@@ -370,9 +227,9 @@ function RouteSheet({
<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">
<SectionTitle>Основные</SectionTitle>
<Field 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 className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
</Field>
</FormField>
</div>
<div className="flex flex-col gap-4">
@@ -396,26 +253,26 @@ function RouteSheet({
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
<Field label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
<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)} />
</Field>
</FormField>
<div className="grid grid-cols-2 gap-2">
<Field label="Distance (приоритет)">
<FormField label="Distance (приоритет)">
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
</Field>
<Field label="Check Gateway">
</FormField>
<FormField label="Check Gateway">
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
</Field>
</FormField>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Scope">
<FormField label="Scope">
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
</Field>
<Field label="T.Scope">
</FormField>
<FormField label="T.Scope">
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
</Field>
</FormField>
</div>
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
@@ -467,10 +324,10 @@ function RouteSheet({
<div className="flex flex-col gap-4">
<SectionTitle>Параметры</SectionTitle>
<Field label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></Field>
<Field label="Комментарий">
<FormField label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
<FormField label="Комментарий">
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</Field>
</FormField>
</div>
{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>}
</div>
@@ -794,13 +651,15 @@ export default function RecursiveRoutesPage() {
<div className="flex-1 overflow-y-auto p-6">
{!isLive ? (
<Card className="p-6 text-sm text-muted-foreground">
Раздел работает в режиме &quot;Живые данные&quot;. Переключи источник данных в настройках.
</Card>
<Frame dense className="w-full">
<FramePanel className="p-6 text-sm text-muted-foreground">
Раздел работает в режиме &quot;Живые данные&quot;. Переключи источник данных в настройках.
</FramePanel>
</Frame>
) : (
<Card className="overflow-hidden py-0 gap-0">
<DataPageCard>
{currentServer && (
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} />
<Flag code={currentServer.country} size={16} />
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
@@ -819,43 +678,19 @@ export default function RecursiveRoutesPage() {
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Route / Comment</th>
<th className="text-left font-medium px-4 py-3">Gateways</th>
<th className="text-left font-medium px-4 py-3">EP</th>
<th className="text-left font-medium px-4 py-3">Priority</th>
<th className="text-left font-medium px-4 py-3">Table</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{groupedRoutes.map((g) => (
<RouteGroupRows
key={g.key}
group={g}
expanded={expandedGroupKey === g.key}
onToggle={() => setExpandedGroupKey(prev => prev === g.key ? null : g.key)}
onEdit={() => openEdit(g)}
onDelete={() => setRows(prev => prev.filter(r => groupKeyOf(r) !== g.key))}
/>
))}
</tbody>
</table>
{groupedRoutes.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
Нет маршрутов в БД для этого сервера. Нажми &quot;Router =&gt; DB&quot; для загрузки.
</div>
)}
</div>
<RecursiveRoutesDataGrid
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
expandedKey={expandedGroupKey}
onExpandedChange={setExpandedGroupKey}
onEdit={openEdit}
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))}
/>
<button onClick={openCreate}
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" />
Добавить маршрут
</button>
</Card>
</DataPageCard>
)}
</div>
+18 -22
View File
@@ -1,6 +1,6 @@
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { OpsPanel } from "@/components/ops-panel"
import { buttonVariants } from "@/components/ui/button"
import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version"
import {
@@ -52,12 +52,11 @@ export default function ReleasesPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-[960px] mx-auto space-y-6">
<Card>
<CardHeader>
<CardTitle>Текущая версия</CardTitle>
<CardDescription>Сборка MikrotikManager, опубликованная через CI/CD.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<OpsPanel
title="Текущая версия"
description="Сборка MikrotikManager, опубликованная через CI/CD."
contentClassName="flex flex-col gap-3 px-5 pb-5"
>
<div className="flex flex-wrap items-center gap-3">
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span>
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt, isProdBuild)}</span>
@@ -65,23 +64,21 @@ export default function ReleasesPage() {
<p className="text-sm text-muted-foreground">
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
</p>
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader>
<CardTitle>Сводка коммитов</CardTitle>
<CardDescription>
{commits.length > 0
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."}
</CardDescription>
</CardHeader>
<CardContent>
<OpsPanel
title="Сводка коммитов"
description={
commits.length > 0
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."
}
contentClassName="px-5 pb-5"
>
{commits.length === 0 ? (
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p>
) : (
<ul className="space-y-3">
<ul className="flex flex-col gap-3">
{commits.map((commit) => (
<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">
@@ -94,8 +91,7 @@ export default function ReleasesPage() {
))}
</ul>
)}
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
</div>
+61 -439
View File
@@ -3,7 +3,15 @@
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { DataPageCard } from "@/components/data-page-card"
import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid"
import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-optimizer-full-routes-data-grid"
import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-optimizer-comm-recs-data-grid"
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
import { FormToggle } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Flag } from "@/components/flag"
@@ -35,7 +43,7 @@ import {
RefreshCwIcon, AlertCircleIcon, ArrowRightIcon,
SettingsIcon, ChevronDownIcon, ChevronUpIcon, PinIcon,
ZapIcon, PlayIcon, CheckCircleIcon, NetworkIcon, WifiIcon,
MonitorIcon, ServerIcon, GitMergeIcon, ShieldIcon, LayersIcon,
MonitorIcon, ServerIcon,
InfoIcon,
} from "lucide-react"
@@ -245,17 +253,6 @@ function LossChip({ loss }: { loss: number }) {
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
checked ? "bg-primary" : "bg-input")}>
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0")} />
</button>
)
}
function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
return (
<Input type="number" value={value} min={min} max={max}
@@ -276,327 +273,6 @@ function SettingRow({ label, unit, children }: { label: string; unit?: string; c
)
}
// ─── WAN Matrix table ─────────────────────────────────────────────────────────
// Rows = WANs, Columns = JHs, cells show ping / bw / score
function WanMatrix({ home, legs, jumpHosts, pw: _pw }: {
home: HomeRouter
legs: WanJhLeg[]
jumpHosts: JumpHost[]
pw: number
}) {
// find best leg overall
const bestScore = legs.length ? Math.max(...legs.map(l => l.score)) : 0
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
<th className="text-left font-medium px-4 py-2 w-[160px]">WAN-аплинк</th>
<th className="text-left font-medium px-3 py-2">ISP / IP</th>
<th className="text-right font-medium px-3 py-2">Макс. полоса</th>
{jumpHosts.map(jh => (
<th key={jh.id} className="text-center font-medium px-3 py-2 min-w-[130px]">
<div>{jh.label}</div>
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
<Flag code={jh.country} />
{jh.site} · {jh.ip}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{home.wans.map(wan => (
<tr key={wan.id} className="hover:bg-muted/30 transition-colors">
{/* WAN name */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<WifiIcon className="size-3.5 text-muted-foreground shrink-0" />
<div>
<p className="font-mono text-xs font-semibold">{wan.name}</p>
<p className="text-[10px] text-muted-foreground">{wan.iface}</p>
</div>
</div>
</td>
{/* ISP */}
<td className="px-3 py-3">
<p className="text-xs font-medium">{wan.isp}</p>
<p className="font-mono text-[10px] text-muted-foreground">{wan.ip}</p>
</td>
{/* Max bandwidth */}
<td className="px-3 py-3 text-right">
<p className="font-mono text-xs">{wan.maxDl}</p>
<p className="font-mono text-[10px] text-muted-foreground">{wan.maxUl} Мбит</p>
</td>
{/* Per-JH cells */}
{jumpHosts.map(jh => {
const leg = legs.find(l => l.wanId === wan.id && l.jhId === jh.id)
if (!leg) return <td key={jh.id} className="px-3 py-3 text-center text-muted-foreground text-xs"></td>
const isBest = leg.score === bestScore
return (
<td key={jh.id} className={cn(
"px-3 py-3 text-center",
isBest && "bg-emerald-500/5",
)}>
<div className={cn(
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
isBest
? "border border-emerald-500/20 bg-emerald-500/8"
: "border border-transparent",
)}>
{isBest && (
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 mb-0.5">
ЛУЧШИЙ
</span>
)}
<span className={cn("font-mono text-xs font-semibold",
leg.pingMs < 10 ? "text-emerald-600 dark:text-emerald-400"
: leg.pingMs < 25 ? "text-foreground"
: "text-amber-600 dark:text-amber-400"
)}>
{leg.pingMs} мс
</span>
<span className="text-[10px] text-muted-foreground font-mono">
{leg.dlMbps} {leg.ulMbps}
</span>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] font-mono text-foreground/70">
score {leg.score}
</span>
{leg.loss > 0 && <LossChip loss={leg.loss} />}
</div>
</div>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)
}
// ─── Full routes table ────────────────────────────────────────────────────────
function FullRoutesTable({ routes, bestId }: { routes: FullRoute[]; bestId?: string }) {
const [expanded, setExpanded] = useState(false)
const visible = expanded ? routes : routes.slice(0, 5)
return (
<div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
<th className="text-left font-medium px-4 py-2"># Маршрут</th>
<th className="text-left font-medium px-3 py-2">WAN JH</th>
<th className="text-left font-medium px-3 py-2">JH Exit</th>
<th className="text-center font-medium px-3 py-2">Ping (итого)</th>
<th className="text-center font-medium px-3 py-2">BW (мин)</th>
<th className="text-center font-medium px-3 py-2">Score</th>
<th className="text-center font-medium px-3 py-2">P(opt)</th>
<th className="text-center font-medium px-3 py-2">Conf.</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{visible.map((r, i) => {
const isBest = r.id === bestId || i === 0
const totalPing = r.hw.pingMs + r.je.pingMs
const minDl = Math.min(r.hw.dlMbps, r.je.dlMbps)
const minUl = Math.min(r.hw.ulMbps, r.je.ulMbps)
return (
<tr key={r.id} className={cn(
"hover:bg-muted/30 transition-colors",
isBest && "bg-emerald-500/5",
)}>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-muted-foreground w-4">{i + 1}</span>
{isBest && (
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-1.5 py-0.5 rounded">
Лучший
</span>
)}
</div>
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1.5 text-xs">
<span className="font-mono font-semibold text-sky-600 dark:text-sky-400">{r.wan.name}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<div>
<div className="font-medium">{r.jh.label}</div>
<div className="font-mono text-[10px] text-muted-foreground">{r.hw.pingMs} мс · {r.hw.dlMbps} {r.hw.ulMbps}</div>
</div>
</div>
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1.5 text-xs">
<div>
<div className="flex items-center gap-1 font-medium">
<Flag code={r.exit.country} />
{r.exit.label}
<span className="text-[10px] text-muted-foreground">({r.exit.site})</span>
</div>
<div className="font-mono text-[10px] text-muted-foreground">{r.je.pingMs} мс · {r.je.dlMbps} {r.je.ulMbps}</div>
</div>
</div>
</td>
<td className={cn("px-3 py-2.5 text-center font-mono text-xs",
totalPing < 40 ? "text-emerald-600 dark:text-emerald-400"
: totalPing < 80 ? "text-amber-600 dark:text-amber-400"
: "text-red-500"
)}>
{totalPing} мс
</td>
<td className="px-3 py-2.5 text-center font-mono text-xs text-muted-foreground">
<div>{minDl}</div>
<div>{minUl}</div>
</td>
<td className="px-3 py-2.5 text-center font-mono text-xs font-semibold">{r.score}</td>
<td className="px-3 py-2.5 text-center"><ProbChip prob={r.probabilityOptimal} best={isBest} /></td>
<td className="px-3 py-2.5 text-center"><ConfChip conf={r.confidence} /></td>
</tr>
)
})}
</tbody>
</table>
</div>
{routes.length > 5 && (
<button onClick={() => setExpanded(v => !v)}
className="w-full py-2 text-xs text-muted-foreground hover:text-foreground transition-colors border-t flex items-center justify-center gap-1">
{expanded
? <><ChevronUpIcon className="size-3" />Свернуть</>
: <><ChevronDownIcon className="size-3" />Показать все {routes.length} комбинаций</>}
</button>
)}
</div>
)
}
// ─── Community recs table ─────────────────────────────────────────────────────
function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply, threshold: _threshold }: {
recs: CommRec[]
homeId: string
pinned: Set<string>
applied: Set<string>
applying: Set<string>
onPin: (k: string) => void
onApply: (comm: string, homeId: string) => void
threshold: number
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
<th className="text-left font-medium px-4 py-2">Community</th>
<th className="text-left font-medium px-3 py-2">Текущий (WAN JH Exit)</th>
<th className="text-left font-medium px-3 py-2">Рекомендуемый</th>
<th className="text-center font-medium px-3 py-2">P(тек / рек)</th>
<th className="text-right font-medium px-3 py-2">Действие</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{recs.map((r, idx) => {
const pinKey = `${homeId}::${r.community}`
const isPinned = pinned.has(pinKey)
const isApplied = applied.has(pinKey)
const isApplying = applying.has(pinKey)
const canApply = r.shouldSwitch && !isPinned && !isApplied
return (
<tr key={`${homeId}::${r.community}::${idx}`} className={cn(
"hover:bg-muted/30 transition-colors",
r.shouldSwitch && !isPinned && !isApplied && "bg-amber-500/5",
isApplied && "bg-emerald-500/5",
)}>
{/* community */}
<td className="px-4 py-2.5">
<div className="font-mono text-xs font-medium">{r.community}</div>
<div className="text-[11px] text-muted-foreground">{r.communityName}</div>
</td>
{/* current route */}
<td className="px-3 py-2.5">
{r.current ? (
<div className="text-xs flex items-center gap-1 flex-wrap">
<span className="font-mono font-medium text-sky-600 dark:text-sky-400">{r.current.wan}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span>{r.current.jh}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">{r.current.exit}</span>
<span className="font-mono text-[10px] text-muted-foreground">({r.current.gateway})</span>
</div>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
{/* recommended */}
<td className="px-3 py-2.5">
{r.recommended ? (
<div className={cn("text-xs flex items-center gap-1 flex-wrap",
r.shouldSwitch && !isPinned && "text-amber-600 dark:text-amber-400")}>
<span className="font-mono font-medium">{r.recommended.wan}</span>
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
<span>{r.recommended.jh}</span>
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
<span>{r.recommended.exit}</span>
{r.shouldSwitch && !isPinned && (
<span className="ml-1 text-[10px] font-bold bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}%
</span>
)}
</div>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
{/* probability */}
<td className="px-3 py-2.5 text-center">
<div className="flex items-center justify-center gap-1">
<ProbChip prob={r.current?.prob ?? 0} />
<span className="text-muted-foreground text-[10px]">/</span>
<ProbChip prob={r.recommended?.prob ?? 0} best={r.shouldSwitch && !isPinned} />
</div>
</td>
{/* actions */}
<td className="px-3 py-2.5">
<div className="flex items-center justify-end gap-1.5">
{isPinned && <PinIcon className="size-3 text-sky-500 fill-sky-500" />}
<Button variant="outline" size="sm"
className={cn("h-7 text-xs", isPinned && "text-sky-600 dark:text-sky-400 border-sky-500/30")}
onClick={() => onPin(pinKey)}>
<PinIcon className={cn("size-3", isPinned && "fill-current")} />
{isPinned ? "Открепить" : "Закрепить"}
</Button>
{canApply && (
<Button size="sm" className="h-7 text-xs" disabled={isApplying}
onClick={() => onApply(r.community, homeId)}>
{isApplying
? <RefreshCwIcon className="size-3 animate-spin" />
: <PlayIcon className="size-3" />}
Применить
</Button>
)}
{isApplied && (
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<CheckCircleIcon className="size-3" />Применено
</span>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
// ─── Home Router card ─────────────────────────────────────────────────────────
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
@@ -616,7 +292,8 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
const switchCount = commRecs.filter(r => r.shouldSwitch && !pinned.has(`${home.id}::${r.community}`)).length
return (
<Card className="gap-0 py-0 overflow-hidden">
<Frame dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
<MonitorIcon className="size-4 text-muted-foreground shrink-0" />
@@ -679,21 +356,21 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
{/* Tab content */}
{tab === "wan-matrix" && (
<WanMatrix home={home} legs={wanJhLegs} jumpHosts={jumpHosts} pw={settings.pingWeight} />
<RouteOptimizerWanMatrixDataGrid home={home} legs={wanJhLegs} jumpHosts={jumpHosts} />
)}
{tab === "full-routes" && (
<FullRoutesTable routes={fullRoutes} bestId={bestRoute?.id} />
<RouteOptimizerFullRoutesDataGrid routes={fullRoutes} bestId={bestRoute?.id} />
)}
{tab === "bgp-community" && (
<CommRecsTable
<RouteOptimizerCommRecsDataGrid
recs={commRecs}
homeId={home.id}
pinned={pinned} applied={applied} applying={applying}
onPin={onPin} onApply={onApply}
threshold={settings.switchThreshold}
/>
)}
</Card>
</FramePanel>
</Frame>
)
}
@@ -1088,16 +765,18 @@ export default function RouteOptimizerPage() {
{/* Stats chips */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{statsChips.map((s) => (
<Card key={s.label}>
<CardContent className="px-4 py-3 flex items-start justify-between">
<div>
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className="text-xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">{s.sub}</p>
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-xl leading-none font-bold tabular-nums">{s.value}</p>
<p className="text-[10px] text-muted-foreground">{s.sub}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -1114,7 +793,8 @@ export default function RouteOptimizerPage() {
)}
{/* Settings */}
<Card className="gap-0 py-0">
<Frame dense className="w-full">
<FramePanel className="p-0">
<div
className={cn("flex items-center gap-3 px-4 py-3 cursor-pointer select-none", showSettings && "border-b")}
onClick={() => setShowSettings(v => !v)}
@@ -1128,7 +808,7 @@ export default function RouteOptimizerPage() {
</div>
{showSettings && (
<CardContent className="py-4 px-5">
<div className="py-4 px-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="flex flex-col gap-3">
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Пороги переключения</p>
@@ -1167,7 +847,7 @@ export default function RouteOptimizerPage() {
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Автоприменение</p>
<div className="flex items-center justify-between">
<span className="text-sm">Применять автоматически</span>
<Toggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
<FormToggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
</div>
{settings.autoApply && (
<>
@@ -1193,20 +873,17 @@ export default function RouteOptimizerPage() {
<Link href="/settings#route-ai" className="text-primary underline-offset-2 hover:underline">Настройки Route AI</Link>
.
</p>
</CardContent>
</div>
)}
</Card>
</FramePanel>
</Frame>
{/* OSPF optimization from Route Optimizer */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-2.5">
<div className="flex items-center gap-2 text-sm">
<NetworkIcon className="size-4 text-sky-400 shrink-0" />
<span className="font-semibold">OSPF</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
Route AI weight: ping {settings.pingWeight}%
</span>
</div>
<OpsPanel
title="OSPF"
description={`Route AI weight: ping ${settings.pingWeight}%`}
contentClassName="px-5 py-4 flex flex-col gap-2.5"
>
{!useLiveData && (
<p className="text-xs text-muted-foreground">
@@ -1264,51 +941,14 @@ export default function RouteOptimizerPage() {
: "нет данных"}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/30">
{["Интерфейс", "Cost", "Score", "Ping", "Speed (dl/ul)"].map((h) => (
<th key={h} className="text-left px-3 py-1.5 font-medium text-muted-foreground">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{ospfPreviewError && (
<tr>
<td colSpan={5} className="px-3 py-2 text-destructive">
Ошибка preview: {ospfPreviewError}
</td>
</tr>
)}
{!ospfPreviewLoading && !ospfPreviewError && (ospfPreview?.interfaces.length ?? 0) === 0 && (
<tr>
<td colSpan={5} className="px-3 py-2 text-muted-foreground">
Интерфейсы OSPF не найдены для выбранного сервера.
</td>
</tr>
)}
{(ospfPreview?.interfaces ?? []).map((row) => (
<tr key={`${row.interface}-${row.currentCost}-${row.optimalCost}`}>
<td className="px-3 py-1.5 font-mono">{row.interface}</td>
<td className="px-3 py-1.5 font-mono">
<span className="text-sky-600 dark:text-sky-400">{row.currentCost}</span>
{" → "}
<span className={row.currentCost === row.optimalCost
? "text-emerald-600 dark:text-emerald-400"
: "text-amber-600 dark:text-amber-400"}
>
{row.optimalCost}
</span>
</td>
<td className="px-3 py-1.5 font-mono">{row.score}</td>
<td className="px-3 py-1.5 font-mono">{row.pingMs}ms</td>
<td className="px-3 py-1.5 font-mono">{row.dlMbps} / {row.ulMbps}</td>
</tr>
))}
</tbody>
</table>
</div>
<RouteOptimizerOspfPreviewDataGrid
rows={(ospfPreview?.interfaces ?? []).map((row) => ({
id: `${row.interface}-${row.currentCost}-${row.optimalCost}`,
...row,
}))}
error={ospfPreviewError || null}
loading={ospfPreviewLoading}
/>
</div>
{ospfApplyResult && (
@@ -1324,20 +964,16 @@ export default function RouteOptimizerPage() {
)}
</>
)}
</CardContent>
</Card>
</OpsPanel>
{/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* ECMP Card */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center gap-2">
<GitMergeIcon className="size-4 text-violet-400" />
<span className="font-semibold text-sm">ECMP</span>
<span className="text-[10px] text-muted-foreground ml-1">Equal-Cost Multi-Path</span>
<div className="ml-auto">
<OpsPanel
title="ECMP"
description="Equal-Cost Multi-Path"
headerRight={
<button type="button" role="switch" aria-checked={ecmpEnabled}
onClick={() => setEcmpEnabled(v => !v)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
@@ -1345,8 +981,9 @@ export default function RouteOptimizerPage() {
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
ecmpEnabled ? "translate-x-4" : "translate-x-0")} />
</button>
</div>
</div>
}
contentClassName="px-5 py-4 flex flex-col gap-4"
>
<div className={cn("flex flex-col gap-3 transition-opacity", !ecmpEnabled && "opacity-40 pointer-events-none")}>
<div className="flex flex-col gap-1.5">
@@ -1377,17 +1014,10 @@ export default function RouteOptimizerPage() {
<span>RouterOS 7.x: <code className="font-mono">/routing/rule add ecmp=yes</code></span>
</div>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* RPF Card */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center gap-2">
<ShieldIcon className="size-4 text-amber-400" />
<span className="font-semibold text-sm">RPF</span>
<span className="text-[10px] text-muted-foreground ml-1">Reverse Path Forwarding</span>
</div>
<OpsPanel title="RPF" description="Reverse Path Forwarding" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground">Режим проверки источника</span>
@@ -1421,17 +1051,10 @@ export default function RouteOptimizerPage() {
<span><code className="font-mono">/ip settings set rp-filter={rpfMode}</code></span>
</div>
)}
</CardContent>
</Card>
</OpsPanel>
{/* VRF Card */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center gap-2">
<LayersIcon className="size-4 text-sky-400" />
<span className="font-semibold text-sm">VRF</span>
<span className="text-[10px] text-muted-foreground ml-1">Virtual Routing</span>
</div>
<OpsPanel title="VRF" description="Virtual Routing" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Контекст оптимизации маршрутов</span>
@@ -1463,8 +1086,7 @@ export default function RouteOptimizerPage() {
<InfoIcon className="size-3 shrink-0 mt-0.5" />
<span>Оптимизатор работает в VRF <code className="font-mono">{selectedVrf}</code></span>
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
+173 -482
View File
@@ -1,8 +1,13 @@
"use client"
import { Fragment, useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { StatusBadge } from "@/components/status-badge"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
import type { Filter } from "@/components/reui/filters"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields"
import { servers as initialServers } from "@/lib/data"
import type { ServerType, Server, WanUplink } from "@/lib/data"
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
@@ -22,7 +27,9 @@ import {
import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -30,56 +37,25 @@ import {
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu"
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import {
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
EyeIcon, EyeOffIcon,
ChevronRightIcon, ChevronDownIcon,
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
ShieldIcon, WifiIcon,
HomeIcon, ServerIcon, NetworkIcon,
} from "lucide-react"
// ─── RouterOS version utilities ───────────────────────────────────────────────
/** Numeric version score: "7.20.1 (stable)" → 720, "7.14.2" → 714, "7.9" → 709 */
function rosVer(os: string): number {
const m = os.match(/(\d+)\.(\d+)/)
if (!m) return 0
return parseInt(m[1], 10) * 100 + parseInt(m[2], 10)
}
interface RosFeature { name: string; minVer: number; label: string; desc: string }
const ROS_FEATURES: RosFeature[] = [
{ name: "WireGuard", minVer: 701, label: "7.1+", desc: "WireGuard VPN туннели" },
{ name: "Container", minVer: 704, label: "7.4+", desc: "Docker-совместимые контейнеры" },
{ name: "BFD", minVer: 705, label: "7.5+", desc: "Bidirectional Forwarding Detection" },
{ name: "Large Communities", minVer: 707, label: "7.7+", desc: "BGP Large Communities (RFC 8092)" },
{ name: "VXLAN", minVer: 710, label: "7.10+", desc: "VXLAN overlay туннели" },
{ name: "RPKI", minVer: 713, label: "7.13+", desc: "Route Origin Validation" },
{ name: "BGP Flowspec", minVer: 714, label: "7.14+", desc: "BGP Flow Spec (RFC 8955)" },
{ name: "IPv6 Firewall", minVer: 715, label: "7.15+", desc: "Расширенный IPv6 Firewall" },
{ name: "REST API v2", minVer: 716, label: "7.16+", desc: "Обновлённый REST API" },
{ name: "VRF Enhanced", minVer: 717, label: "7.17+", desc: "Расширенная поддержка VRF" },
]
function RosBadge({ os }: { os: string }) {
const v = rosVer(os)
const cls = v >= 715
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
: v >= 710
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
return (
<span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>
{os}
</span>
)
}
// ─── Countries ───────────────────────────────────────────────────────────────
const COUNTRIES = [
@@ -97,90 +73,8 @@ const COUNTRIES = [
{ code: "NO", label: "Норвегия" },
]
// ─── Type config ─────────────────────────────────────────────────────────────
const TYPE_LABELS: Record<ServerType, string> = {
"jump-host": "JumpHost",
"exit-node": "Exit Node",
"home-router": "Home Router",
}
const TYPE_STYLES: Record<ServerType, string> = {
"jump-host": "bg-violet-500/10 text-violet-400 border-violet-500/20",
"exit-node": "bg-sky-500/10 text-sky-400 border-sky-500/20",
"home-router": "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
}
const TYPE_ICONS: Record<ServerType, React.ReactNode> = {
"jump-host": <ServerIcon className="size-3 mr-1" />,
"exit-node": <NetworkIcon className="size-3 mr-1" />,
"home-router": <HomeIcon className="size-3 mr-1" />,
}
function TypeBadge({ type }: { type: ServerType }) {
return (
<span className={cn(
"inline-flex items-center text-xs font-medium border rounded px-2 py-0.5",
TYPE_STYLES[type],
)}>
{TYPE_ICONS[type]}{TYPE_LABELS[type]}
</span>
)
}
// ─── Shared small components ──────────────────────────────────────────────────
function Field({ label, hint, required, children }: {
label: string; hint?: string; required?: boolean; children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" role="switch" aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
checked ? "bg-primary" : "bg-input")}>
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0")} />
</button>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={cn("px-3 py-1 text-sm rounded transition-colors",
value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}>
{o.label}
</button>
))}
</div>
)
}
// ─── Country field ────────────────────────────────────────────────────────────
function CountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
@@ -267,30 +161,30 @@ function WanUplinkEditor({ wans, onChange }: {
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Имя" required>
<FormField label="Имя" required>
<Input className="h-8 font-mono text-xs" placeholder="WAN1-RT"
value={wan.name} onChange={e => updateWan(wan.id, { name: e.target.value })} />
</Field>
<Field label="Интерфейс">
</FormField>
<FormField label="Интерфейс">
<Input className="h-8 font-mono text-xs" placeholder="ether1"
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
</Field>
<Field label="Провайдер (ISP)">
</FormField>
<FormField label="Провайдер (ISP)">
<Input className="h-8 text-xs" placeholder="Rostelecom"
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
</Field>
<Field label="Внешний IP">
</FormField>
<FormField label="Внешний IP">
<Input className="h-8 font-mono text-xs" placeholder="94.25.168.1"
value={wan.ip} onChange={e => updateWan(wan.id, { ip: e.target.value })} />
</Field>
<Field label="↓ Макс. Мбит">
</FormField>
<FormField label="↓ Макс. Мбит">
<Input className="h-8 font-mono text-xs" type="number" min={1}
value={wan.maxDl} onChange={e => updateWan(wan.id, { maxDl: Number(e.target.value) })} />
</Field>
<Field label="↑ Макс. Мбит">
</FormField>
<FormField label="↑ Макс. Мбит">
<Input className="h-8 font-mono text-xs" type="number" min={1}
value={wan.maxUl} onChange={e => updateWan(wan.id, { maxUl: Number(e.target.value) })} />
</Field>
</FormField>
</div>
</div>
))}
@@ -328,10 +222,11 @@ export default function ServersPage() {
const [_backendOk, setBackendOk] = useState(false)
const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
const [open, setOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
const [editingId, setEditingId] = useState<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [sheetStep, setSheetStep] = useState(1)
const [form, setForm] = useState<FormState>(defaultForm)
const [testState, setTestState] = useState<TestState>("idle")
const [testMsg, setTestMsg] = useState("")
@@ -368,6 +263,7 @@ export default function ServersPage() {
function openAdd() {
setSheetMode("add"); setEditingId(null)
setForm(defaultForm); setTestState("idle"); setTestMsg("")
setSheetStep(1)
setOpen(true)
}
@@ -381,7 +277,7 @@ export default function ServersPage() {
lanSubnet: s.lanSubnet ?? "",
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
})
setTestState("idle"); setTestMsg(""); setOpen(true)
setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)
// Fetch full server details (including credentials) from backend
if (isLive) {
@@ -549,13 +445,14 @@ export default function ServersPage() {
// ── derived ──────────────────────────────────────────────────────────────
const filtered = useMemo(() => {
return serverList.filter(sv => {
const base = serverList.filter(sv => {
if (typeFilter !== "all" && sv.type !== typeFilter) return false
if (!search) return true
const q = search.toLowerCase()
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
})
}, [serverList, search, typeFilter])
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS)
}, [serverList, search, typeFilter, advancedFilters])
const counts = useMemo(() => ({
all: serverList.length,
@@ -597,291 +494,55 @@ export default function ServersPage() {
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: "Всего серверов", value: counts.all, icon: <ServerIcon className="size-4 text-muted-foreground" /> },
{ label: "Онлайн", value: counts.online, icon: <CheckCircleIcon className="size-4 text-emerald-500" /> },
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4 text-violet-400" /> },
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4 text-emerald-400" /> },
{ label: "Всего серверов", value: counts.all, icon: <ServerIcon className="size-4" />, iconClass: "text-muted-foreground" },
{ label: "Онлайн", value: counts.online, icon: <CheckCircleIcon className="size-4" />, iconClass: "text-[var(--status-online-fg)]" },
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4" />, iconClass: "text-muted-foreground" },
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4" />, iconClass: "text-muted-foreground" },
].map(s => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", s.iconClass)}>
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
{/* Table */}
<Card>
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{tabs.map(tab => (
<button key={tab.value} onClick={() => setTypeFilter(tab.value)}
className={cn(
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
typeFilter === tab.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{tab.label}
<span className="text-xs tabular-nums opacity-60">
{tab.value === "all" ? counts.all : counts[tab.value as ServerType]}
</span>
</button>
))}
</div>
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, хосту…" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} серверов</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Имя / Хост</th>
<th className="text-left font-medium px-4 py-3">Тип</th>
<th className="text-left font-medium px-4 py-3">Модель</th>
<th className="text-left font-medium px-4 py-3">RouterOS</th>
<th className="text-left font-medium px-4 py-3">Площадка</th>
<th className="text-left font-medium px-4 py-3">WAN / LAN</th>
<th className="text-right font-medium px-4 py-3">Задержка</th>
<th className="text-left font-medium px-4 py-3">Статус</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map(s => {
const isExpanded = expandedId === s.id
const ver = rosVer(s.os)
return (
<Fragment key={s.id}>
<tr
className={cn(
"hover:bg-muted/40 transition-colors cursor-pointer",
isExpanded && "bg-muted/30",
)}
onClick={() => setExpandedId(prev => prev === s.id ? null : s.id)}
>
{/* Expand chevron + name */}
<td className="px-5 py-3">
<div className="flex items-start gap-2">
{isExpanded
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
<div className="min-w-0">
<p className="font-medium truncate">{s.name}</p>
<p className="text-xs font-mono text-muted-foreground">{s.host}</p>
{s.ipv6Address && (
<p className="text-[10px] font-mono text-sky-500/70 truncate max-w-[150px]" title={s.ipv6Address}>
{s.ipv6Address}
</p>
)}
</div>
</div>
</td>
<td className="px-4 py-3"><TypeBadge type={s.type} /></td>
<td className="px-4 py-3 text-muted-foreground text-xs">{s.model}</td>
<td className="px-4 py-3"><RosBadge os={s.os} /></td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<Flag code={s.country} />
<span className="font-medium">{s.site}</span>
</div>
</td>
{/* WAN / LAN column */}
<td className="px-4 py-3">
{s.type === "home-router" && s.wanUplinks?.length ? (
<div className="flex flex-col gap-0.5">
{s.wanUplinks.map(w => (
<div key={w.id} className="flex items-center gap-1.5 text-[11px] font-mono">
<WifiIcon className="size-3 text-sky-400 shrink-0" />
<span className="font-semibold text-sky-600 dark:text-sky-400">{w.name}</span>
<span className="text-muted-foreground">{w.isp}</span>
<span className="text-muted-foreground">{w.maxDl}{w.maxUl}</span>
</div>
))}
{s.lanSubnet && (
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">
LAN {s.lanSubnet}
</div>
)}
</div>
) : (
<div className="flex flex-col gap-0.5">
{s.wireGuardIfaces && s.wireGuardIfaces.length > 0 && (
<div className="text-[11px] font-mono text-violet-500 dark:text-violet-400 flex items-center gap-1">
<ShieldIcon className="size-3" />
WG: {s.wireGuardIfaces.length} iface · {s.wireGuardIfaces.reduce((n, i) => n + i.peers.length, 0)} peers
</div>
)}
{s.rpkiEnabled && (
<div className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">RPKI </div>
)}
{!s.wireGuardIfaces?.length && !s.rpkiEnabled && (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
)}
</td>
<td className={cn("px-4 py-3 font-mono text-right text-sm",
s.latency == null ? "text-muted-foreground"
: s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "")}>
{s.latency == null ? "—" : `${s.latency} мс`}
</td>
<td className="px-4 py-3"><StatusBadge status={s.status} /></td>
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>{s.name}</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => window.open(`https://${s.host}`, "_blank")}>
<ExternalLinkIcon className="size-3.5" />Открыть WebFig
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEdit(s)}>
<PencilIcon className="size-3.5" />Редактировать
</DropdownMenuItem>
{isLive && (
<DropdownMenuItem onClick={() => handlePoll(s.id)} disabled={pollingIds.has(s.id)}>
<RefreshCwIcon className={cn("size-3.5", pollingIds.has(s.id) && "animate-spin")} />
{pollingIds.has(s.id) ? "Опрос…" : "Опросить"}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => handleToggleStatus(s.id)}>
<PowerIcon className="size-3.5" />
{s.status === "offline" ? "Включить" : "Отключить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => handleDelete(s.id)}>
<Trash2Icon className="size-3.5" />Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
{/* ── Expandable detail row ── */}
{isExpanded && (
<tr className="bg-muted/20">
<td colSpan={9} className="px-8 py-5 border-b border-border/50">
<div className="flex flex-col gap-5">
{/* Snapshot / live data */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
{s.model && s.model !== "—" && (
<span className="text-muted-foreground">Модель: <span className="font-mono text-foreground">{s.model}</span></span>
)}
{s.uptime && (
<span className="text-muted-foreground">Uptime: <span className="font-mono text-foreground">{s.uptime}</span></span>
)}
{s.cpuLoad != null && (
<span className="text-muted-foreground">CPU: <span className={cn("font-mono font-semibold", s.cpuLoad > 80 ? "text-red-400" : s.cpuLoad > 50 ? "text-amber-400" : "text-emerald-400")}>{s.cpuLoad}%</span></span>
)}
{s.asn && (
<span className="text-muted-foreground">ASN: <span className="font-mono text-foreground">{s.asn}</span></span>
)}
{s.ipv6Address && (
<span className="text-muted-foreground">IPv6: <span className="font-mono text-sky-400">{s.ipv6Address}</span></span>
)}
{s.vrfNames?.map(v => (
<span key={v} className="text-muted-foreground">VRF: <span className="font-mono text-foreground">{v}</span></span>
))}
{s.comment && (
<span className="text-muted-foreground italic">{s.comment}</span>
)}
{s.polledAt && (
<span className="text-muted-foreground/50 text-[11px]">
Опрошен: {new Date(s.polledAt).toLocaleString("ru")}
</span>
)}
{!s.polledAt && (
<span className="text-amber-500/70 text-[11px]"> Ещё не опрашивался</span>
)}
</div>
{isLive && (
<Button
variant="outline" size="sm"
className="h-7 gap-1.5 text-xs shrink-0"
disabled={pollingIds.has(s.id)}
onClick={e => { e.stopPropagation(); handlePoll(s.id) }}
>
<RefreshCwIcon className={cn("size-3.5", pollingIds.has(s.id) && "animate-spin")} />
{pollingIds.has(s.id) ? "Опрос…" : "Опросить сейчас"}
</Button>
)}
</div>
{/* Feature matrix */}
<div>
<div className="flex items-center gap-3 mb-3">
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
Возможности RouterOS
</p>
<RosBadge os={s.os} />
<span className="text-[11px] text-muted-foreground">
{ver >= 715
? "✓ Актуальная версия — все ключевые фичи доступны"
: ver >= 710
? "⚠ Рекомендуется обновление до 7.15+"
: s.os !== "—"
? "✗ Устаревшая версия — требуется обновление"
: "Нет данных — нажмите «Опросить сейчас»"}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
{ROS_FEATURES.map(f => {
const ok = ver >= f.minVer
return (
<div key={f.name} className={cn(
"flex items-start gap-2 rounded-md border px-3 py-2.5 transition-colors",
ok
? "border-emerald-500/25 bg-emerald-500/5"
: "border-border/40 bg-background/40 opacity-60",
)}>
{ok
? <CheckCircleIcon className="size-3.5 text-emerald-500 shrink-0 mt-0.5" />
: <XCircleIcon className="size-3.5 text-muted-foreground/40 shrink-0 mt-0.5" />}
<div className="min-w-0">
<p className={cn(
"text-xs font-medium leading-tight truncate",
ok ? "text-foreground" : "text-muted-foreground",
)}>
{f.name}
</p>
<p className="text-[10px] text-muted-foreground leading-tight mt-0.5">
{f.label} · {f.desc}
</p>
</div>
</div>
)
})}
</div>
</div>
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<DataPageToolbar
segmented={{
value: typeFilter,
onChange: setTypeFilter,
options: tabs.map((tab) => ({
value: tab.value,
label: tab.label,
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
})),
}}
filters={advancedFilters}
onFiltersChange={setAdvancedFilters}
filterFields={SERVER_FILTER_FIELDS}
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, хосту…"
countLabel={`${filtered.length} серверов`}
/>
<ServersDataGrid
servers={filtered}
isLive={isLive}
pollingIds={pollingIds}
onPoll={handlePoll}
onEdit={openEdit}
onDelete={handleDelete}
onToggleStatus={handleToggleStatus}
/>
</DataPageCard>
</div>
</div>
@@ -893,18 +554,45 @@ export default function ServersPage() {
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Основные</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">WAN</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">API</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={4}>
<StepperTrigger>
<StepperIndicator>4</StepperIndicator>
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-4">
{/* 1. Основные */}
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Имя сервера" required hint="Например home-msk-01">
<FormField label="Имя сервера" required hint="Например home-msk-01">
<Input className="font-mono" placeholder="home-msk-01"
value={form.name} onChange={e => set("name", e.target.value)} />
</Field>
</FormField>
<Field label="Тип узла" required>
<FormField label="Тип узла" required>
<SegmentedControl
value={form.type}
onChange={v => set("type", v)}
@@ -914,24 +602,24 @@ export default function ServersPage() {
{ value: "exit-node", label: "Exit Node" },
]}
/>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Площадка" required hint="MSK, SPB, FRA…">
<FormField label="Площадка" required hint="MSK, SPB, FRA…">
<Input className="font-mono uppercase" placeholder="MSK"
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
</Field>
</FormField>
{!isHomeRouter && (
<Field label="ASN" hint="Например AS65001">
<FormField label="ASN" hint="Например AS65001">
<Input className="font-mono" placeholder="AS65001"
value={form.asn} onChange={e => set("asn", e.target.value)} />
</Field>
</FormField>
)}
{isHomeRouter && (
<Field label="LAN-подсеть" hint="Например 192.168.10.0/24">
<FormField label="LAN-подсеть" hint="Например 192.168.10.0/24">
<Input className="font-mono" placeholder="192.168.10.0/24"
value={form.lanSubnet} onChange={e => set("lanSubnet", e.target.value)} />
</Field>
</FormField>
)}
</div>
@@ -939,45 +627,43 @@ export default function ServersPage() {
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Включён</span>
<Toggle checked={form.enabled} onChange={v => set("enabled", v)} />
<FormToggle checked={form.enabled} onChange={v => set("enabled", v)} />
</div>
</div>
{/* 2. WAN-аплинки (только для home-router) */}
{isHomeRouter && (
<div className="flex flex-col gap-4">
</StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>
{!isHomeRouter ? (
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
) : (
<WanUplinkEditor
wans={form.wanUplinks}
onChange={wans => set("wanUplinks", wans)}
/>
</div>
)}
{/* 3. Подключение (API) */}
<div className="flex flex-col gap-4">
)}
</StepperContent>
<StepperContent value={3} className="flex flex-col gap-4">
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
<Field label="Хост / IP-адрес" required
<FormField label="Хост / IP-адрес" required
hint={isHomeRouter
? "Управляющий LAN-адрес роутера, например 192.168.10.1"
: "Внешний или управляющий IP-адрес роутера"}>
<Input className="font-mono" placeholder={isHomeRouter ? "192.168.10.1" : "203.0.113.1"}
value={form.host} onChange={e => set("host", e.target.value)} />
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Протокол">
<FormField label="Протокол">
<SegmentedControl
value={form.proto}
onChange={v => { set("proto", v); set("port", v === "https" ? "443" : "80") }}
options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]}
/>
</Field>
<Field label="Порт" hint="443 / 80">
</FormField>
<FormField label="Порт" hint="443 / 80">
<Input className="font-mono" placeholder="443"
value={form.port} onChange={e => set("port", e.target.value)} />
</Field>
</FormField>
</div>
<div className="flex items-center justify-between">
@@ -988,13 +674,13 @@ export default function ServersPage() {
</p>
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
</div>
<Toggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
<FormToggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
</div>
<Field label="Путь API">
<FormField label="Путь API">
<Input className="font-mono" placeholder="/rest"
value={form.apiPath} onChange={e => set("apiPath", e.target.value)} />
</Field>
</FormField>
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">RouterOS 7.1+ REST API</p>
@@ -1008,12 +694,12 @@ export default function ServersPage() {
)}
</div>
<Field label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
<FormField label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
<Input className="font-mono" placeholder="api-user"
value={form.username} onChange={e => set("username", e.target.value)} />
</Field>
</FormField>
<Field label="Пароль" required>
<FormField label="Пароль" required>
<div className="relative">
<Input type={form.showPassword ? "text" : "password"}
className="font-mono pr-9" placeholder="Пароль пользователя RouterOS"
@@ -1024,7 +710,7 @@ export default function ServersPage() {
{form.showPassword ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</button>
</div>
</Field>
</FormField>
<div className="flex flex-col gap-2">
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
@@ -1046,10 +732,8 @@ export default function ServersPage() {
</div>
)}
</div>
</div>
{/* 4. Дополнительно */}
<div className="flex flex-col gap-4">
</StepperContent>
<StepperContent value={4} className="flex flex-col gap-4">
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
{form.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
@@ -1059,34 +743,41 @@ export default function ServersPage() {
{form.showAdvanced && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<Field label="SSH-порт">
<FormField label="SSH-порт">
<Input type="number" className="font-mono" value={form.sshPort}
onChange={e => set("sshPort", Number(e.target.value))} />
</Field>
<Field label="Winbox-порт">
</FormField>
<FormField label="Winbox-порт">
<Input type="number" className="font-mono" value={form.winboxPort}
onChange={e => set("winboxPort", Number(e.target.value))} />
</Field>
</FormField>
</div>
<Field label="Таймаут соединения, с">
<FormField label="Таймаут соединения, с">
<Input type="number" className="font-mono" value={form.timeout}
onChange={e => set("timeout", Number(e.target.value))} />
</Field>
<Field label="Комментарий">
</FormField>
<FormField label="Комментарий">
<Input placeholder="Описание или заметка" value={form.comment}
onChange={e => set("comment", e.target.value)} />
</Field>
</FormField>
</div>
)}
</div>
</div>
</StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1" onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)}
{sheetStep < 4 ? (
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : (
<Button className="ml-auto" onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
+136 -327
View File
@@ -1,11 +1,12 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
} from "@/components/ui/card"
import { FormField, FormToggle } from "@/components/form-kit"
import { FileImportDialog } from "@/components/file-import-dialog"
import { OpsPanel } from "@/components/ops-panel"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useDataSource } from "@/lib/data-source"
@@ -24,6 +25,9 @@ import {
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
DownloadIcon, UploadIcon,
} from "lucide-react"
import { SubusersDataGrid } from "@/components/data-grids/subusers-data-grid"
import { SettingsAccessSummaryDataGrid } from "@/components/data-grids/settings-access-summary-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
@@ -162,17 +166,6 @@ function makeApiFetch(backendUrl: string) {
// ─── small components ─────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
checked ? "bg-primary" : "bg-input")}>
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0")} />
</button>
)
}
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 py-3.5">
@@ -413,24 +406,24 @@ function UserSheet({ open, user, onSave, onClose }: {
{tab === "profile" && (
<div className="px-5 py-5 flex flex-col gap-4">
<Field label="Полное имя" error={errors.name}>
<FormField label="Полное имя" error={errors.name}>
<Input value={form.name} onChange={e => setField("name", e.target.value)}
placeholder="Иван Иванов" className="h-9" />
</Field>
</FormField>
<Field label="Логин" error={errors.login}>
<FormField label="Логин" error={errors.login}>
<Input value={form.login} onChange={e => setField("login", e.target.value)}
placeholder="i.ivanov" className="h-9 font-mono" />
</Field>
</FormField>
<Field label="Email" error={errors.email}>
<FormField label="Email" error={errors.email}>
<Input value={form.email} onChange={e => setField("email", e.target.value)}
placeholder="i.ivanov@company.io" type="email" className="h-9" />
</Field>
</FormField>
<Separator />
<Field label="Роль">
<FormField label="Роль">
<div className="flex gap-2 flex-wrap">
{(["viewer", "operator", "admin"] as Role[]).map(r => (
<button key={r} type="button" onClick={() => setRole(r)}
@@ -453,18 +446,18 @@ function UserSheet({ open, user, onSave, onClose }: {
? "Управление инфраструктурой согласно выданным правам"
: "Только просмотр согласно выданным правам"}
</p>
</Field>
</FormField>
<Separator />
<Field label="Статус учётной записи">
<FormField label="Статус учётной записи">
<div className="flex items-center gap-3">
<Toggle checked={form.active} onChange={v => setField("active", v)} />
<FormToggle checked={form.active} onChange={v => setField("active", v)} />
<span className={cn("text-xs font-medium", form.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
{form.active ? "Активна" : "Заблокирована"}
</span>
</div>
</Field>
</FormField>
</div>
)}
@@ -577,100 +570,14 @@ function UserSheet({ open, user, onSave, onClose }: {
</p>
</div>
{/* table header */}
{form.subUsers.length > 0 && (
<div className="grid items-center gap-2 px-4 py-1.5 bg-muted/20 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
style={{ gridTemplateColumns: "1fr 130px 120px 90px 36px 32px" }}>
<span>Логин / описание</span>
<span>Пароль</span>
<span>JH-серверы</span>
<span>IP-клиента</span>
<span />
<span />
</div>
)}
{/* rows */}
<div className="divide-y divide-border/60">
{form.subUsers.length === 0 && !addSubOpen && (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<CableIcon className="size-6 opacity-20" />
<p className="text-sm">Нет GRE-клиентов</p>
<p className="text-xs opacity-60">Добавьте учётки для подключения устройств</p>
</div>
)}
{form.subUsers.map(su => {
const jhs = servers.filter(s => su.jhServerIds.includes(s.id))
const revealed = revealedIds.has(su.id)
return (
<div key={su.id}
className={cn(
"grid items-center gap-2 px-4 py-2.5 hover:bg-muted/20 transition-colors",
!su.active && "opacity-50",
)}
style={{ gridTemplateColumns: "1fr 130px 120px 90px 36px 32px" }}>
{/* login + description */}
<div className="min-w-0">
<p className="text-xs font-mono font-medium truncate">{su.login}</p>
{su.description && (
<p className="text-[11px] text-muted-foreground truncate">{su.description}</p>
)}
{su.lastSeen && (
<p className="text-[10px] text-muted-foreground/50">{su.lastSeen}</p>
)}
</div>
{/* password */}
<div className="flex items-center gap-1 min-w-0">
<span className="font-mono text-[11px] truncate flex-1">
{revealed ? su.password : "••••••••••••"}
</span>
<button onClick={() => toggleReveal(su.id)}
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors">
{revealed
? <EyeOffIcon className="size-3" />
: <EyeIcon className="size-3" />}
</button>
<button onClick={() => navigator.clipboard.writeText(su.password).catch(() => {})}
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors">
<CopyIcon className="size-3" />
</button>
</div>
{/* JH servers */}
<div className="flex flex-wrap gap-1 min-w-0">
{jhs.length === 0
? <span className="text-[11px] text-muted-foreground/40"></span>
: jhs.map(jh => (
<span key={jh.id} className="inline-flex items-center gap-1 text-[10px] font-medium
bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20
rounded px-1 py-0.5">
<Flag code={jh.country} size={10} />
{jh.name.split("-").slice(-1)[0]}
</span>
))
}
</div>
{/* client IP */}
<span className="font-mono text-[11px] text-muted-foreground truncate">
{su.clientIp || "—"}
</span>
{/* active toggle */}
<Toggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
{/* delete */}
<button onClick={() => removeSubUser(su.id)}
className="text-muted-foreground/40 hover:text-destructive transition-colors flex justify-end">
<TrashIcon className="size-3.5" />
</button>
</div>
)
})}
</div>
<SubusersDataGrid
subUsers={form.subUsers}
servers={servers}
revealedIds={revealedIds}
onToggleReveal={toggleReveal}
onToggleActive={toggleSubUser}
onRemove={removeSubUser}
/>
{/* inline add form */}
{addSubOpen ? (
@@ -775,22 +682,6 @@ function UserSheet({ open, user, onSave, onClose }: {
)
}
// ─── helper ───────────────────────────────────────────────────────────────────
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">{label}</label>
{children}
{error && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircleIcon className="size-3" />{error}
</p>
)}
</div>
)
}
// ─── delete confirm ───────────────────────────────────────────────────────────
function DatabaseRestoreConfirm({
@@ -900,7 +791,7 @@ export default function SettingsPage() {
const [dbBackupBusy, setDbBackupBusy] = useState(false)
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
const dbRestoreInputRef = useRef<HTMLInputElement>(null)
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
// notifications
const [notifEmail, setNotifEmail] = useState(true)
@@ -1031,7 +922,6 @@ export default function SettingsPage() {
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
toast.success("База приложения восстановлена")
setDbRestoreFile(null)
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
} finally {
@@ -1047,16 +937,15 @@ export default function SettingsPage() {
<div className="space-y-4">
{/* ── Источник данных ── */}
<Card>
<CardHeader>
<CardTitle className="text-base">Источник данных</CardTitle>
<CardDescription className="text-xs">
{mockModeAvailable
<OpsPanel
title="Источник данных"
description={
mockModeAvailable
? "Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts"
: "В продакшен-образе доступны только живые данные от бекенда"}
</CardDescription>
</CardHeader>
<CardContent className="divide-y px-5">
: "В продакшен-образе доступны только живые данные от бекенда"
}
contentClassName="divide-y px-5"
>
{/* mode toggle */}
{mockModeAvailable ? (
@@ -1151,13 +1040,10 @@ export default function SettingsPage() {
<span>Бекенд недоступен данные будут отображаться из кеша или моков. Запустите бекенд: <code className="font-mono bg-muted px-1 rounded">cd backend && npm run dev</code></span>
</div>
)}
</CardContent>
</Card>
</OpsPanel>
{/* ── Основные настройки ── */}
<Card>
<CardHeader><CardTitle className="text-base">Основные настройки</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
<OpsPanel title="Основные настройки" contentClassName="divide-y px-5">
<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={lang} onChange={e => setLang(e.target.value)}>
@@ -1192,18 +1078,13 @@ export default function SettingsPage() {
description="Максимальное время ожидания при probe-тестах">
<Input className="w-20 h-8 text-sm" value={probeTimeout} onChange={e => setProbeTimeout(e.target.value)} />
</SettingRow>
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader>
<CardTitle className="text-base">База данных приложения</CardTitle>
<CardDescription className="text-xs">
Резервная копия SQLite бекенда: серверы, мониторинг, оповещения, EvoBGP. На время операции планировщик
сбора данных приостанавливается.
</CardDescription>
</CardHeader>
<CardContent className="divide-y px-5">
<OpsPanel
title="База данных приложения"
description="Резервная копия SQLite бекенда: серверы, мониторинг, оповещения, EvoBGP. На время операции планировщик сбора данных приостанавливается."
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" />
@@ -1230,47 +1111,30 @@ export default function SettingsPage() {
description="Полностью заменяет текущую базу SQLite"
>
<div className="flex flex-col items-end gap-2">
<Input
ref={dbRestoreInputRef}
type="file"
accept=".db,.sqlite,.sqlite3,application/octet-stream"
className="hidden"
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
onChange={(e) => {
const file = e.target.files?.[0] ?? null
if (!file) return
setDbRestoreFile(file)
}}
/>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
onClick={() => dbRestoreInputRef.current?.click()}
>
<UploadIcon className="size-4" />
Выбрать файл
</Button>
</div>
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>
</CardContent>
</Card>
</OpsPanel>
<Card id="route-ai" className="scroll-mt-4">
<CardHeader>
<CardTitle className="text-base">Route AI</CardTitle>
<CardDescription className="text-xs">
Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и
веса настраиваются в инструменте, не через отдельный API.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-5 pb-5">
<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>
@@ -1299,8 +1163,7 @@ export default function SettingsPage() {
>
Открыть оптимизатор маршрутов
</Link>
</CardContent>
</Card>
</OpsPanel>
</div>
)
@@ -1309,24 +1172,24 @@ export default function SettingsPage() {
if (section === "EvoBGP") return (
<div className="space-y-4">
{(mode !== "live" || backendStatus !== true) && (
<Card>
<CardContent className="pt-4 pb-4 px-4">
<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>
</CardContent>
</Card>
</FramePanel>
</Frame>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">EvoBGP API</CardTitle>
<CardDescription className="text-xs">
<OpsPanel
title="EvoBGP API"
description={
<>
Control plane EvoBGP: Bearer-ключ и роль viewer+ см.{" "}
<a
className="underline underline-offset-2"
href="https://git.shts.su/denozord/EvoBGP/src/branch/main/docs/access.md"
href="https://git.shx.one/denozord/EvoBGP/src/branch/main/docs/access.md"
target="_blank"
rel="noreferrer"
>
@@ -1336,9 +1199,10 @@ export default function SettingsPage() {
<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> через прокси.
</CardDescription>
</CardHeader>
<CardContent className="divide-y px-5 space-y-4 pb-5">
</>
}
contentClassName="divide-y px-5 flex flex-col gap-4 pb-5"
>
<SettingRow
label="Базовый URL API"
description="Например http://control.example:8080 — без суффикса /v1"
@@ -1384,7 +1248,7 @@ export default function SettingsPage() {
label="Подставлять данные EvoBGP"
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
>
<Toggle
<FormToggle
checked={evoEnabledDraft}
onChange={(v) => setEvoEnabledDraft(v)}
/>
@@ -1484,48 +1348,41 @@ export default function SettingsPage() {
{evo.error && (
<p className="text-xs text-destructive">{evo.error}</p>
)}
</CardContent>
</Card>
</OpsPanel>
</div>
)
// ── Уведомления ──
if (section === "Уведомления") return (
<div className="space-y-4">
<Card>
<CardHeader><CardTitle className="text-base">Каналы уведомлений</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
<OpsPanel title="Каналы уведомлений" contentClassName="divide-y px-5">
<SettingRow label="Email" description="Отправка уведомлений на admin@routerlists.io">
<Toggle checked={notifEmail} onChange={setNotifEmail} />
<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">
<Toggle checked={notifSlack} onChange={setNotifSlack} />
<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">
<Toggle checked={notifWh} onChange={setNotifWh} />
<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>}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">Триггеры</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
</OpsPanel>
<OpsPanel title="Триггеры" contentClassName="divide-y px-5">
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
<Toggle checked={notifDegr} onChange={setNotifDegr} />
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
</SettingRow>
<SettingRow label="Узел ушёл offline">
<Toggle checked={notifOffline} onChange={setNotifOffline} />
<FormToggle checked={notifOffline} onChange={setNotifOffline} />
</SettingRow>
<SettingRow label="Падение BGP-сессии">
<Toggle checked={notifBgp} onChange={setNotifBgp} />
<FormToggle checked={notifBgp} onChange={setNotifBgp} />
</SettingRow>
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
<Toggle checked={notifBackup} onChange={setNotifBackup} />
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
</SettingRow>
</CardContent>
</Card>
</OpsPanel>
</div>
)
@@ -1542,7 +1399,8 @@ export default function SettingsPage() {
</Button>
</div>
<Card className="overflow-hidden gap-0 py-0">
<Frame dense className="w-full">
<FramePanel className="overflow-hidden p-0">
{/* table header */}
<div className="grid grid-cols-[1fr_120px_100px_80px_auto] items-center gap-3 px-4 py-2 border-b bg-muted/30 text-[11px] font-medium text-muted-foreground">
<span>Пользователь</span>
@@ -1598,69 +1456,21 @@ export default function SettingsPage() {
)
})}
</div>
</Card>
</FramePanel>
</Frame>
{/* access summary */}
<Card className="overflow-hidden gap-0 py-0">
<DataPageCard>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<UserIcon className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium">Сводка прав доступа</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] text-muted-foreground bg-muted/30">
<th className="text-left font-medium px-4 py-2">Пользователь</th>
<th className="text-left font-medium px-4 py-2">Разделы</th>
<th className="text-left font-medium px-4 py-2">Серверы</th>
<th className="text-left font-medium px-4 py-2">Права записи</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{users.map(u => {
const writeSections = u.role === "admin" ? ALL_SECTIONS : u.sections.filter(s => s.level === "write").map(s => s.section)
const readSections = u.role === "admin" ? [] : u.sections.filter(s => s.level === "read").map(s => s.section)
const accessServers = u.role === "admin" ? servers : servers.filter(s => u.servers.find(p => p.serverId === s.id && p.level !== "none"))
return (
<tr key={u.id} className="hover:bg-muted/20 transition-colors">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<AvatarCircle avatar={u.avatar} active={u.active} />
<span className="text-sm font-medium">{u.name}</span>
</div>
</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">
{u.role === "admin"
? <span className="text-violet-600 dark:text-violet-400 font-medium">Все ({ALL_SECTIONS.length})</span>
: <span>{(readSections.length + writeSections.length)} из {ALL_SECTIONS.length}</span>}
</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">
{u.role === "admin"
? <span className="text-violet-600 dark:text-violet-400 font-medium">Все ({servers.length})</span>
: <span>{accessServers.length} из {servers.length}</span>}
</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{u.role === "admin"
? <span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20">Полный доступ</span>
: writeSections.length === 0
? <span className="text-[10px] text-muted-foreground">Только просмотр</span>
: writeSections.slice(0, 3).map(s => (
<span key={s} className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">{s}</span>
))
}
{u.role !== "admin" && writeSections.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
<SettingsAccessSummaryDataGrid
users={users}
servers={servers}
allSectionsCount={ALL_SECTIONS.length}
/>
</DataPageCard>
</div>
)
@@ -1672,8 +1482,8 @@ export default function SettingsPage() {
</div>
<div className="space-y-3">
{apiKeys.map(k => (
<Card key={k.id}>
<CardContent className="pt-4 pb-3 px-4">
<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>
@@ -1701,16 +1511,15 @@ export default function SettingsPage() {
</div>
</div>
</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm">Документация API</CardTitle>
<CardDescription className="text-xs">Base URL: https://api.routerlists.io/v1</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<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", "Список серверов"],
@@ -1728,48 +1537,37 @@ export default function SettingsPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
)
// ── Безопасность ──
if (section === "Безопасность") return (
<div className="space-y-4">
<Card>
<CardHeader><CardTitle className="text-base">Аутентификация</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
<OpsPanel title="Аутентификация" contentClassName="divide-y px-5">
<SettingRow label="Двухфакторная аутентификация (MFA)"
description="TOTP / Authenticator app для всех администраторов">
<Toggle checked={mfa} onChange={setMfa} />
<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>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Allowlist IP-адресов</CardTitle>
<CardDescription className="text-xs">Доступ разрешён только с этих сетей. Одна запись на строку.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
</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" />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">Аудит</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
</OpsPanel>
<OpsPanel title="Аудит" contentClassName="divide-y px-5">
<SettingRow label="Расширенный журнал аудита"
description="Записывать все изменения конфигурации с указанием пользователя и IP">
<Toggle checked={auditLog} onChange={setAuditLog} />
<FormToggle checked={auditLog} onChange={setAuditLog} />
</SettingRow>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base text-destructive">Опасная зона</CardTitle></CardHeader>
<CardContent className="px-4 pb-4">
</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>
@@ -1779,8 +1577,7 @@ export default function SettingsPage() {
Сбросить
</Button>
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
)
}
@@ -1853,7 +1650,6 @@ export default function SettingsPage() {
onCancel={() => {
if (dbRestoreBusy) return
setDbRestoreFile(null)
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
}}
/>
)}
@@ -1864,6 +1660,19 @@ export default function SettingsPage() {
onCancel={() => setDeleteTarget(null)}
/>
)}
<FileImportDialog
open={dbRestoreDialogOpen}
onOpenChange={setDbRestoreDialogOpen}
title="Восстановление базы данных"
description="Выберите файл SQLite (.db) — текущая база будет полностью заменена"
accept=".db,.sqlite,.sqlite3,application/octet-stream"
onImport={async (files) => {
const file = files[0]
if (!file) return
setDbRestoreFile(file)
}}
/>
</div>
)
}
+17 -11
View File
@@ -2,7 +2,8 @@
import { useState, useMemo, useEffect, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { Button } from "@/components/ui/button"
import { Sparkline } from "@/components/sparkline"
import { StatusDot } from "@/components/status-dot"
@@ -972,12 +973,17 @@ export default function TrafficPage() {
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик RX", value: fmtMbps(peakRx) },
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик TX", value: fmtMbps(peakTx) },
].map(({ icon, label, value }) => (
<Card key={label} className="overflow-hidden">
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-1.5">{icon}{label}</p>
<p className="text-2xl font-semibold tabular-nums tracking-tight">{value}</p>
</CardContent>
</Card>
<Frame key={label} className="h-full overflow-hidden">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{label}</p>
<p className="text-2xl leading-none font-bold tabular-nums tracking-tight">{value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
@@ -1034,8 +1040,8 @@ export default function TrafficPage() {
</div>
{/* ── detail panel ── */}
<Card className="flex flex-col">
<CardContent className="flex-1 px-5 pb-5 pt-5">
<Frame dense className="w-full flex flex-col">
<FramePanel className="flex-1 px-5 pb-5 pt-5">
{isLive && effectiveMode === "servers" && (
<div className="mb-3 pb-3 border-b">
<div className="flex items-center gap-2 flex-wrap">
@@ -1088,8 +1094,8 @@ export default function TrafficPage() {
{effectiveMode === "servers" && (liveDetailServer ?? selServer) && <ServerDetail sel={(liveDetailServer ?? selServer)!} range={range} setRange={setRange} />}
{effectiveMode === "users" && <UserDetail sel={selUser} range={range} setRange={setRange} />}
{effectiveMode === "gre" && <GreDetail sel={selGre} range={range} setRange={setRange} />}
</CardContent>
</Card>
</FramePanel>
</Frame>
</div>
</div>
+99 -470
View File
@@ -2,13 +2,22 @@
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { Sparkline } from "@/components/sparkline"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import {
UptimeResourcesDataGrid,
type UptimeResourceRow,
} from "@/components/data-grids/uptime-resources-data-grid"
import { UptimeSpeedHistoryDataGrid } from "@/components/data-grids/uptime-speed-history-data-grid"
import { PING_PROBE_WARN_RTT_MS } from "@/lib/ping-probe"
import { cn } from "@/lib/utils"
import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
@@ -212,21 +221,6 @@ function probeGroupActionKey(srvId: string, group: { name: string; target: strin
// ── shared components ──────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
checked ? "bg-primary" : "bg-input",
)}>
<span className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)} />
</button>
)
}
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
return (
<span className={cn(
@@ -267,18 +261,6 @@ function StatChip({
)
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">
{label}
{hint && <span className="font-normal text-muted-foreground ml-1">{hint}</span>}
</label>
{children}
</div>
)
}
/** Активный интерфейс RouterOS: не disabled и running */
function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean {
return i.running === true && i.disabled !== true
@@ -323,36 +305,6 @@ function interfaceOptionMatchesSearch(iface: RouterInterfaceOption, raw: string)
return false
}
function SegmentedControl<T extends string>({
value,
onChange,
options,
}: {
value: T
onChange: (v: T) => void
options: Array<{ value: T; label: string }>
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"px-3 py-1 text-sm rounded transition-colors",
value === option.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
)
}
function ServerPickerCards({
options,
selectedId,
@@ -910,74 +862,55 @@ function SortIcon({ k, sortKey, sortAsc }: { k: ResSortKey; sortKey: ResSortKey;
type ResTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerResource[]; serversList: Server[]; liveApi?: boolean }) {
const [sortKey, setSortKey] = useState<ResSortKey>("name")
const [sortAsc, setSortAsc] = useState(true)
const [resSearch, setResSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<ResTypeFilter>("all")
const rows = useMemo(() => resources.map((r) => {
const rows = useMemo((): UptimeResourceRow[] => resources.map((r) => {
const hasData = r.hasData !== false
const ramPct = hasData && r.ramTotal > 0 ? Math.round(r.ramUsed / r.ramTotal * 100) : 0
const hddPct = hasData && r.hddTotal > 0 ? Math.round(r.hddUsed / r.hddTotal * 100) : 0
return {
...r,
hasData,
server: serversList.find(s => s.id === r.serverId),
server: serversList.find(s => s.id === r.serverId)!,
ramPct,
hddPct,
}
}).filter(r => r.server !== undefined), [resources, serversList])
}).filter(r => serversList.some(s => s.id === r.serverId)), [resources, serversList])
// KPI aggregates (только серверы с реальными сэмплами за окно)
const onlineWithSamples = rows.filter(r => r.server!.status === "online" && r.hasData)
const onlineWithSamples = rows.filter(r => r.server.status === "online" && r.hasData)
const avgCpu = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.cpu, 0) / onlineWithSamples.length) : 0
const avgRam = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.ramPct, 0) / onlineWithSamples.length) : 0
const highCpu = rows.filter(r => r.server!.status === "online" && r.hasData && r.cpu >= 85).length
const highRam = rows.filter(r => r.server!.status === "online" && r.hasData && r.ramPct >= 85).length
const highHdd = rows.filter(r => r.server!.status === "online" && r.hasData && r.hddPct >= 85).length
const highCpu = rows.filter(r => r.server.status === "online" && r.hasData && r.cpu >= 85).length
const highRam = rows.filter(r => r.server.status === "online" && r.hasData && r.ramPct >= 85).length
const highHdd = rows.filter(r => r.server.status === "online" && r.hasData && r.hddPct >= 85).length
// Alerts
const alerts = useMemo(() =>
rows.filter(r => r.server!.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
rows.filter(r => r.server.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
[rows],
)
// Filtered + sorted
const visible = useMemo(() => {
let list = rows
if (typeFilter !== "all") list = list.filter(r => r.server!.type === typeFilter)
if (typeFilter !== "all") list = list.filter(r => r.server.type === typeFilter)
if (resSearch.trim()) {
const q = resSearch.toLowerCase()
list = list.filter(r =>
r.server!.name.toLowerCase().includes(q) ||
r.server!.site.toLowerCase().includes(q) ||
r.server.name.toLowerCase().includes(q) ||
r.server.site.toLowerCase().includes(q) ||
r.boardName.toLowerCase().includes(q)
)
}
list = [...list].sort((a, b) => {
let diff = 0
switch (sortKey) {
case "name": diff = a.server!.name.localeCompare(b.server!.name); break
case "cpu": diff = a.cpu - b.cpu; break
case "ram": diff = a.ramPct - b.ramPct; break
case "hdd": diff = a.hddPct - b.hddPct; break
case "uptime": diff = a.uptimeSeconds - b.uptimeSeconds; break
case "temp": diff = (a.temp ?? -1) - (b.temp ?? -1); break
}
return sortAsc ? diff : -diff
})
return list
}, [rows, typeFilter, resSearch, sortKey, sortAsc])
function toggleSort(k: ResSortKey) {
if (sortKey === k) setSortAsc(v => !v)
else { setSortKey(k); setSortAsc(false) } // default desc for metrics
}
}, [rows, typeFilter, resSearch])
function exportCsv() {
const header = ["Сервер", "Тип", "Площадка", "CPU %", "RAM %", "RAM использ.", "RAM всего", "HDD %", "HDD использ.", "HDD всего", "Uptime", "Температура °C", "RouterOS"]
const rowsCsv = visible.map(r => {
const s = r.server!
const s = r.server
return [s.name, s.type, s.site, r.cpu, r.ramPct, fmtMB(r.ramUsed), fmtMB(r.ramTotal),
r.hddPct, fmtMB(r.hddUsed), fmtMB(r.hddTotal), fmtUptime(r.uptimeSeconds),
r.temp ?? "", s.os].join(",")
@@ -1010,7 +943,7 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
<AlertDescription>
<div className="flex flex-wrap gap-1.5">
{alerts.map(r => {
const s = r.server!
const s = r.server
const issues: string[] = []
if (r.cpu >= 85) issues.push(`CPU ${r.cpu}%`)
if (r.ramPct >= 85) issues.push(`RAM ${r.ramPct}%`)
@@ -1038,248 +971,41 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
].map(kpi => (
<Card key={kpi.sub}>
<CardContent className="px-4 py-3 flex items-start gap-3">
<div className={cn("mt-0.5 shrink-0", kpi.color)}>{kpi.icon}</div>
<div className="min-w-0">
<p className={cn("text-xl font-semibold tabular-nums leading-tight", kpi.color)}>{kpi.label}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{kpi.sub}</p>
<Frame key={kpi.sub} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", kpi.color)}>
{kpi.icon}
</IconTile>
<div className="min-w-0 flex flex-col gap-0.5">
<p className={cn("text-xl leading-none font-bold tabular-nums", kpi.color)}>{kpi.label}</p>
<p className="text-[11px] text-muted-foreground">{kpi.sub}</p>
</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
<div className="flex items-center gap-3 flex-wrap">
{/* Type filter */}
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5 shrink-0">
{typeOpts.map(o => (
<button key={o.value} onClick={() => setTypeFilter(o.value)}
className={cn(
"px-2.5 py-1 text-xs rounded transition-colors whitespace-nowrap",
typeFilter === o.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}>
{o.label}
</button>
))}
</div>
{/* Search */}
<div className="relative min-w-[180px] flex-1 max-w-xs">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Поиск по имени, площадке…"
value={resSearch} onChange={e => setResSearch(e.target.value)} />
{resSearch && (
<button onClick={() => setResSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{visible.length} из {rows.length} серверов
</span>
{/* Export CSV */}
<Button variant="outline" size="sm" className="h-8 gap-1.5 shrink-0" onClick={exportCsv}>
<DownloadIcon className="size-3.5" />CSV
</Button>
</div>
{/* ── Table ─────────────────────────────────────────────────────────── */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30 text-xs text-muted-foreground font-medium">
{/* Sortable: name */}
<th className="text-left px-5 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("name")}>
<span className="flex items-center gap-0.5">
Сервер <SortIcon k="name" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
<th className="text-left px-4 py-3 hidden md:table-cell whitespace-nowrap">Модель · ROS</th>
{/* Sortable: cpu */}
<th className="text-left px-4 py-3 min-w-[160px] cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("cpu")}>
<span className="flex items-center gap-1.5">
<CpuIcon className="size-3.5" />CPU
<SortIcon k="cpu" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
{/* Sortable: ram */}
<th className="text-left px-4 py-3 min-w-[175px] cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("ram")}>
<span className="flex items-center gap-1.5">
<HardDriveIcon className="size-3.5" />RAM
<SortIcon k="ram" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
{/* Sortable: hdd */}
<th className="text-left px-4 py-3 min-w-[175px] cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("hdd")}>
<span className="flex items-center gap-1.5">
<HardDriveIcon className="size-3.5" />Диск
<SortIcon k="hdd" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
{/* Sortable: uptime */}
<th className="text-left px-4 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("uptime")}>
<span className="flex items-center gap-1.5">
<ClockIcon className="size-3.5" />Uptime
<SortIcon k="uptime" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
{/* Sortable: temp */}
<th className="text-left px-4 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("temp")}>
<span className="flex items-center gap-1.5">
<ThermometerIcon className="size-3.5" />°C
<SortIcon k="temp" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{visible.length === 0 && (
<tr>
<td colSpan={7} className="text-center text-sm text-muted-foreground py-12">
<SearchIcon className="size-6 mx-auto mb-2 opacity-20" />
Ничего не найдено
</td>
</tr>
)}
{visible.map(r => {
const srv = r.server!
const offline = srv.status !== "online"
const hasSamples = r.hasData !== false
const noMetrics = offline || !hasSamples
const isCrit = !noMetrics && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
const cpuColor = r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
return (
<tr key={r.serverId} className={cn(
"hover:bg-muted/30 transition-colors",
offline && "opacity-50",
isCrit && "bg-red-500/3",
)}>
{/* Server */}
<td className="px-5 py-3">
<div className="flex items-center gap-2 flex-wrap">
{isCrit && <AlertCircleIcon className="size-3.5 text-red-500 shrink-0" />}
{!isCrit && <StatusDot status={srv.status} pulse={!offline} />}
<Flag code={srv.country} size={16} />
<span className="font-mono font-semibold">{srv.name}</span>
<TypeChip type={srv.type} />
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
{!offline && r.hasData === false && (
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
нет данных
</span>
)}
</div>
</td>
{/* Board + ROS */}
<td className="px-4 py-3 hidden md:table-cell">
<div className="flex flex-col leading-tight">
<span className="font-mono text-xs text-muted-foreground">{hasSamples ? r.boardName : "—"}</span>
<span className="text-[10px] text-muted-foreground/50">{srv.os}</span>
</div>
</td>
{/* CPU */}
<td className="px-4 py-3">
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[140px]">
<div className="flex items-center gap-2">
<span className={cn("font-mono text-sm font-semibold tabular-nums w-10 shrink-0", resPctColor(r.cpu))}>
{r.cpu}%
</span>
<MiniBar pct={r.cpu} className="flex-1" />
</div>
<Sparkline data={r.cpuHistory} width={120} height={18} color={cpuColor} filled />
</div>
)}
</td>
{/* RAM */}
<td className="px-4 py-3">
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
<span className={cn("font-mono font-semibold", resPctColor(r.ramPct))}>{r.ramPct}%</span>
<span className="text-muted-foreground/60 font-mono text-[10px]">
{fmtMB(r.ramUsed)}/{fmtMB(r.ramTotal)}
</span>
</div>
<MiniBar pct={r.ramPct} />
</div>
)}
</td>
{/* HDD */}
<td className="px-4 py-3">
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
<span className={cn("font-mono font-semibold", resPctColor(r.hddPct))}>{r.hddPct}%</span>
<span className="text-muted-foreground/60 font-mono text-[10px]">
{fmtMB(r.hddUsed)}/{fmtMB(r.hddTotal)}
</span>
</div>
<MiniBar pct={r.hddPct} />
</div>
)}
</td>
{/* Uptime */}
<td className="px-4 py-3">
<span className="font-mono text-xs text-muted-foreground">
{noMetrics ? (offline ? "—" : "—") : fmtUptime(r.uptimeSeconds)}
</span>
</td>
{/* Temp */}
<td className="px-4 py-3">
{r.temp !== undefined && !noMetrics ? (
<span className={cn("font-mono text-sm font-semibold tabular-nums",
r.temp >= 70 ? "text-red-600 dark:text-red-400"
: r.temp >= 55 ? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground",
)}>
{r.temp}°C
</span>
) : (
<span className="text-muted-foreground/30 text-xs"></span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<DataPageToolbar
segmented={{
value: typeFilter,
onChange: setTypeFilter,
options: typeOpts,
}}
search={resSearch}
onSearchChange={setResSearch}
searchPlaceholder="Поиск по имени, площадке…"
countLabel={`${visible.length} из ${rows.length} серверов`}
actions={
<Button variant="outline" size="sm" className="h-8 gap-1.5 shrink-0" onClick={exportCsv}>
<DownloadIcon className="size-3.5" />
CSV
</Button>
}
/>
<UptimeResourcesDataGrid rows={visible} />
</DataPageCard>
<p className="text-xs text-muted-foreground/40 text-center">
{liveApi
@@ -2303,11 +2029,11 @@ export default function UptimePage() {
{ label: "Макс TX", value: maxTx != null ? `${maxTx}` : "—", unit: maxTx != null ? "Мбит/с" : "", color: "text-[var(--chart-tx)]" },
{ label: "Макс RX", value: maxRx != null ? `${maxRx}` : "—", unit: maxRx != null ? "Мбит/с" : "", color: "text-[var(--chart-rx)]" },
].map(k => (
<Card key={k.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{k.label}</p>
<div className="flex items-baseline gap-1 mt-0.5">
<span className={cn("text-2xl font-semibold tabular-nums", k.color)}>{k.value}</span>
<Frame key={k.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{k.label}</p>
<div className="flex items-baseline gap-1">
<span className={cn("text-2xl leading-none font-bold tabular-nums", k.color)}>{k.value}</span>
{k.unit && <span className="text-xs text-muted-foreground">{k.unit}</span>}
</div>
{runningCnt > 0 && k.label === "Тестов выполнено" && (
@@ -2315,8 +2041,8 @@ export default function UptimePage() {
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется
</p>
)}
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
)
@@ -2354,7 +2080,8 @@ export default function UptimePage() {
{speedGrouped.map(({ server, probes: srvProbes }) => {
const isCollapsed = speedCollapsed.has(server.id)
return (
<Card key={server.id} className="overflow-hidden py-0 gap-0">
<Frame key={server.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* server header */}
<button
onClick={() => toggleSpeedCollapse(server.id)}
@@ -2401,7 +2128,7 @@ export default function UptimePage() {
className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}>
<div className="flex items-center gap-3 flex-wrap">
{/* enable toggle */}
<Toggle checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
<FormToggle checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
{/* route: src → dst */}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
@@ -2522,120 +2249,20 @@ export default function UptimePage() {
})}
</div>
)}
</Card>
</FramePanel>
</Frame>
)
})}
{/* ── history ── */}
{speedRuns.length > 0 && (
<Card className="overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b">
<DataPageCard>
<div className="flex items-center justify-between px-5 py-3 border-b">
<p className="text-sm font-medium">История тестов</p>
<span className="text-xs text-muted-foreground">{speedRuns.length} запусков</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40 text-muted-foreground">
{["Время", "Маршрут", "Параметры", "Статус", "TX avg", "RX avg", "Ping после BT"].map(h => (
<th key={h} className="px-4 py-2.5 text-left font-medium whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{speedRuns.map((run) => {
const src = allServers.find((s) => s.id === run.srcServerId)
const dst = allServers.find((s) => s.id === run.dstServerId)
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1)
return (
<tr key={run.id} className="hover:bg-muted/20 transition-colors">
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums font-mono">
{new Date(run.startedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit", day: "2-digit", month: "2-digit" })}
</td>
<td className="px-4 py-2.5 font-mono whitespace-nowrap">
<div className="flex items-center gap-1.5">
<Flag code={src?.country ?? "UN"} size={13} />
<span>{src?.name ?? run.srcServerId}</span>
<ArrowRightIcon className="size-3 text-muted-foreground" />
<Flag code={dst?.country ?? "UN"} size={13} />
<span>{dst?.name ?? run.dstServerId}</span>
</div>
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
{run.srcInterfaceAddress && run.dstInterfaceAddress
? `${run.srcInterfaceAddress}${run.dstInterfaceAddress}`
: "внутренние IP: auto/не указаны"}
</div>
</td>
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap">
<div className="flex items-center gap-1">
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold bg-muted/60 border-border/60">
{run.protocol.toUpperCase()}
</span>
<span className="text-muted-foreground/60">·</span>
<span>{run.direction}</span>
<span className="text-muted-foreground/60">·</span>
<span>{run.durationSec}s</span>
</div>
</td>
<td className="px-4 py-2.5">
{run.status === "running" ? (
<span className="inline-flex items-center gap-1 text-[var(--status-degraded-fg)]">
<RefreshCwIcon className="size-3 animate-spin" />running
</span>
) : run.status === "error" ? (
<span className="text-[var(--status-offline-fg)]">error</span>
) : (
<span className="text-[var(--status-online-fg)]">done</span>
)}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2 min-w-[120px]">
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full rounded-full bg-[var(--chart-tx)]"
style={{ width: `${(run.txAvgMbps / maxVal) * 100}%` }} />
</div>
<span className="font-mono tabular-nums text-[var(--chart-tx)] font-medium whitespace-nowrap">
{run.txAvgMbps} Мбит/с
</span>
</div>
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2 min-w-[120px]">
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full rounded-full bg-[var(--chart-rx)]"
style={{ width: `${(run.rxAvgMbps / maxVal) * 100}%` }} />
</div>
<span className="font-mono tabular-nums text-[var(--chart-rx)] font-medium whitespace-nowrap">
{run.rxAvgMbps} Мбит/с
</span>
</div>
</td>
<td className="px-4 py-2.5 font-mono tabular-nums whitespace-nowrap">
{run.status !== "done" ? "—" : run.afterBtPing?.error ? (
<span className="text-[var(--status-offline-fg)]" title={run.afterBtPing.error}>
ошибка
</span>
) : run.afterBtPing?.rttMs != null ? (
<span className="text-violet-600 dark:text-violet-400">
{run.afterBtPing.rttMs} мс
{run.afterBtPing.lossPct != null && run.afterBtPing.lossPct > 0 && (
<span className="text-amber-600 dark:text-amber-400"> · {run.afterBtPing.lossPct}%</span>
)}
</span>
) : (
<span className="text-amber-600 dark:text-amber-400">
timeout
{run.afterBtPing?.lossPct != null && <span> · {run.afterBtPing.lossPct}%</span>}
</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
<UptimeSpeedHistoryDataGrid runs={speedRuns} servers={allServers} />
</DataPageCard>
)}
</div>
@@ -2743,7 +2370,8 @@ export default function UptimePage() {
const warnCount = allServerProbes.filter(p => p.status === "warn").length
return (
<Card key={srv.id} className="overflow-hidden py-0 gap-0">
<Frame key={srv.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* server header */}
<button onClick={() => toggleCollapse(srv.id)}
@@ -2824,7 +2452,7 @@ export default function UptimePage() {
!p.enabled && "opacity-40",
)}
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
<Toggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
<FormToggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
<StatusDot
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
pulse={p.status === "up" && p.enabled}
@@ -2970,7 +2598,8 @@ export default function UptimePage() {
</div>
</>
)}
</Card>
</FramePanel>
</Frame>
)
})}
</div>
@@ -3002,7 +2631,7 @@ export default function UptimePage() {
</SheetHeader>
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
<Field label="Источник">
<FormField label="Источник">
<ServerPickerCards
options={selectableSources}
selectedId={speedDraft.srcServerId}
@@ -3015,9 +2644,9 @@ export default function UptimePage() {
void loadSpeedInterfaces(nextSrc)
}}
/>
</Field>
</FormField>
<Field label="Назначение">
<FormField label="Назначение">
<ServerPickerCards
options={selectableSources.filter((s) => s.id !== speedDraft.srcServerId)}
selectedId={speedDraft.dstServerId}
@@ -3026,28 +2655,28 @@ export default function UptimePage() {
void loadSpeedInterfaces(nextDst)
}}
/>
</Field>
</FormField>
<Field label="Интерфейс источника">
<FormField label="Интерфейс источника">
<InterfacePickerCards
value={speedDraft.srcInterface}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, srcInterface: v }))}
options={filterActiveInterfaces(speedIfaces[speedDraft.srcServerId] ?? [])}
autoLabel="auto"
/>
</Field>
</FormField>
<Field label="Интерфейс назначения">
<FormField label="Интерфейс назначения">
<InterfacePickerCards
value={speedDraft.dstInterface}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, dstInterface: v }))}
options={filterActiveInterfaces(speedIfaces[speedDraft.dstServerId] ?? [])}
autoLabel="auto"
/>
</Field>
</FormField>
<div className="grid grid-cols-3 gap-3">
<Field label="Протокол">
<FormField label="Протокол">
<SegmentedControl
value={speedDraft.protocol}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, protocol: v }))}
@@ -3056,8 +2685,8 @@ export default function UptimePage() {
{ value: "udp", label: "UDP" },
]}
/>
</Field>
<Field label="Direction">
</FormField>
<FormField label="Direction">
<SegmentedControl
value={speedDraft.direction}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, direction: v }))}
@@ -3067,10 +2696,10 @@ export default function UptimePage() {
{ value: "receive", label: "rx" },
]}
/>
</Field>
<Field label="Сек">
</FormField>
<FormField label="Сек">
<Input className="h-9" value={speedDraft.durationSec} onChange={(e) => setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
</Field>
</FormField>
</div>
{speedDraft.srcServerId &&
@@ -3169,7 +2798,7 @@ export default function UptimePage() {
</div>
</div>
<Field
<FormField
label="Источник (кто пингует)"
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
>
@@ -3178,9 +2807,9 @@ export default function UptimePage() {
selectedId={newSrcId}
onSelect={(id) => setNewSrcId(id)}
/>
</Field>
</FormField>
<Field label="Интерфейс источника" hint="(необязательно)">
<FormField label="Интерфейс источника" hint="(необязательно)">
<InterfacePickerCards
value={newSrcInterface}
onChange={setNewSrcInterface}
@@ -3188,25 +2817,25 @@ export default function UptimePage() {
autoLabel="авто (по маршруту)"
busy={srcInterfacesBusy}
/>
</Field>
</FormField>
<Field label="Имя пробы">
<FormField label="Имя пробы">
<Input className="h-9 text-sm" placeholder="youtube.com"
value={newName} onChange={e => setNewName(e.target.value)} />
</Field>
</FormField>
<Field label="Целевой IP / хост">
<FormField label="Целевой IP / хост">
<Input className="h-9 text-sm font-mono" placeholder="142.250.74.110"
value={newTarget} onChange={e => setNewTarget(e.target.value)} />
</Field>
</FormField>
<Field label="Связанный фильтр" hint="(необязательно)">
<FormField label="Связанный фильтр" hint="(необязательно)">
<LinkedFilterPickerCards
value={newFilter}
onChange={setNewFilter}
items={filters}
/>
</Field>
</FormField>
</div>
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
+33 -156
View File
@@ -4,17 +4,16 @@ import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { vxlanTunnels, servers } from "@/lib/data"
import type { VxlanTunnel } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import {
SearchIcon, NetworkIcon, PlusIcon, MoreHorizontalIcon,
Trash2Icon, PencilIcon, PowerIcon, CopyIcon, CheckIcon,
NetworkIcon, PlusIcon, CopyIcon, CheckIcon,
CodeXmlIcon, LayersIcon,
} from "lucide-react"
import {
@@ -125,100 +124,7 @@ function ExportSheet({ open, tunnel, onClose }: {
)
}
// ─── Tunnel row ───────────────────────────────────────────────────────────────
function TunnelRow({
tunnel,
onExport,
}: {
tunnel: VxlanTunnel
onExport: () => void
}) {
const srv = serverFor(tunnel.serverId)
return (
<div className={cn(
"grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 transition-colors",
!tunnel.enabled && "opacity-50",
)}>
{/* status dot */}
<span className={cn(
"size-2 rounded-full shrink-0",
tunnel.status === "up" ? "bg-emerald-500" : "bg-red-500",
)} />
{/* name */}
<div className="min-w-0">
<p className="font-mono font-medium text-sm truncate">{tunnel.name}</p>
<p className="text-[11px] text-muted-foreground font-mono">VTEP: {tunnel.vtepIp}</p>
</div>
{/* server */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
{srv && <><Flag code={srv.country} size={12} /><span className="font-mono truncate">{srv.name}</span></>}
</div>
{/* VNI */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">VNI</p>
<p className="font-mono text-sm">{tunnel.vni}</p>
</div>
{/* Port */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Port</p>
<p className="font-mono text-sm">{tunnel.dstPort}</p>
</div>
{/* Remote VTEPs */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Remote VTEP</p>
<p className="font-mono text-sm">{tunnel.remoteVteps.length}</p>
</div>
{/* ARP Proxy */}
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
tunnel.arpProxy ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-muted text-muted-foreground")}>
ARP {tunnel.arpProxy ? "✓" : "✗"}
</span>
{/* MAC learning */}
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
tunnel.macLearning ? "bg-sky-500/10 text-sky-600 dark:text-sky-400" : "bg-muted text-muted-foreground")}>
MAC {tunnel.macLearning ? "✓" : "✗"}
</span>
{/* Status badge */}
<span className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
tunnel.status === "up"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20",
)}>
{tunnel.status === "up" ? "UP" : "DOWN"}
</span>
{/* menu */}
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />{tunnel.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
// ════════════════════════════════════════════════════════════════════════════
// ─── Export Sheet ─────────────────────────────────────────────────────────────
export default function VxlanPage() {
const [search, setSearch] = useState("")
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
@@ -259,15 +165,17 @@ export default function VxlanPage() {
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -284,52 +192,22 @@ export default function VxlanPage() {
</div>
{/* Table */}
<Card>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, VNI, серверу…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
</div>
{/* header */}
<div className="grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
<span />
<span>Имя / VTEP IP</span>
<span>Сервер</span>
<span>VNI</span>
<span>Port</span>
<span>Remote</span>
<span />
<span />
<span>Статус</span>
<span />
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<NetworkIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">VXLAN туннели не найдены</p>
</div>
) : (
filtered.map((t) => (
<TunnelRow key={t.id} tunnel={t} onExport={() => setExportTunnel(t)} />
))
)}
</Card>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, VNI, серверу…"
countLabel={`${filtered.length} туннелей`}
/>
<VxlanDataGrid
tunnels={filtered}
servers={servers}
onExport={setExportTunnel}
/>
</DataPageCard>
{/* Reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7 · /interface/vxlan быстрые команды
</p>
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
@@ -379,8 +257,7 @@ export default function VxlanPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
+38 -253
View File
@@ -3,34 +3,30 @@
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { servers } from "@/lib/data"
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import type { WireGuardInterface } from "@/lib/data"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import {
WireguardDataGrid,
type WgIfaceWithServer,
} from "@/components/data-grids/wireguard-data-grid"
import { Button } from "@/components/ui/button"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { cn } from "@/lib/utils"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import {
ShieldCheckIcon, PlusIcon, SearchIcon, KeyRoundIcon,
ChevronDownIcon, ChevronRightIcon, MoreHorizontalIcon,
PencilIcon, Trash2Icon, PowerIcon, CopyIcon, CheckIcon,
CodeXmlIcon, UsersIcon, ActivityIcon, ArrowDownIcon, ArrowUpIcon,
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
CodeXmlIcon, UsersIcon, ActivityIcon,
CopyIcon, CheckIcon,
} from "lucide-react"
// ─── collect all WireGuard interfaces from all servers ────────────────────────
interface WgIfaceWithServer extends WireGuardInterface {
serverId: string
serverName: string
serverCountry: string
}
function collectInterfaces(): WgIfaceWithServer[] {
const result: WgIfaceWithServer[] = []
for (const srv of servers) {
@@ -48,19 +44,6 @@ function collectInterfaces(): WgIfaceWithServer[] {
// ─── helpers ──────────────────────────────────────────────────────────────────
function fmtBytes(n: number | undefined): string {
if (!n) return "—"
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
return `${n} Б`
}
function truncKey(key: string): string {
if (key.length <= 20) return key
return `${key.slice(0, 8)}${key.slice(-8)}`
}
// ─── RSC generator ────────────────────────────────────────────────────────────
function generateWgRsc(iface: WgIfaceWithServer): string {
@@ -89,161 +72,6 @@ function generateWgRsc(iface: WgIfaceWithServer): string {
return lines.join("\n")
}
// ─── Peer row ─────────────────────────────────────────────────────────────────
function PeerRow({ peer }: { peer: WireGuardPeer }) {
return (
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20">
{/* public key */}
<div className="flex items-center gap-1.5 min-w-0">
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
</span>
</div>
{/* allowed IPs */}
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
</div>
{/* handshake */}
<span className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}>
{peer.latestHandshake ?? "нет рукопожатия"}
</span>
{/* rx / tx */}
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />{fmtBytes(peer.transferTx)}
</span>
</div>
{/* endpoint */}
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
</div>
)
}
// ─── Interface card ───────────────────────────────────────────────────────────
function IfaceRow({
iface,
expanded,
onToggleExpand,
onExport,
}: {
iface: WgIfaceWithServer
expanded: boolean
onToggleExpand: () => void
onExport: () => void
}) {
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
return (
<div className={cn("border-b last:border-b-0", !iface.enabled && "opacity-50")}>
<div
className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer"
onClick={onToggleExpand}
>
{/* expand */}
<button className="text-muted-foreground" onClick={(e) => { e.stopPropagation(); onToggleExpand() }}>
{expanded
? <ChevronDownIcon className="size-3.5" />
: <ChevronRightIcon className="size-3.5" />}
</button>
{/* name + server */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
"size-2 rounded-full shrink-0",
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
)} />
<span className="font-mono font-semibold text-sm">{iface.name}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry} size={12} />
{iface.serverName}
</div>
</div>
{/* port */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Порт</p>
<p className="font-mono text-sm">{iface.listenPort}</p>
</div>
{/* MTU */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">MTU</p>
<p className="font-mono text-sm">{iface.mtu}</p>
</div>
{/* peers */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Пиров</p>
<p className="font-mono text-sm">
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
<span className="text-muted-foreground">/{iface.peers.length}</span>
</p>
</div>
{/* status badge */}
<span className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border",
iface.status === "up"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20",
)}>
{iface.status === "up" ? "UP" : "DOWN"}
</span>
{/* menu */}
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7" onClick={(e) => e.stopPropagation()}>
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onExport() }}>
<CodeXmlIcon className="size-4" />Экспорт .rsc
</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem><PlusIcon className="size-4" />Добавить пира</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />{iface.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* expanded peers */}
{expanded && iface.peers.length > 0 && (
<div>
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-t border-border/50">
<span>Public Key</span>
<span>Allowed IPs</span>
<span>Последнее рукопожатие</span>
<span>RX / TX</span>
<span>Endpoint</span>
</div>
{iface.peers.map((p) => <PeerRow key={p.publicKey} peer={p} />)}
</div>
)}
{expanded && iface.peers.length === 0 && (
<div className="px-4 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
Нет пиров
</div>
)}
</div>
)
}
// ─── Export Sheet ─────────────────────────────────────────────────────────────
function ExportSheet({ open, iface, onClose }: {
@@ -309,8 +137,7 @@ function ExportSheet({ open, iface, onClose }: {
export default function WireGuardPage() {
const allIfaces = useMemo(() => collectInterfaces(), [])
const [search, setSearch] = useState("")
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState("")
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
const filtered = useMemo(() => {
@@ -327,14 +154,6 @@ export default function WireGuardPage() {
const onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0)
const upIfaces = allIfaces.filter((i) => i.status === "up").length
function toggleExpand(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
return (
<div className="flex flex-col h-full">
<PageHeader
@@ -359,15 +178,17 @@ export default function WireGuardPage() {
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -384,56 +205,21 @@ export default function WireGuardPage() {
</div>
{/* Search + table */}
<Card>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[260px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, серверу, IP…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} интерфейсов</span>
</div>
{/* table header */}
<div className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
<span />
<span>Интерфейс / Сервер</span>
<span>Порт</span>
<span>MTU</span>
<span>Пиры</span>
<span>Статус</span>
<span />
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">Нет WireGuard интерфейсов</p>
<p className="text-xs mt-1">Добавьте первый интерфейс или проверьте поиск</p>
</div>
) : (
filtered.map((iface) => (
<IfaceRow
key={iface.id}
iface={iface}
expanded={expandedIds.has(iface.id)}
onToggleExpand={() => toggleExpand(iface.id)}
onExport={() => setExportIface(iface)}
/>
))
)}
</Card>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, серверу, IP…"
countLabel={`${filtered.length} интерфейсов`}
/>
<WireguardDataGrid
interfaces={filtered}
onExport={setExportIface}
/>
</DataPageCard>
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7 · /interface wireguard быстрые команды
</p>
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
@@ -476,8 +262,7 @@ export default function WireGuardPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
+19
View File
@@ -0,0 +1,19 @@
"use client"
export default function AccessDeniedPage() {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
<h1 className="text-lg font-semibold">Нет доступа</h1>
<p className="text-muted-foreground max-w-md text-sm">
У вашей учётной записи нет приложения MikrotikManager (`mm`) или
необходимых прав. Обратитесь к администратору auth-portal.
</p>
<a
href="/auth/callback"
className="text-primary text-sm underline-offset-4 hover:underline"
>
Войти снова
</a>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
import type { NextRequest } from "next/server"
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export const maxDuration = 600
export async function GET(request: NextRequest) {
return proxyBackendRequest(request, "/api/system/database/backup", {
method: "GET",
forwardRequestBody: false,
})
}
+10
View File
@@ -0,0 +1,10 @@
import type { NextRequest } from "next/server"
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export const maxDuration = 600
export async function POST(request: NextRequest) {
return proxyBackendRequest(request, "/api/system/database/restore", { method: "POST" })
}
+98
View File
@@ -0,0 +1,98 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import {
clearPortalHandoffFlag,
clearToken,
ensureAuthConfig,
firstAllowedPath,
getClaims,
getToken,
parseHashToken,
redirectToPortalLogin,
redirectToPortalLoginInteractive,
setToken,
} from "@/lib/auth"
export default function AuthCallbackPage() {
const router = useRouter()
const [message, setMessage] = useState("Перенаправление на Auth Portal…")
useEffect(() => {
let cancelled = false
void (async () => {
await ensureAuthConfig()
if (cancelled) return
const params = new URLSearchParams(window.location.search)
const error = params.get("error")
if (error === "sso_loop" || error === "jwt_rejected") {
redirectToPortalLoginInteractive()
return
}
const { accessToken } = parseHashToken(window.location.hash)
if (accessToken) {
setToken(accessToken)
clearPortalHandoffFlag()
const claims = getClaims()
if (!claims) {
clearToken()
redirectToPortalLoginInteractive()
return
}
if (!claims.apps.includes("mm")) {
setMessage("Нет доступа к приложению")
router.replace("/access-denied")
return
}
try {
const res = await fetch("/api/auth/config", {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (res.status === 401) {
clearToken()
redirectToPortalLoginInteractive()
return
}
} catch {
/* ignore network — proceed */
}
const next = firstAllowedPath()
if (next === "/access-denied") {
router.replace("/access-denied")
return
}
router.replace(next)
return
}
if (getToken() && getClaims()) {
clearPortalHandoffFlag()
if (!getClaims()!.apps.includes("mm")) {
router.replace("/access-denied")
return
}
router.replace(firstAllowedPath())
return
}
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
if (!ok) redirectToPortalLoginInteractive()
})()
return () => {
cancelled = true
}
}, [router])
return (
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
{message}
</div>
)
}
+27
View File
@@ -39,6 +39,15 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-destructive-foreground: var(--destructive-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-invert: var(--invert);
--color-invert-foreground: var(--invert-foreground);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
@@ -74,6 +83,15 @@
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: var(--color-red-800);
--info: var(--color-violet-500);
--info-foreground: var(--color-violet-900);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-900);
--warning: var(--color-yellow-500);
--warning-foreground: var(--color-yellow-900);
--invert: var(--color-zinc-900);
--invert-foreground: var(--color-zinc-50);
/* Borders + inputs */
--border: oklch(0.904 0.006 264.0);
@@ -148,6 +166,15 @@
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: var(--color-red-600);
--info: var(--color-violet-500);
--info-foreground: var(--color-violet-600);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-600);
--warning: var(--color-yellow-500);
--warning-foreground: var(--color-yellow-600);
--invert: var(--color-zinc-700);
--invert-foreground: var(--color-zinc-50);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
+7
View File
@@ -6,3 +6,10 @@ PORT=8000
# Allowed CORS origin (Next.js frontend)
CORS_ORIGIN=http://localhost:3000
# Portal SSO (false = open API for local/dev)
AUTH_REQUIRED=false
# Same HS256 secret as auth-portal JWT_SECRET when AUTH_REQUIRED=true
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
+1
View File
@@ -4,3 +4,4 @@ dist/
*.db-shm
*.db-wal
.env
storage/backups/
+5 -1
View File
@@ -10,10 +10,12 @@
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
"@mmapp/contracts": "1.0.0",
"acme-client": "^5.4.0",
@@ -21,6 +23,7 @@
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"pino-pretty": "^13.1.3",
"undici": "^8.1.0",
"zod": "^4.4.1"
@@ -29,6 +32,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3",
"drizzle-kit": "^0.31.10",
"jose": "^6.2.11",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
+46 -2
View File
@@ -3,17 +3,61 @@ import { z } from "zod"
config()
function boolEnv(v: string | undefined, fallback: boolean): boolean {
if (v === undefined || v === "") return fallback
return v === "1" || v.toLowerCase() === "true"
}
const isProd = process.env.NODE_ENV === "production"
const envSchema = z.object({
DATABASE_PATH: z.string().default("./mikrotik.db"),
PORT: z.coerce.number().int().positive().default(8000),
CORS_ORIGIN: z.string().default("http://localhost:3000"),
AUTH_REQUIRED: z.boolean().default(false),
AUTH_JWT_SECRET: z.string().default(""),
AUTH_ISSUER: z.string().default("https://auth.shnt.top"),
AUTH_PORTAL_URL: z.string().default("http://localhost:5175"),
})
const parsed = envSchema.safeParse(process.env)
const raw = {
DATABASE_PATH: process.env.DATABASE_PATH,
PORT: process.env.PORT,
CORS_ORIGIN: process.env.CORS_ORIGIN,
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
AUTH_JWT_SECRET:
process.env.AUTH_JWT_SECRET?.trim() ||
process.env.JWT_SECRET?.trim() ||
(isProd ? "" : "dev-secret-change-me"),
AUTH_ISSUER:
process.env.AUTH_ISSUER?.trim() ||
process.env.ISSUER?.trim() ||
"https://auth.shnt.top",
AUTH_PORTAL_URL: (
process.env.AUTH_PORTAL_URL ??
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL ??
"http://localhost:5175"
).replace(/\/$/, ""),
}
const parsed = envSchema.safeParse(raw)
if (!parsed.success) {
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
process.exit(1)
}
export const env = parsed.data
if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
console.error("❌ AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
process.exit(1)
}
export const env = {
DATABASE_PATH: parsed.data.DATABASE_PATH,
PORT: parsed.data.PORT,
CORS_ORIGIN: parsed.data.CORS_ORIGIN,
authRequired: parsed.data.AUTH_REQUIRED,
jwtSecret: parsed.data.AUTH_JWT_SECRET || "dev-secret-change-me",
authIssuer: parsed.data.AUTH_ISSUER,
authPortalUrl: parsed.data.AUTH_PORTAL_URL,
}
+96
View File
@@ -1,4 +1,6 @@
import Database from "better-sqlite3"
import { existsSync, readFileSync } from "node:fs"
import path from "node:path"
type SqliteHandle = InstanceType<typeof Database>
import { drizzle } from "drizzle-orm/better-sqlite3"
@@ -351,6 +353,7 @@ CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'queued',
step TEXT NOT NULL DEFAULT 'queued',
source TEXT NOT NULL DEFAULT 'manual',
server_id TEXT NOT NULL,
cert_name TEXT NOT NULL,
domain_names TEXT NOT NULL,
@@ -362,6 +365,47 @@ CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
error TEXT
);
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 21600,
renew_before_days INTEGER NOT NULL DEFAULT 30,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
frequency TEXT NOT NULL DEFAULT 'daily',
hour INTEGER NOT NULL DEFAULT 3,
minute INTEGER NOT NULL DEFAULT 0,
week_day INTEGER NOT NULL DEFAULT 0,
month_day INTEGER NOT NULL DEFAULT 1,
keep_count INTEGER NOT NULL DEFAULT 7,
format TEXT NOT NULL DEFAULT 'rsc',
server_ids_json TEXT NOT NULL DEFAULT '[]',
last_run_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS backup_entries (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
server_name TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'manual',
notes TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
CREATE TABLE IF NOT EXISTS alert_rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -617,12 +661,64 @@ SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
`)
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
if (!certIssueJobCols.some((c) => c.name === "source")) {
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
}
sqlite.exec(`
INSERT INTO certificate_renew_settings (id, enabled, interval_sec, renew_before_days)
SELECT 1, 1, 21600, 30
WHERE NOT EXISTS (SELECT 1 FROM certificate_renew_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO backup_schedule_settings (id, enabled, frequency, hour, minute, week_day, month_day, keep_count, format, server_ids_json)
SELECT 1, 1, 'daily', 3, 0, 0, 1, 7, 'rsc', '[]'
WHERE NOT EXISTS (SELECT 1 FROM backup_schedule_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO alert_engine_cursor (id, last_source_finished_at)
SELECT 1, NULL
WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1);
`)
const backupEntryCount = sqlite.prepare(`SELECT COUNT(*) AS c FROM backup_entries`).get() as { c: number }
if (backupEntryCount.c === 0) {
const legacyIndexPath = path.resolve(process.cwd(), "storage", "backups", "index.json")
if (existsSync(legacyIndexPath)) {
try {
const parsed = JSON.parse(readFileSync(legacyIndexPath, "utf8")) as unknown
if (Array.isArray(parsed)) {
const insert = sqlite.prepare(`
INSERT OR IGNORE INTO backup_entries (id, server_id, server_name, filename, size_bytes, kind, notes, created_at)
VALUES (@id, @serverId, @serverName, @filename, @sizeBytes, @kind, @notes, @createdAt)
`)
for (const row of parsed) {
if (!row || typeof row !== "object") continue
const item = row as Record<string, unknown>
const id = String(item.id ?? "").trim()
const filename = String(item.filename ?? "").trim()
if (!id || !filename) continue
insert.run({
id,
serverId: String(item.serverId ?? ""),
serverName: String(item.serverName ?? ""),
filename,
sizeBytes: Number(item.sizeBytes ?? 0) || 0,
kind: item.kind === "auto" ? "auto" : "manual",
notes: item.notes == null ? null : String(item.notes),
createdAt: String(item.createdAt ?? new Date().toISOString()),
})
}
}
} catch {
/* legacy index.json не читается — пропускаем */
}
}
}
export const db = drizzle(sqlite, { schema })
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
+43
View File
@@ -300,6 +300,7 @@ export const certificateIssueJobs = sqliteTable("certificate_issue_jobs", {
id: text("id").primaryKey(),
status: text("status", { enum: ["queued", "running", "done", "failed"] }).notNull().default("queued"),
step: text("step").notNull().default("queued"),
source: text("source", { enum: ["manual", "scheduler"] }).notNull().default("manual"),
serverId: text("server_id").notNull(),
certName: text("cert_name").notNull(),
domainNames: text("domain_names").notNull(),
@@ -311,6 +312,45 @@ export const certificateIssueJobs = sqliteTable("certificate_issue_jobs", {
error: text("error"),
})
export const certificateRenewSettings = sqliteTable("certificate_renew_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(21600),
renewBeforeDays: integer("renew_before_days").notNull().default(30),
lastCollectedAt: text("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const backupScheduleSettings = sqliteTable("backup_schedule_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
frequency: text("frequency", { enum: ["daily", "weekly", "monthly"] }).notNull().default("daily"),
hour: integer("hour").notNull().default(3),
minute: integer("minute").notNull().default(0),
weekDay: integer("week_day").notNull().default(0),
monthDay: integer("month_day").notNull().default(1),
keepCount: integer("keep_count").notNull().default(7),
format: text("format", { enum: ["rsc", "backup"] }).notNull().default("rsc"),
serverIdsJson: text("server_ids_json").notNull().default("[]"),
lastRunAt: text("last_run_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const backupEntries = sqliteTable("backup_entries", {
id: text("id").primaryKey(),
serverId: text("server_id").notNull(),
serverName: text("server_name").notNull(),
filename: text("filename").notNull(),
sizeBytes: integer("size_bytes").notNull(),
kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"),
notes: text("notes"),
createdAt: text("created_at").notNull(),
})
/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */
export const alertGroups = sqliteTable("alert_groups", {
id: text("id").primaryKey(),
@@ -531,6 +571,9 @@ export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
export type AlertTelegramSettingsRow = typeof alertTelegramSettings.$inferSelect
export type AcmeSettingsRow = typeof acmeSettings.$inferSelect
export type CertificateIssueJobRow = typeof certificateIssueJobs.$inferSelect
export type CertificateRenewSettingsRow = typeof certificateRenewSettings.$inferSelect
export type BackupScheduleSettingsRow = typeof backupScheduleSettings.$inferSelect
export type BackupEntryRow = typeof backupEntries.$inferSelect
export type AlertGroupRow = typeof alertGroups.$inferSelect
export type AlertRuleRow = typeof alertRules.$inferSelect
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
+97 -60
View File
@@ -1,11 +1,12 @@
import Fastify from "fastify"
import Fastify, { type FastifyInstance } from "fastify"
import cors from "@fastify/cors"
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
import { env } from "./config.js"
import authPlugin, { requireAuth } from "./plugins/auth.js"
import serversRoutes from "./routes/servers.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
import execRoutes from "./routes/exec.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
import execRoutes from "./routes/exec.js"
import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js"
@@ -24,69 +25,105 @@ import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
// ── app factory ────────────────────────────────────────────────────────────────
export async function buildApp(opts?: {
logger?: boolean
startScheduler?: boolean
}): Promise<FastifyInstance> {
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger:
opts?.logger === false
? false
: {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
},
},
},
})
const app = Fastify({
logger: {
transport: {
target: "pino-pretty",
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
},
},
})
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
// Use Zod for request validation and response serialization
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
await app.register(cors, {
origin: env.CORS_ORIGIN,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
// CORS — allow Next.js frontend
await app.register(cors, {
origin: env.CORS_ORIGIN,
/** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
await app.register(authPlugin)
// ── routes ─────────────────────────────────────────────────────────────────────
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
}))
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
}))
app.get("/api/auth/config", async () => ({
required: env.authRequired,
portal_url: env.authPortalUrl,
issuer: env.authIssuer,
}))
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(internetPathRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
await app.register(certificatesRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
if (env.authRequired) {
app.addHook("preHandler", async (request, reply) => {
const pathname = request.url.split("?")[0] ?? request.url
if (!pathname.startsWith("/api/")) return
if (pathname === "/api/auth/config") return
await requireAuth(request, reply)
if (reply.sent) return
})
}
refreshScheduler()
app.addHook("onClose", async () => {
stopScheduler()
})
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(internetPathRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
await app.register(certificatesRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
// ── start ──────────────────────────────────────────────────────────────────────
if (opts?.startScheduler !== false) {
refreshScheduler()
app.addHook("onClose", async () => {
stopScheduler()
})
}
try {
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
app.log.error(err)
process.exit(1)
return app
}
const isMain =
process.argv[1] &&
(process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js"))
if (isMain) {
try {
const app = await buildApp()
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
console.error(err)
process.exit(1)
}
}
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict"
import { hasPermission, permissionForRequest } from "./permissions.js"
assert.equal(hasPermission(["mm:servers:write"], "mm:servers:read"), true)
assert.equal(hasPermission(["mm:servers:admin"], "mm:servers:write"), true)
assert.equal(hasPermission(["mm:servers:read"], "mm:servers:write"), false)
assert.equal(
permissionForRequest("GET", "/api/servers"),
"mm:servers:read",
)
assert.equal(
permissionForRequest("POST", "/api/servers"),
"mm:servers:write",
)
assert.equal(
permissionForRequest("GET", "/api/system/database/backup"),
"mm:settings:admin",
)
assert.equal(
permissionForRequest("GET", "/api/unknown-thing"),
"mm:dashboard:read",
)
console.log("permissions.test.ts: ok")
+173
View File
@@ -0,0 +1,173 @@
/**
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
* Format: mm:<section>:<read|write|admin>
*/
export type AuthUser = {
id: string
email: string
name: string
apps: string[]
permissions: string[]
isAdmin?: boolean
}
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
const parts = required.split(":")
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === "read") {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === "write") {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
type Rule = {
methods: string[]
match: (path: string) => boolean
permission: string
}
const RULES: Rule[] = [
{
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/system") ||
p.startsWith("/api/scheduler") ||
p.startsWith("/api/evobgp"),
permission: "mm:settings:admin",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
permission: "mm:dashboard:read",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/servers"),
permission: "mm:servers:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/servers"),
permission: "mm:servers:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/filters"),
permission: "mm:filters:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/filters"),
permission: "mm:filters:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/bgp"),
permission: "mm:bgp:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/bgp"),
permission: "mm:bgp:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/uptime"),
permission: "mm:uptime:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/uptime"),
permission: "mm:uptime:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/traffic"),
permission: "mm:traffic:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/traffic"),
permission: "mm:traffic:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/alerts"),
permission: "mm:alerts:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/alerts"),
permission: "mm:alerts:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/backups"),
permission: "mm:backups:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/backups"),
permission: "mm:backups:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/certificates"),
permission: "mm:certificates:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/certificates"),
permission: "mm:certificates:write",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/network") ||
p.startsWith("/api/ospf") ||
p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec"),
permission: "mm:network:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/network") ||
p.startsWith("/api/ospf") ||
p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec"),
permission: "mm:network:write",
},
]
/** Resolve required permission for method+path, or null if public / unknown. */
export function permissionForRequest(
method: string,
path: string,
): string | null {
const m = method.toUpperCase()
const pathname = path.split("?")[0] ?? path
for (const rule of RULES) {
if (!rule.methods.includes(m)) continue
if (rule.match(pathname)) return rule.permission
}
if (pathname.startsWith("/api/")) return "mm:dashboard:read"
return null
}

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