Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15ad53af1f | ||
|
|
883842636b | ||
|
|
b9f430de16 | ||
|
|
25e040a5dd | ||
|
|
f2df990746 | ||
|
|
77bc174e43 | ||
|
|
0e9349e508 | ||
|
|
0208aa4d7c | ||
|
|
42a2e18047 | ||
|
|
9df3971f6c | ||
|
|
7ed269cf2c | ||
|
|
7f81c304ae | ||
|
|
5f29cfaf0f | ||
|
|
d2de8d3188 | ||
|
|
d3a2d38b37 | ||
|
|
a1a9124f3d | ||
|
|
009011a917 | ||
|
|
399871f4f9 | ||
|
|
158fc36294 |
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
|
||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||
|
||||
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||
|
||||
## The core loop (MCP-native)
|
||||
|
||||
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||
|
||||
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||
|
||||
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||
|
||||
## Commands
|
||||
|
||||
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||
|
||||
| Command | Invoke | Does |
|
||||
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||
|
||||
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||
|
||||
## When to reach for ReUI vs plain shadcn
|
||||
|
||||
| Need | Reach for |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||
| A drag-and-drop board | the **kanban** component |
|
||||
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||
|
||||
## Detailed references
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||
@@ -0,0 +1,43 @@
|
||||
# Adapting installed ReUI code (reuse-first, no AI slop)
|
||||
|
||||
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
|
||||
|
||||
## Preserve the design - don't over-customize
|
||||
|
||||
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
|
||||
|
||||
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
|
||||
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
|
||||
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
|
||||
|
||||
## Reuse the parts: examples and the block's own elements
|
||||
|
||||
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
|
||||
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
|
||||
|
||||
## Don't invent (read, don't guess)
|
||||
|
||||
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
|
||||
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
|
||||
|
||||
## What to change vs leave alone
|
||||
|
||||
- **Change:** the item's own data, copy, props, and layout to fit the app.
|
||||
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
|
||||
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
|
||||
|
||||
## Demo data -> real data
|
||||
|
||||
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
|
||||
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
|
||||
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
|
||||
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
|
||||
|
||||
## Believable content (no AI tells)
|
||||
|
||||
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
|
||||
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
|
||||
|
||||
## Operational surfaces (settings / profile / admin)
|
||||
|
||||
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||
@@ -0,0 +1,60 @@
|
||||
# CLI: registry setup, license, non-interactive install
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
```
|
||||
|
||||
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
|
||||
|
||||
1. Add the key to `.env.local`:
|
||||
|
||||
```bash
|
||||
REUI_LICENSE_KEY=your-license-key
|
||||
```
|
||||
|
||||
2. Switch `components.json` to the authenticated object form:
|
||||
|
||||
```json
|
||||
{
|
||||
"registries": {
|
||||
"@reui": {
|
||||
"url": "https://reui.io/r/{style}/{name}.json",
|
||||
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
|
||||
Use the project's package runner (check `packageManager`):
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @reui/<name> --yes # npm
|
||||
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
|
||||
bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
```
|
||||
|
||||
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
|
||||
|
||||
## Handling prompts and conflicts
|
||||
|
||||
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
|
||||
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
|
||||
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
|
||||
- Run from the **project root** so `components.json` and `.env.local` are found.
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
@@ -0,0 +1,408 @@
|
||||
# ReUI components
|
||||
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
## data-grid (the flagship - read its API every time)
|
||||
|
||||
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
|
||||
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||
|
||||
```tsx
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns,
|
||||
})
|
||||
|
||||
<DataGrid table={table} recordCount={data.length}>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
```
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||
|
||||
## event-calendar
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
|
||||
<KanbanBoard>
|
||||
{Object.entries(cols).map(([id, items]) => (
|
||||
<KanbanColumn key={id} value={id}>
|
||||
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||
<KanbanColumnContent value={id}>
|
||||
{items.map((i) => (
|
||||
<KanbanItem key={i.id} value={i.id}>
|
||||
<KanbanItemHandle>{i.title}</KanbanItemHandle>
|
||||
</KanbanItem>
|
||||
))}
|
||||
</KanbanColumnContent>
|
||||
</KanbanColumn>
|
||||
))}
|
||||
</KanbanBoard>
|
||||
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
|
||||
</Kanban>
|
||||
```
|
||||
|
||||
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
|
||||
|
||||
## sortable
|
||||
|
||||
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
|
||||
{items.map((i) => (
|
||||
<SortableItem key={i.id} value={i.id}>
|
||||
<SortableItemHandle><GripVertical /></SortableItemHandle>
|
||||
{i.label}
|
||||
</SortableItem>
|
||||
))}
|
||||
</Sortable>
|
||||
```
|
||||
|
||||
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
**Required:** none, but wire `onChange` to capture the value.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||
|
||||
<DateSelector value={value} onChange={setValue} label="Due date" />
|
||||
```
|
||||
|
||||
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
|
||||
|
||||
## tree
|
||||
|
||||
**Required:** `tree` (a `@headless-tree/core` instance you construct)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Tree tree={tree}>
|
||||
{tree.getItems().map((item) => (
|
||||
<TreeItem key={item.getId()} item={item}>
|
||||
<TreeItemLabel />
|
||||
</TreeItem>
|
||||
))}
|
||||
</Tree>
|
||||
```
|
||||
|
||||
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
|
||||
|
||||
## stepper
|
||||
|
||||
**Required:** `StepperItem step` (number), `StepperContent value` (number)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Stepper defaultValue={1}>
|
||||
<StepperNav>
|
||||
<StepperItem step={1}>
|
||||
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
|
||||
<StepperSeparator />
|
||||
</StepperItem>
|
||||
<StepperItem step={2}>
|
||||
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
|
||||
</StepperItem>
|
||||
</StepperNav>
|
||||
<StepperPanel>
|
||||
<StepperContent value={1}>Step 1 content</StepperContent>
|
||||
<StepperContent value={2}>Step 2 content</StepperContent>
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
```
|
||||
|
||||
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
|
||||
|
||||
## timeline
|
||||
|
||||
**Required:** `TimelineItem step` (number)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Timeline>
|
||||
<TimelineItem step={1}>
|
||||
<TimelineHeader>
|
||||
<TimelineDate>March 2024</TimelineDate>
|
||||
<TimelineTitle>Project initialized</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineIndicator />
|
||||
<TimelineSeparator />
|
||||
<TimelineContent>Repo and architecture set up.</TimelineContent>
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
```
|
||||
|
||||
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
|
||||
|
||||
## autocomplete
|
||||
|
||||
**Required:** `items` (array; each item has at least `value`)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Autocomplete items={items}>
|
||||
<AutocompleteInput placeholder="Search..." />
|
||||
<AutocompleteContent>
|
||||
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
|
||||
<AutocompleteList>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
```
|
||||
|
||||
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
|
||||
|
||||
## phone-input
|
||||
|
||||
**Required:** none, but wire `onChange`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
|
||||
```
|
||||
|
||||
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
|
||||
|
||||
## number-field
|
||||
|
||||
**Required:** wrap the controls in `NumberFieldGroup`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<NumberField defaultValue={0}>
|
||||
<NumberFieldScrubArea label="Quantity" />
|
||||
<NumberFieldGroup>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
```
|
||||
|
||||
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
|
||||
|
||||
## rating
|
||||
|
||||
**Required:** `rating` (number)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
|
||||
```
|
||||
|
||||
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
|
||||
|
||||
## scrollspy
|
||||
|
||||
**Required:** `targetRef` (the scroll container ref)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Scrollspy targetRef={containerRef}>
|
||||
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
|
||||
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
|
||||
</Scrollspy>
|
||||
<div ref={containerRef}>
|
||||
<div id="s1">...</div>
|
||||
<div id="s2">...</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
|
||||
|
||||
## frame
|
||||
|
||||
**Required:** `Frame` > `FramePanel`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Frame>
|
||||
<FramePanel>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Title</FrameTitle>
|
||||
<FrameDescription>Description</FrameDescription>
|
||||
</FrameHeader>
|
||||
<div className="p-5">Content</div>
|
||||
<FrameFooter>Footer</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
```
|
||||
|
||||
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||
|
||||
## icon-stack
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconStack aria-hidden="true">
|
||||
<InboxIcon className="size-4" />
|
||||
</IconStack>
|
||||
```
|
||||
|
||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||
|
||||
## icon-tile
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconTile variant="elevated" size="lg">
|
||||
<PackageIcon />
|
||||
</IconTile>
|
||||
```
|
||||
|
||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Alert variant="success">
|
||||
<ShieldCheckIcon />
|
||||
<AlertTitle>Security update</AlertTitle>
|
||||
<AlertDescription>Enable two-factor authentication.</AlertDescription>
|
||||
<AlertAction><Button size="xs">Update</Button></AlertAction>
|
||||
</Alert>
|
||||
```
|
||||
|
||||
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
|
||||
|
||||
## badge
|
||||
|
||||
**Required:** none (text child).
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Badge variant="success-light" size="sm">Success</Badge>
|
||||
<Badge variant="outline" radius="full">Pill</Badge>
|
||||
```
|
||||
|
||||
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||
|
||||
## base vs radix - write for the project's base
|
||||
|
||||
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||
|
||||
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||
|
||||
- `"style": "base-nova"` -> **Base UI**
|
||||
- `"style": "radix-nova"` -> **Radix UI**
|
||||
|
||||
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||
|
||||
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
|
||||
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
|
||||
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
|
||||
|
||||
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Craft: make ReUI UI exceptional, not generic
|
||||
|
||||
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||
|
||||
## Have a point of view
|
||||
|
||||
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
|
||||
|
||||
## Brutally clear hierarchy
|
||||
|
||||
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||
|
||||
## Spacing rhythm and deliberate density
|
||||
|
||||
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||
|
||||
## Cover the real states (the usual miss)
|
||||
|
||||
A surface isn't done at the happy path. Compose, and wire:
|
||||
|
||||
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||
|
||||
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||
|
||||
## Responsive by default
|
||||
|
||||
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||
|
||||
## Motion, subtly
|
||||
|
||||
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||
|
||||
## Real, activated content
|
||||
|
||||
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||
|
||||
## Avoid the AI tells
|
||||
|
||||
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||
|
||||
## The bar
|
||||
|
||||
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Icons (ReUI delta over shadcn)
|
||||
|
||||
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||
|
||||
## Portable icons (library-agnostic)
|
||||
|
||||
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||
|
||||
- `lucide` -> `lucide-react`
|
||||
- `tabler` -> `@tabler/icons-react`
|
||||
- `phosphor` -> `@phosphor-icons/react`
|
||||
- `remix` -> `@remixicon/react`
|
||||
- `hugeicons` -> `@hugeicons/react`
|
||||
|
||||
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||
|
||||
## Keep icons purposeful
|
||||
|
||||
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
|
||||
|
||||
## Motion Icons (the `@reui/icons/...` set)
|
||||
|
||||
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||
```
|
||||
|
||||
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||
|
||||
Finding icons:
|
||||
|
||||
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
|
||||
- One icon: `search` with `type: "icon"`.
|
||||
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||
|
||||
The `icon-stack` component composes multiple icons into a stacked display.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Quality gates (security, accessibility, scroll)
|
||||
|
||||
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||
|
||||
## Security
|
||||
|
||||
- Never `dangerouslySetInnerHTML`. Render data as text/components.
|
||||
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
|
||||
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
|
||||
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
|
||||
- Every non-submit button is `type="button"`.
|
||||
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||
|
||||
## Scroll mechanics
|
||||
|
||||
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
|
||||
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
|
||||
@@ -0,0 +1,35 @@
|
||||
# ReUI registry structure
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
|
||||
## The @reui registry
|
||||
|
||||
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
```
|
||||
|
||||
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
|
||||
|
||||
## Know your base: base or radix
|
||||
|
||||
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
|
||||
|
||||
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
|
||||
|
||||
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Styling (ReUI delta over shadcn)
|
||||
|
||||
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||
|
||||
## ReUI extended semantic tokens
|
||||
|
||||
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||
|
||||
- `--success` / `--success-foreground`
|
||||
- `--info` / `--info-foreground`
|
||||
- `--warning` / `--warning-foreground`
|
||||
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||
|
||||
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||
|
||||
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||
|
||||
## Blocks follow your theme
|
||||
|
||||
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||
|
||||
## Density and typography rhythm
|
||||
|
||||
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Workflow: find -> install -> read API -> adapt
|
||||
|
||||
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
|
||||
|
||||
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||
|
||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||
|
||||
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||
|
||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||
|
||||
## 2. Install (shadcn CLI)
|
||||
|
||||
Run the result's `install` command from the project root, non-interactively:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @reui/<name> --yes
|
||||
```
|
||||
|
||||
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
|
||||
|
||||
## 3. Read the API (do not guess props)
|
||||
|
||||
Before writing code against any component an item uses:
|
||||
|
||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||
|
||||
## 4. Adapt (reuse-first) - do not skip
|
||||
|
||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
|
||||
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
|
||||
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
|
||||
8. **Typecheck / lint**.
|
||||
|
||||
## If no single block fits
|
||||
|
||||
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ReUI MCP: full reference
|
||||
|
||||
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
|
||||
|
||||
## Golden path (token-optimal - follow this order)
|
||||
|
||||
Most tasks need 2-4 MCP calls and ZERO web fetches:
|
||||
|
||||
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
|
||||
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
|
||||
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
|
||||
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
|
||||
5. **`get_audit_checklist()`** before declaring done.
|
||||
|
||||
If you already know the exact item name, skip `search`. Everything else is situational.
|
||||
|
||||
## The 5 task-specific tools (when to reach for each)
|
||||
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||
|
||||
## All 19 tools
|
||||
|
||||
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||
|
||||
## Token + speed rules
|
||||
|
||||
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
|
||||
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
|
||||
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
|
||||
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
|
||||
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
|
||||
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
|
||||
|
||||
## Result shapes (so you don't re-fetch)
|
||||
|
||||
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
|
||||
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||
|
||||
## Error playbook
|
||||
|
||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||
|
||||
## Fallbacks
|
||||
|
||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
|
||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||
|
||||
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||
|
||||
## The core loop (MCP-native)
|
||||
|
||||
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||
|
||||
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||
|
||||
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||
|
||||
## Commands
|
||||
|
||||
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||
|
||||
| Command | Invoke | Does |
|
||||
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||
|
||||
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||
|
||||
## When to reach for ReUI vs plain shadcn
|
||||
|
||||
| Need | Reach for |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||
| A drag-and-drop board | the **kanban** component |
|
||||
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||
|
||||
## Detailed references
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||
@@ -0,0 +1,43 @@
|
||||
# Adapting installed ReUI code (reuse-first, no AI slop)
|
||||
|
||||
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
|
||||
|
||||
## Preserve the design - don't over-customize
|
||||
|
||||
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
|
||||
|
||||
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
|
||||
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
|
||||
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
|
||||
|
||||
## Reuse the parts: examples and the block's own elements
|
||||
|
||||
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
|
||||
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
|
||||
|
||||
## Don't invent (read, don't guess)
|
||||
|
||||
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
|
||||
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
|
||||
|
||||
## What to change vs leave alone
|
||||
|
||||
- **Change:** the item's own data, copy, props, and layout to fit the app.
|
||||
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
|
||||
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
|
||||
|
||||
## Demo data -> real data
|
||||
|
||||
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
|
||||
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
|
||||
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
|
||||
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
|
||||
|
||||
## Believable content (no AI tells)
|
||||
|
||||
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
|
||||
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
|
||||
|
||||
## Operational surfaces (settings / profile / admin)
|
||||
|
||||
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||
@@ -0,0 +1,60 @@
|
||||
# CLI: registry setup, license, non-interactive install
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
```
|
||||
|
||||
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
|
||||
|
||||
1. Add the key to `.env.local`:
|
||||
|
||||
```bash
|
||||
REUI_LICENSE_KEY=your-license-key
|
||||
```
|
||||
|
||||
2. Switch `components.json` to the authenticated object form:
|
||||
|
||||
```json
|
||||
{
|
||||
"registries": {
|
||||
"@reui": {
|
||||
"url": "https://reui.io/r/{style}/{name}.json",
|
||||
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
|
||||
Use the project's package runner (check `packageManager`):
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @reui/<name> --yes # npm
|
||||
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
|
||||
bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
```
|
||||
|
||||
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
|
||||
|
||||
## Handling prompts and conflicts
|
||||
|
||||
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
|
||||
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
|
||||
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
|
||||
- Run from the **project root** so `components.json` and `.env.local` are found.
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
@@ -0,0 +1,408 @@
|
||||
# ReUI components
|
||||
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
## data-grid (the flagship - read its API every time)
|
||||
|
||||
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
|
||||
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||
|
||||
```tsx
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns,
|
||||
})
|
||||
|
||||
<DataGrid table={table} recordCount={data.length}>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
```
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||
|
||||
## event-calendar
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
|
||||
<KanbanBoard>
|
||||
{Object.entries(cols).map(([id, items]) => (
|
||||
<KanbanColumn key={id} value={id}>
|
||||
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||
<KanbanColumnContent value={id}>
|
||||
{items.map((i) => (
|
||||
<KanbanItem key={i.id} value={i.id}>
|
||||
<KanbanItemHandle>{i.title}</KanbanItemHandle>
|
||||
</KanbanItem>
|
||||
))}
|
||||
</KanbanColumnContent>
|
||||
</KanbanColumn>
|
||||
))}
|
||||
</KanbanBoard>
|
||||
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
|
||||
</Kanban>
|
||||
```
|
||||
|
||||
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
|
||||
|
||||
## sortable
|
||||
|
||||
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
|
||||
{items.map((i) => (
|
||||
<SortableItem key={i.id} value={i.id}>
|
||||
<SortableItemHandle><GripVertical /></SortableItemHandle>
|
||||
{i.label}
|
||||
</SortableItem>
|
||||
))}
|
||||
</Sortable>
|
||||
```
|
||||
|
||||
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
**Required:** none, but wire `onChange` to capture the value.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||
|
||||
<DateSelector value={value} onChange={setValue} label="Due date" />
|
||||
```
|
||||
|
||||
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
|
||||
|
||||
## tree
|
||||
|
||||
**Required:** `tree` (a `@headless-tree/core` instance you construct)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Tree tree={tree}>
|
||||
{tree.getItems().map((item) => (
|
||||
<TreeItem key={item.getId()} item={item}>
|
||||
<TreeItemLabel />
|
||||
</TreeItem>
|
||||
))}
|
||||
</Tree>
|
||||
```
|
||||
|
||||
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
|
||||
|
||||
## stepper
|
||||
|
||||
**Required:** `StepperItem step` (number), `StepperContent value` (number)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Stepper defaultValue={1}>
|
||||
<StepperNav>
|
||||
<StepperItem step={1}>
|
||||
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
|
||||
<StepperSeparator />
|
||||
</StepperItem>
|
||||
<StepperItem step={2}>
|
||||
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
|
||||
</StepperItem>
|
||||
</StepperNav>
|
||||
<StepperPanel>
|
||||
<StepperContent value={1}>Step 1 content</StepperContent>
|
||||
<StepperContent value={2}>Step 2 content</StepperContent>
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
```
|
||||
|
||||
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
|
||||
|
||||
## timeline
|
||||
|
||||
**Required:** `TimelineItem step` (number)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Timeline>
|
||||
<TimelineItem step={1}>
|
||||
<TimelineHeader>
|
||||
<TimelineDate>March 2024</TimelineDate>
|
||||
<TimelineTitle>Project initialized</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineIndicator />
|
||||
<TimelineSeparator />
|
||||
<TimelineContent>Repo and architecture set up.</TimelineContent>
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
```
|
||||
|
||||
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
|
||||
|
||||
## autocomplete
|
||||
|
||||
**Required:** `items` (array; each item has at least `value`)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Autocomplete items={items}>
|
||||
<AutocompleteInput placeholder="Search..." />
|
||||
<AutocompleteContent>
|
||||
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
|
||||
<AutocompleteList>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
```
|
||||
|
||||
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
|
||||
|
||||
## phone-input
|
||||
|
||||
**Required:** none, but wire `onChange`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
|
||||
```
|
||||
|
||||
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
|
||||
|
||||
## number-field
|
||||
|
||||
**Required:** wrap the controls in `NumberFieldGroup`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<NumberField defaultValue={0}>
|
||||
<NumberFieldScrubArea label="Quantity" />
|
||||
<NumberFieldGroup>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
```
|
||||
|
||||
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
|
||||
|
||||
## rating
|
||||
|
||||
**Required:** `rating` (number)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
|
||||
```
|
||||
|
||||
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
|
||||
|
||||
## scrollspy
|
||||
|
||||
**Required:** `targetRef` (the scroll container ref)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Scrollspy targetRef={containerRef}>
|
||||
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
|
||||
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
|
||||
</Scrollspy>
|
||||
<div ref={containerRef}>
|
||||
<div id="s1">...</div>
|
||||
<div id="s2">...</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
|
||||
|
||||
## frame
|
||||
|
||||
**Required:** `Frame` > `FramePanel`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Frame>
|
||||
<FramePanel>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Title</FrameTitle>
|
||||
<FrameDescription>Description</FrameDescription>
|
||||
</FrameHeader>
|
||||
<div className="p-5">Content</div>
|
||||
<FrameFooter>Footer</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
```
|
||||
|
||||
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||
|
||||
## icon-stack
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconStack aria-hidden="true">
|
||||
<InboxIcon className="size-4" />
|
||||
</IconStack>
|
||||
```
|
||||
|
||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||
|
||||
## icon-tile
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconTile variant="elevated" size="lg">
|
||||
<PackageIcon />
|
||||
</IconTile>
|
||||
```
|
||||
|
||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Alert variant="success">
|
||||
<ShieldCheckIcon />
|
||||
<AlertTitle>Security update</AlertTitle>
|
||||
<AlertDescription>Enable two-factor authentication.</AlertDescription>
|
||||
<AlertAction><Button size="xs">Update</Button></AlertAction>
|
||||
</Alert>
|
||||
```
|
||||
|
||||
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
|
||||
|
||||
## badge
|
||||
|
||||
**Required:** none (text child).
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Badge variant="success-light" size="sm">Success</Badge>
|
||||
<Badge variant="outline" radius="full">Pill</Badge>
|
||||
```
|
||||
|
||||
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||
|
||||
## base vs radix - write for the project's base
|
||||
|
||||
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||
|
||||
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||
|
||||
- `"style": "base-nova"` -> **Base UI**
|
||||
- `"style": "radix-nova"` -> **Radix UI**
|
||||
|
||||
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||
|
||||
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
|
||||
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
|
||||
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
|
||||
|
||||
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Craft: make ReUI UI exceptional, not generic
|
||||
|
||||
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||
|
||||
## Have a point of view
|
||||
|
||||
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
|
||||
|
||||
## Brutally clear hierarchy
|
||||
|
||||
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||
|
||||
## Spacing rhythm and deliberate density
|
||||
|
||||
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||
|
||||
## Cover the real states (the usual miss)
|
||||
|
||||
A surface isn't done at the happy path. Compose, and wire:
|
||||
|
||||
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||
|
||||
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||
|
||||
## Responsive by default
|
||||
|
||||
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||
|
||||
## Motion, subtly
|
||||
|
||||
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||
|
||||
## Real, activated content
|
||||
|
||||
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||
|
||||
## Avoid the AI tells
|
||||
|
||||
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||
|
||||
## The bar
|
||||
|
||||
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Icons (ReUI delta over shadcn)
|
||||
|
||||
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||
|
||||
## Portable icons (library-agnostic)
|
||||
|
||||
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||
|
||||
- `lucide` -> `lucide-react`
|
||||
- `tabler` -> `@tabler/icons-react`
|
||||
- `phosphor` -> `@phosphor-icons/react`
|
||||
- `remix` -> `@remixicon/react`
|
||||
- `hugeicons` -> `@hugeicons/react`
|
||||
|
||||
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||
|
||||
## Keep icons purposeful
|
||||
|
||||
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
|
||||
|
||||
## Motion Icons (the `@reui/icons/...` set)
|
||||
|
||||
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||
```
|
||||
|
||||
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||
|
||||
Finding icons:
|
||||
|
||||
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
|
||||
- One icon: `search` with `type: "icon"`.
|
||||
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||
|
||||
The `icon-stack` component composes multiple icons into a stacked display.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Quality gates (security, accessibility, scroll)
|
||||
|
||||
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||
|
||||
## Security
|
||||
|
||||
- Never `dangerouslySetInnerHTML`. Render data as text/components.
|
||||
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
|
||||
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
|
||||
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
|
||||
- Every non-submit button is `type="button"`.
|
||||
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||
|
||||
## Scroll mechanics
|
||||
|
||||
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
|
||||
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
|
||||
@@ -0,0 +1,35 @@
|
||||
# ReUI registry structure
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
|
||||
## The @reui registry
|
||||
|
||||
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
```
|
||||
|
||||
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
|
||||
|
||||
## Know your base: base or radix
|
||||
|
||||
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
|
||||
|
||||
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
|
||||
|
||||
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Styling (ReUI delta over shadcn)
|
||||
|
||||
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||
|
||||
## ReUI extended semantic tokens
|
||||
|
||||
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||
|
||||
- `--success` / `--success-foreground`
|
||||
- `--info` / `--info-foreground`
|
||||
- `--warning` / `--warning-foreground`
|
||||
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||
|
||||
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||
|
||||
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||
|
||||
## Blocks follow your theme
|
||||
|
||||
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||
|
||||
## Density and typography rhythm
|
||||
|
||||
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Workflow: find -> install -> read API -> adapt
|
||||
|
||||
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
|
||||
|
||||
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||
|
||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||
|
||||
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||
|
||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||
|
||||
## 2. Install (shadcn CLI)
|
||||
|
||||
Run the result's `install` command from the project root, non-interactively:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @reui/<name> --yes
|
||||
```
|
||||
|
||||
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
|
||||
|
||||
## 3. Read the API (do not guess props)
|
||||
|
||||
Before writing code against any component an item uses:
|
||||
|
||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||
|
||||
## 4. Adapt (reuse-first) - do not skip
|
||||
|
||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
|
||||
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
|
||||
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
|
||||
8. **Typecheck / lint**.
|
||||
|
||||
## If no single block fits
|
||||
|
||||
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ReUI MCP: full reference
|
||||
|
||||
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
|
||||
|
||||
## Golden path (token-optimal - follow this order)
|
||||
|
||||
Most tasks need 2-4 MCP calls and ZERO web fetches:
|
||||
|
||||
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
|
||||
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
|
||||
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
|
||||
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
|
||||
5. **`get_audit_checklist()`** before declaring done.
|
||||
|
||||
If you already know the exact item name, skip `search`. Everything else is situational.
|
||||
|
||||
## The 5 task-specific tools (when to reach for each)
|
||||
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||
|
||||
## All 19 tools
|
||||
|
||||
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||
|
||||
## Token + speed rules
|
||||
|
||||
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
|
||||
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
|
||||
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
|
||||
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
|
||||
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
|
||||
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
|
||||
|
||||
## Result shapes (so you don't re-fetch)
|
||||
|
||||
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
|
||||
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||
|
||||
## Error playbook
|
||||
|
||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||
|
||||
## Fallbacks
|
||||
|
||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||
@@ -0,0 +1,16 @@
|
||||
# CodeGraph data files
|
||||
# These are local to each machine and should not be committed
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Cache
|
||||
cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Hook markers
|
||||
.dirty
|
||||
@@ -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`, раздел «Запуск».
|
||||
|
||||
@@ -18,7 +18,7 @@ alwaysApply: false
|
||||
|
||||
## Эталон UI
|
||||
|
||||
- **`app/(main)/servers`** — главный эталон layout, сетки, типографики, композиции и взаимодействий. Не вводить новый UI-паттерн, если на `/servers` уже есть эквивалент.
|
||||
- **`app/(main)/servers`** — эталон **поведения** CRUD. Оболочка ops: **Frame + DataGrid**, не Card-shell. Не копировать `DataPageCard` на новые экраны.
|
||||
|
||||
## Качество и совместимость
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, готовый к продакшену код без ошибок сборки/рантайма по возможности.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
description: Use the ReUI registry (blocks, primitives, icons) correctly
|
||||
globs: ["**/*.tsx","**/*.ts"]
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
|
||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||
|
||||
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||
|
||||
## The core loop (MCP-native)
|
||||
|
||||
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||
|
||||
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||
|
||||
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||
|
||||
## Commands
|
||||
|
||||
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||
|
||||
| Command | Invoke | Does |
|
||||
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||
|
||||
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||
|
||||
## When to reach for ReUI vs plain shadcn
|
||||
|
||||
| Need | Reach for |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||
| A drag-and-drop board | the **kanban** component |
|
||||
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||
|
||||
## Detailed references
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
|
||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||
|
||||
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||
|
||||
## The core loop (MCP-native)
|
||||
|
||||
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||
|
||||
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||
|
||||
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||
|
||||
## Commands
|
||||
|
||||
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||
|
||||
| Command | Invoke | Does |
|
||||
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||
|
||||
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||
|
||||
## When to reach for ReUI vs plain shadcn
|
||||
|
||||
| Need | Reach for |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||
| A drag-and-drop board | the **kanban** component |
|
||||
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||
|
||||
## Detailed references
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||
@@ -0,0 +1,43 @@
|
||||
# Adapting installed ReUI code (reuse-first, no AI slop)
|
||||
|
||||
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
|
||||
|
||||
## Preserve the design - don't over-customize
|
||||
|
||||
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
|
||||
|
||||
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
|
||||
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
|
||||
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
|
||||
|
||||
## Reuse the parts: examples and the block's own elements
|
||||
|
||||
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
|
||||
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
|
||||
|
||||
## Don't invent (read, don't guess)
|
||||
|
||||
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
|
||||
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
|
||||
|
||||
## What to change vs leave alone
|
||||
|
||||
- **Change:** the item's own data, copy, props, and layout to fit the app.
|
||||
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
|
||||
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
|
||||
|
||||
## Demo data -> real data
|
||||
|
||||
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
|
||||
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
|
||||
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
|
||||
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
|
||||
|
||||
## Believable content (no AI tells)
|
||||
|
||||
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
|
||||
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
|
||||
|
||||
## Operational surfaces (settings / profile / admin)
|
||||
|
||||
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||
@@ -0,0 +1,60 @@
|
||||
# CLI: registry setup, license, non-interactive install
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
```
|
||||
|
||||
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
|
||||
|
||||
1. Add the key to `.env.local`:
|
||||
|
||||
```bash
|
||||
REUI_LICENSE_KEY=your-license-key
|
||||
```
|
||||
|
||||
2. Switch `components.json` to the authenticated object form:
|
||||
|
||||
```json
|
||||
{
|
||||
"registries": {
|
||||
"@reui": {
|
||||
"url": "https://reui.io/r/{style}/{name}.json",
|
||||
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
|
||||
Use the project's package runner (check `packageManager`):
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @reui/<name> --yes # npm
|
||||
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
|
||||
bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
```
|
||||
|
||||
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
|
||||
|
||||
## Handling prompts and conflicts
|
||||
|
||||
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
|
||||
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
|
||||
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
|
||||
- Run from the **project root** so `components.json` and `.env.local` are found.
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
@@ -0,0 +1,408 @@
|
||||
# ReUI components
|
||||
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
## data-grid (the flagship - read its API every time)
|
||||
|
||||
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
|
||||
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||
|
||||
```tsx
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns,
|
||||
})
|
||||
|
||||
<DataGrid table={table} recordCount={data.length}>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
```
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||
|
||||
## event-calendar
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
|
||||
<KanbanBoard>
|
||||
{Object.entries(cols).map(([id, items]) => (
|
||||
<KanbanColumn key={id} value={id}>
|
||||
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||
<KanbanColumnContent value={id}>
|
||||
{items.map((i) => (
|
||||
<KanbanItem key={i.id} value={i.id}>
|
||||
<KanbanItemHandle>{i.title}</KanbanItemHandle>
|
||||
</KanbanItem>
|
||||
))}
|
||||
</KanbanColumnContent>
|
||||
</KanbanColumn>
|
||||
))}
|
||||
</KanbanBoard>
|
||||
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
|
||||
</Kanban>
|
||||
```
|
||||
|
||||
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
|
||||
|
||||
## sortable
|
||||
|
||||
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
|
||||
{items.map((i) => (
|
||||
<SortableItem key={i.id} value={i.id}>
|
||||
<SortableItemHandle><GripVertical /></SortableItemHandle>
|
||||
{i.label}
|
||||
</SortableItem>
|
||||
))}
|
||||
</Sortable>
|
||||
```
|
||||
|
||||
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
**Required:** none, but wire `onChange` to capture the value.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||
|
||||
<DateSelector value={value} onChange={setValue} label="Due date" />
|
||||
```
|
||||
|
||||
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
|
||||
|
||||
## tree
|
||||
|
||||
**Required:** `tree` (a `@headless-tree/core` instance you construct)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Tree tree={tree}>
|
||||
{tree.getItems().map((item) => (
|
||||
<TreeItem key={item.getId()} item={item}>
|
||||
<TreeItemLabel />
|
||||
</TreeItem>
|
||||
))}
|
||||
</Tree>
|
||||
```
|
||||
|
||||
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
|
||||
|
||||
## stepper
|
||||
|
||||
**Required:** `StepperItem step` (number), `StepperContent value` (number)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Stepper defaultValue={1}>
|
||||
<StepperNav>
|
||||
<StepperItem step={1}>
|
||||
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
|
||||
<StepperSeparator />
|
||||
</StepperItem>
|
||||
<StepperItem step={2}>
|
||||
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
|
||||
</StepperItem>
|
||||
</StepperNav>
|
||||
<StepperPanel>
|
||||
<StepperContent value={1}>Step 1 content</StepperContent>
|
||||
<StepperContent value={2}>Step 2 content</StepperContent>
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
```
|
||||
|
||||
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
|
||||
|
||||
## timeline
|
||||
|
||||
**Required:** `TimelineItem step` (number)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Timeline>
|
||||
<TimelineItem step={1}>
|
||||
<TimelineHeader>
|
||||
<TimelineDate>March 2024</TimelineDate>
|
||||
<TimelineTitle>Project initialized</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineIndicator />
|
||||
<TimelineSeparator />
|
||||
<TimelineContent>Repo and architecture set up.</TimelineContent>
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
```
|
||||
|
||||
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
|
||||
|
||||
## autocomplete
|
||||
|
||||
**Required:** `items` (array; each item has at least `value`)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Autocomplete items={items}>
|
||||
<AutocompleteInput placeholder="Search..." />
|
||||
<AutocompleteContent>
|
||||
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
|
||||
<AutocompleteList>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
```
|
||||
|
||||
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
|
||||
|
||||
## phone-input
|
||||
|
||||
**Required:** none, but wire `onChange`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
|
||||
```
|
||||
|
||||
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
|
||||
|
||||
## number-field
|
||||
|
||||
**Required:** wrap the controls in `NumberFieldGroup`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<NumberField defaultValue={0}>
|
||||
<NumberFieldScrubArea label="Quantity" />
|
||||
<NumberFieldGroup>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
```
|
||||
|
||||
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
|
||||
|
||||
## rating
|
||||
|
||||
**Required:** `rating` (number)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
|
||||
```
|
||||
|
||||
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
|
||||
|
||||
## scrollspy
|
||||
|
||||
**Required:** `targetRef` (the scroll container ref)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Scrollspy targetRef={containerRef}>
|
||||
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
|
||||
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
|
||||
</Scrollspy>
|
||||
<div ref={containerRef}>
|
||||
<div id="s1">...</div>
|
||||
<div id="s2">...</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
|
||||
|
||||
## frame
|
||||
|
||||
**Required:** `Frame` > `FramePanel`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Frame>
|
||||
<FramePanel>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Title</FrameTitle>
|
||||
<FrameDescription>Description</FrameDescription>
|
||||
</FrameHeader>
|
||||
<div className="p-5">Content</div>
|
||||
<FrameFooter>Footer</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
```
|
||||
|
||||
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||
|
||||
## icon-stack
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconStack aria-hidden="true">
|
||||
<InboxIcon className="size-4" />
|
||||
</IconStack>
|
||||
```
|
||||
|
||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||
|
||||
## icon-tile
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconTile variant="elevated" size="lg">
|
||||
<PackageIcon />
|
||||
</IconTile>
|
||||
```
|
||||
|
||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Alert variant="success">
|
||||
<ShieldCheckIcon />
|
||||
<AlertTitle>Security update</AlertTitle>
|
||||
<AlertDescription>Enable two-factor authentication.</AlertDescription>
|
||||
<AlertAction><Button size="xs">Update</Button></AlertAction>
|
||||
</Alert>
|
||||
```
|
||||
|
||||
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
|
||||
|
||||
## badge
|
||||
|
||||
**Required:** none (text child).
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Badge variant="success-light" size="sm">Success</Badge>
|
||||
<Badge variant="outline" radius="full">Pill</Badge>
|
||||
```
|
||||
|
||||
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||
|
||||
## base vs radix - write for the project's base
|
||||
|
||||
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||
|
||||
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||
|
||||
- `"style": "base-nova"` -> **Base UI**
|
||||
- `"style": "radix-nova"` -> **Radix UI**
|
||||
|
||||
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||
|
||||
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
|
||||
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
|
||||
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
|
||||
|
||||
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Craft: make ReUI UI exceptional, not generic
|
||||
|
||||
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||
|
||||
## Have a point of view
|
||||
|
||||
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
|
||||
|
||||
## Brutally clear hierarchy
|
||||
|
||||
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||
|
||||
## Spacing rhythm and deliberate density
|
||||
|
||||
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||
|
||||
## Cover the real states (the usual miss)
|
||||
|
||||
A surface isn't done at the happy path. Compose, and wire:
|
||||
|
||||
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||
|
||||
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||
|
||||
## Responsive by default
|
||||
|
||||
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||
|
||||
## Motion, subtly
|
||||
|
||||
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||
|
||||
## Real, activated content
|
||||
|
||||
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||
|
||||
## Avoid the AI tells
|
||||
|
||||
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||
|
||||
## The bar
|
||||
|
||||
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Icons (ReUI delta over shadcn)
|
||||
|
||||
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||
|
||||
## Portable icons (library-agnostic)
|
||||
|
||||
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||
|
||||
- `lucide` -> `lucide-react`
|
||||
- `tabler` -> `@tabler/icons-react`
|
||||
- `phosphor` -> `@phosphor-icons/react`
|
||||
- `remix` -> `@remixicon/react`
|
||||
- `hugeicons` -> `@hugeicons/react`
|
||||
|
||||
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||
|
||||
## Keep icons purposeful
|
||||
|
||||
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
|
||||
|
||||
## Motion Icons (the `@reui/icons/...` set)
|
||||
|
||||
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||
```
|
||||
|
||||
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||
|
||||
Finding icons:
|
||||
|
||||
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
|
||||
- One icon: `search` with `type: "icon"`.
|
||||
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||
|
||||
The `icon-stack` component composes multiple icons into a stacked display.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Quality gates (security, accessibility, scroll)
|
||||
|
||||
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||
|
||||
## Security
|
||||
|
||||
- Never `dangerouslySetInnerHTML`. Render data as text/components.
|
||||
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
|
||||
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
|
||||
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
|
||||
- Every non-submit button is `type="button"`.
|
||||
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||
|
||||
## Scroll mechanics
|
||||
|
||||
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
|
||||
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
|
||||
@@ -0,0 +1,35 @@
|
||||
# ReUI registry structure
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
|
||||
## The @reui registry
|
||||
|
||||
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
```
|
||||
|
||||
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
|
||||
|
||||
## Know your base: base or radix
|
||||
|
||||
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
|
||||
|
||||
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
|
||||
|
||||
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Styling (ReUI delta over shadcn)
|
||||
|
||||
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||
|
||||
## ReUI extended semantic tokens
|
||||
|
||||
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||
|
||||
- `--success` / `--success-foreground`
|
||||
- `--info` / `--info-foreground`
|
||||
- `--warning` / `--warning-foreground`
|
||||
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||
|
||||
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||
|
||||
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||
|
||||
## Blocks follow your theme
|
||||
|
||||
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||
|
||||
## Density and typography rhythm
|
||||
|
||||
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Workflow: find -> install -> read API -> adapt
|
||||
|
||||
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
|
||||
|
||||
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||
|
||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||
|
||||
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||
|
||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||
|
||||
## 2. Install (shadcn CLI)
|
||||
|
||||
Run the result's `install` command from the project root, non-interactively:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @reui/<name> --yes
|
||||
```
|
||||
|
||||
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
|
||||
|
||||
## 3. Read the API (do not guess props)
|
||||
|
||||
Before writing code against any component an item uses:
|
||||
|
||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||
|
||||
## 4. Adapt (reuse-first) - do not skip
|
||||
|
||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
|
||||
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
|
||||
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
|
||||
8. **Typecheck / lint**.
|
||||
|
||||
## If no single block fits
|
||||
|
||||
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ReUI MCP: full reference
|
||||
|
||||
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
|
||||
|
||||
## Golden path (token-optimal - follow this order)
|
||||
|
||||
Most tasks need 2-4 MCP calls and ZERO web fetches:
|
||||
|
||||
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
|
||||
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
|
||||
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
|
||||
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
|
||||
5. **`get_audit_checklist()`** before declaring done.
|
||||
|
||||
If you already know the exact item name, skip `search`. Everything else is situational.
|
||||
|
||||
## The 5 task-specific tools (when to reach for each)
|
||||
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||
|
||||
## All 19 tools
|
||||
|
||||
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||
|
||||
## Token + speed rules
|
||||
|
||||
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
|
||||
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
|
||||
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
|
||||
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
|
||||
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
|
||||
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
|
||||
|
||||
## Result shapes (so you don't re-fetch)
|
||||
|
||||
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
|
||||
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||
|
||||
## Error playbook
|
||||
|
||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||
|
||||
## Fallbacks
|
||||
|
||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||
@@ -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:
|
||||
@@ -61,7 +61,16 @@ jobs:
|
||||
STAGING=".ci/docker/backend"
|
||||
rm -rf "$STAGING"
|
||||
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
|
||||
cp package.json package-lock.json "$STAGING/"
|
||||
cp package-lock.json "$STAGING/"
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs")
|
||||
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
||||
pkg.workspaces = ["packages/*", "backend"]
|
||||
pkg.dependencies = {}
|
||||
pkg.devDependencies = {}
|
||||
delete pkg.scripts
|
||||
fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
|
||||
NODE
|
||||
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
||||
@@ -70,13 +79,14 @@ jobs:
|
||||
cp -R backend/drizzle "$STAGING/backend/"
|
||||
fi
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shts.su
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
@@ -161,7 +171,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 +187,7 @@ jobs:
|
||||
NEXT_PUBLIC_BACKEND_URL=same-origin
|
||||
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
||||
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
||||
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
|
||||
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
|
||||
tags: |
|
||||
@@ -214,7 +225,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 }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
|
||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||
|
||||
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||
|
||||
## The core loop (MCP-native)
|
||||
|
||||
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||
|
||||
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||
|
||||
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||
|
||||
## Commands
|
||||
|
||||
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||
|
||||
| Command | Invoke | Does |
|
||||
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||
|
||||
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||
|
||||
## When to reach for ReUI vs plain shadcn
|
||||
|
||||
| Need | Reach for |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||
| A drag-and-drop board | the **kanban** component |
|
||||
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||
|
||||
## Detailed references
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||
@@ -0,0 +1,43 @@
|
||||
# Adapting installed ReUI code (reuse-first, no AI slop)
|
||||
|
||||
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
|
||||
|
||||
## Preserve the design - don't over-customize
|
||||
|
||||
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
|
||||
|
||||
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
|
||||
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
|
||||
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
|
||||
|
||||
## Reuse the parts: examples and the block's own elements
|
||||
|
||||
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
|
||||
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
|
||||
|
||||
## Don't invent (read, don't guess)
|
||||
|
||||
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
|
||||
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
|
||||
|
||||
## What to change vs leave alone
|
||||
|
||||
- **Change:** the item's own data, copy, props, and layout to fit the app.
|
||||
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
|
||||
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
|
||||
|
||||
## Demo data -> real data
|
||||
|
||||
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
|
||||
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
|
||||
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
|
||||
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
|
||||
|
||||
## Believable content (no AI tells)
|
||||
|
||||
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
|
||||
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
|
||||
|
||||
## Operational surfaces (settings / profile / admin)
|
||||
|
||||
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||
@@ -0,0 +1,60 @@
|
||||
# CLI: registry setup, license, non-interactive install
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
```
|
||||
|
||||
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
|
||||
|
||||
1. Add the key to `.env.local`:
|
||||
|
||||
```bash
|
||||
REUI_LICENSE_KEY=your-license-key
|
||||
```
|
||||
|
||||
2. Switch `components.json` to the authenticated object form:
|
||||
|
||||
```json
|
||||
{
|
||||
"registries": {
|
||||
"@reui": {
|
||||
"url": "https://reui.io/r/{style}/{name}.json",
|
||||
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
|
||||
Use the project's package runner (check `packageManager`):
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @reui/<name> --yes # npm
|
||||
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
|
||||
bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
```
|
||||
|
||||
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
|
||||
|
||||
## Handling prompts and conflicts
|
||||
|
||||
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
|
||||
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
|
||||
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
|
||||
- Run from the **project root** so `components.json` and `.env.local` are found.
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
@@ -0,0 +1,408 @@
|
||||
# ReUI components
|
||||
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
## data-grid (the flagship - read its API every time)
|
||||
|
||||
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
|
||||
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||
|
||||
```tsx
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns,
|
||||
})
|
||||
|
||||
<DataGrid table={table} recordCount={data.length}>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
```
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||
|
||||
## event-calendar
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
|
||||
<KanbanBoard>
|
||||
{Object.entries(cols).map(([id, items]) => (
|
||||
<KanbanColumn key={id} value={id}>
|
||||
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||
<KanbanColumnContent value={id}>
|
||||
{items.map((i) => (
|
||||
<KanbanItem key={i.id} value={i.id}>
|
||||
<KanbanItemHandle>{i.title}</KanbanItemHandle>
|
||||
</KanbanItem>
|
||||
))}
|
||||
</KanbanColumnContent>
|
||||
</KanbanColumn>
|
||||
))}
|
||||
</KanbanBoard>
|
||||
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
|
||||
</Kanban>
|
||||
```
|
||||
|
||||
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
|
||||
|
||||
## sortable
|
||||
|
||||
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
|
||||
{items.map((i) => (
|
||||
<SortableItem key={i.id} value={i.id}>
|
||||
<SortableItemHandle><GripVertical /></SortableItemHandle>
|
||||
{i.label}
|
||||
</SortableItem>
|
||||
))}
|
||||
</Sortable>
|
||||
```
|
||||
|
||||
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
**Required:** none, but wire `onChange` to capture the value.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||
|
||||
<DateSelector value={value} onChange={setValue} label="Due date" />
|
||||
```
|
||||
|
||||
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
|
||||
|
||||
## tree
|
||||
|
||||
**Required:** `tree` (a `@headless-tree/core` instance you construct)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Tree tree={tree}>
|
||||
{tree.getItems().map((item) => (
|
||||
<TreeItem key={item.getId()} item={item}>
|
||||
<TreeItemLabel />
|
||||
</TreeItem>
|
||||
))}
|
||||
</Tree>
|
||||
```
|
||||
|
||||
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
|
||||
|
||||
## stepper
|
||||
|
||||
**Required:** `StepperItem step` (number), `StepperContent value` (number)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Stepper defaultValue={1}>
|
||||
<StepperNav>
|
||||
<StepperItem step={1}>
|
||||
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
|
||||
<StepperSeparator />
|
||||
</StepperItem>
|
||||
<StepperItem step={2}>
|
||||
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
|
||||
</StepperItem>
|
||||
</StepperNav>
|
||||
<StepperPanel>
|
||||
<StepperContent value={1}>Step 1 content</StepperContent>
|
||||
<StepperContent value={2}>Step 2 content</StepperContent>
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
```
|
||||
|
||||
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
|
||||
|
||||
## timeline
|
||||
|
||||
**Required:** `TimelineItem step` (number)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Timeline>
|
||||
<TimelineItem step={1}>
|
||||
<TimelineHeader>
|
||||
<TimelineDate>March 2024</TimelineDate>
|
||||
<TimelineTitle>Project initialized</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineIndicator />
|
||||
<TimelineSeparator />
|
||||
<TimelineContent>Repo and architecture set up.</TimelineContent>
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
```
|
||||
|
||||
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
|
||||
|
||||
## autocomplete
|
||||
|
||||
**Required:** `items` (array; each item has at least `value`)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Autocomplete items={items}>
|
||||
<AutocompleteInput placeholder="Search..." />
|
||||
<AutocompleteContent>
|
||||
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
|
||||
<AutocompleteList>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
```
|
||||
|
||||
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
|
||||
|
||||
## phone-input
|
||||
|
||||
**Required:** none, but wire `onChange`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
|
||||
```
|
||||
|
||||
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
|
||||
|
||||
## number-field
|
||||
|
||||
**Required:** wrap the controls in `NumberFieldGroup`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<NumberField defaultValue={0}>
|
||||
<NumberFieldScrubArea label="Quantity" />
|
||||
<NumberFieldGroup>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
```
|
||||
|
||||
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
|
||||
|
||||
## rating
|
||||
|
||||
**Required:** `rating` (number)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
|
||||
```
|
||||
|
||||
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
|
||||
|
||||
## scrollspy
|
||||
|
||||
**Required:** `targetRef` (the scroll container ref)
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Scrollspy targetRef={containerRef}>
|
||||
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
|
||||
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
|
||||
</Scrollspy>
|
||||
<div ref={containerRef}>
|
||||
<div id="s1">...</div>
|
||||
<div id="s2">...</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
|
||||
|
||||
## frame
|
||||
|
||||
**Required:** `Frame` > `FramePanel`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Frame>
|
||||
<FramePanel>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Title</FrameTitle>
|
||||
<FrameDescription>Description</FrameDescription>
|
||||
</FrameHeader>
|
||||
<div className="p-5">Content</div>
|
||||
<FrameFooter>Footer</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
```
|
||||
|
||||
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||
|
||||
## icon-stack
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconStack aria-hidden="true">
|
||||
<InboxIcon className="size-4" />
|
||||
</IconStack>
|
||||
```
|
||||
|
||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||
|
||||
## icon-tile
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconTile variant="elevated" size="lg">
|
||||
<PackageIcon />
|
||||
</IconTile>
|
||||
```
|
||||
|
||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Alert variant="success">
|
||||
<ShieldCheckIcon />
|
||||
<AlertTitle>Security update</AlertTitle>
|
||||
<AlertDescription>Enable two-factor authentication.</AlertDescription>
|
||||
<AlertAction><Button size="xs">Update</Button></AlertAction>
|
||||
</Alert>
|
||||
```
|
||||
|
||||
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
|
||||
|
||||
## badge
|
||||
|
||||
**Required:** none (text child).
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Badge variant="success-light" size="sm">Success</Badge>
|
||||
<Badge variant="outline" radius="full">Pill</Badge>
|
||||
```
|
||||
|
||||
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||
|
||||
## base vs radix - write for the project's base
|
||||
|
||||
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||
|
||||
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||
|
||||
- `"style": "base-nova"` -> **Base UI**
|
||||
- `"style": "radix-nova"` -> **Radix UI**
|
||||
|
||||
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||
|
||||
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
|
||||
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
|
||||
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
|
||||
|
||||
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Craft: make ReUI UI exceptional, not generic
|
||||
|
||||
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||
|
||||
## Have a point of view
|
||||
|
||||
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
|
||||
|
||||
## Brutally clear hierarchy
|
||||
|
||||
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||
|
||||
## Spacing rhythm and deliberate density
|
||||
|
||||
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||
|
||||
## Cover the real states (the usual miss)
|
||||
|
||||
A surface isn't done at the happy path. Compose, and wire:
|
||||
|
||||
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||
|
||||
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||
|
||||
## Responsive by default
|
||||
|
||||
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||
|
||||
## Motion, subtly
|
||||
|
||||
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||
|
||||
## Real, activated content
|
||||
|
||||
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||
|
||||
## Avoid the AI tells
|
||||
|
||||
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||
|
||||
## The bar
|
||||
|
||||
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Icons (ReUI delta over shadcn)
|
||||
|
||||
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||
|
||||
## Portable icons (library-agnostic)
|
||||
|
||||
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||
|
||||
- `lucide` -> `lucide-react`
|
||||
- `tabler` -> `@tabler/icons-react`
|
||||
- `phosphor` -> `@phosphor-icons/react`
|
||||
- `remix` -> `@remixicon/react`
|
||||
- `hugeicons` -> `@hugeicons/react`
|
||||
|
||||
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||
|
||||
## Keep icons purposeful
|
||||
|
||||
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
|
||||
|
||||
## Motion Icons (the `@reui/icons/...` set)
|
||||
|
||||
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||
```
|
||||
|
||||
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||
|
||||
Finding icons:
|
||||
|
||||
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
|
||||
- One icon: `search` with `type: "icon"`.
|
||||
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||
|
||||
The `icon-stack` component composes multiple icons into a stacked display.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Quality gates (security, accessibility, scroll)
|
||||
|
||||
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||
|
||||
## Security
|
||||
|
||||
- Never `dangerouslySetInnerHTML`. Render data as text/components.
|
||||
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
|
||||
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
|
||||
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
|
||||
- Every non-submit button is `type="button"`.
|
||||
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||
|
||||
## Scroll mechanics
|
||||
|
||||
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
|
||||
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
|
||||
@@ -0,0 +1,35 @@
|
||||
# ReUI registry structure
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
|
||||
## The @reui registry
|
||||
|
||||
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
```
|
||||
|
||||
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
|
||||
|
||||
## Know your base: base or radix
|
||||
|
||||
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
|
||||
|
||||
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
|
||||
|
||||
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Styling (ReUI delta over shadcn)
|
||||
|
||||
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||
|
||||
## ReUI extended semantic tokens
|
||||
|
||||
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||
|
||||
- `--success` / `--success-foreground`
|
||||
- `--info` / `--info-foreground`
|
||||
- `--warning` / `--warning-foreground`
|
||||
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||
|
||||
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||
|
||||
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||
|
||||
## Blocks follow your theme
|
||||
|
||||
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||
|
||||
## Density and typography rhythm
|
||||
|
||||
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Workflow: find -> install -> read API -> adapt
|
||||
|
||||
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
|
||||
|
||||
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||
|
||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||
|
||||
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||
|
||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||
|
||||
## 2. Install (shadcn CLI)
|
||||
|
||||
Run the result's `install` command from the project root, non-interactively:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @reui/<name> --yes
|
||||
```
|
||||
|
||||
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
|
||||
|
||||
## 3. Read the API (do not guess props)
|
||||
|
||||
Before writing code against any component an item uses:
|
||||
|
||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||
|
||||
## 4. Adapt (reuse-first) - do not skip
|
||||
|
||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
|
||||
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
|
||||
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
|
||||
8. **Typecheck / lint**.
|
||||
|
||||
## If no single block fits
|
||||
|
||||
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ReUI MCP: full reference
|
||||
|
||||
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
|
||||
|
||||
## Golden path (token-optimal - follow this order)
|
||||
|
||||
Most tasks need 2-4 MCP calls and ZERO web fetches:
|
||||
|
||||
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
|
||||
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
|
||||
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
|
||||
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
|
||||
5. **`get_audit_checklist()`** before declaring done.
|
||||
|
||||
If you already know the exact item name, skip `search`. Everything else is situational.
|
||||
|
||||
## The 5 task-specific tools (when to reach for each)
|
||||
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||
|
||||
## All 19 tools
|
||||
|
||||
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||
|
||||
## Token + speed rules
|
||||
|
||||
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
|
||||
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
|
||||
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
|
||||
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
|
||||
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
|
||||
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
|
||||
|
||||
## Result shapes (so you don't re-fetch)
|
||||
|
||||
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
|
||||
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||
|
||||
## Error playbook
|
||||
|
||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||
|
||||
## Fallbacks
|
||||
|
||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||
@@ -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 -` из корня проекта.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [Прод-сервер]
|
||||
@@ -135,6 +135,7 @@ sequenceDiagram
|
||||
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
|
||||
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
|
||||
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
|
||||
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
|
||||
|
||||
### Запуск
|
||||
|
||||
@@ -192,9 +193,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 +213,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 +314,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 +363,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 +376,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 +387,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 +396,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 +455,7 @@ bash deploy/updater/validate.sh
|
||||
|
||||
## Первичная настройка сервера
|
||||
|
||||
Пошагово на чистом Linux-хосте с доступом в интернет и к `git.shts.su`.
|
||||
Пошагово на чистом Linux-хосте с доступом в интернет и к `git.shx.one`.
|
||||
|
||||
1. Установить Docker Engine и плагин Compose (официальная документация Docker для вашего дистрибутива).
|
||||
2. Проверить:
|
||||
@@ -413,7 +469,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 +499,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 +541,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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+208
-232
@@ -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 {
|
||||
@@ -21,53 +28,19 @@ import { useDataSource } from "@/lib/data-source"
|
||||
import { listServers } from "@/shared/api/servers"
|
||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
|
||||
import { requestBlob } from "@/shared/api/http-client"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// ─── 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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -168,8 +141,11 @@ export default function BackupsPage() {
|
||||
|
||||
// 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)
|
||||
@@ -301,8 +277,7 @@ export default function BackupsPage() {
|
||||
}
|
||||
|
||||
async function handleDownload(id: string, fallbackFilename: string) {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`)
|
||||
if (!res.ok) throw new Error("Не удалось скачать файл")
|
||||
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
@@ -356,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" />Новый бэкап
|
||||
@@ -376,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>
|
||||
|
||||
@@ -424,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>
|
||||
)}
|
||||
|
||||
{/* ── Настройки ────────────────────────────────────────────────── */}
|
||||
@@ -508,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)}
|
||||
@@ -531,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)}
|
||||
@@ -548,18 +469,18 @@ export default function BackupsPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{schedule.frequency === "monthly" && (
|
||||
<Field label="День месяца" hint="1–28">
|
||||
<FormField label="День месяца" hint="1–28">
|
||||
<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"
|
||||
@@ -581,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)}
|
||||
@@ -598,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)}
|
||||
@@ -620,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"}
|
||||
@@ -673,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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -694,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>
|
||||
@@ -709,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) => {
|
||||
@@ -743,8 +662,7 @@ 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">
|
||||
@@ -759,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>
|
||||
)
|
||||
}
|
||||
|
||||
+104
-328
@@ -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"
|
||||
@@ -14,52 +23,16 @@ import {
|
||||
XIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
|
||||
// ─── 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 +213,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 +220,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 +261,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 +361,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 +427,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -682,20 +471,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 +518,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 +544,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 +588,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OpsPanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -842,11 +622,7 @@ export default function BgpPage() {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/bgp/sessions`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json() as Promise<BackendBgpSession[]>
|
||||
})
|
||||
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveSessions(data.map(backendToFrontend))
|
||||
@@ -954,12 +730,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
@@ -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'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'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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+102
-218
@@ -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()
|
||||
@@ -784,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="Активные фильтры"
|
||||
@@ -793,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-префиксы"
|
||||
@@ -802,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="Активные алерты"
|
||||
@@ -811,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"
|
||||
@@ -848,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>
|
||||
@@ -890,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" && (
|
||||
<>
|
||||
@@ -981,8 +922,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OpsPanel>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
@@ -998,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>
|
||||
|
||||
+199
-542
@@ -3,9 +3,12 @@
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -27,7 +30,6 @@ import {
|
||||
import { requestJson, ApiClientError } from "@/shared/api/http-client"
|
||||
import {
|
||||
parseSchedulerRunSnapshot,
|
||||
type AlertEngineRuleDiagSnapshot,
|
||||
type AlertEngineRunSnapshot,
|
||||
type GreBgpSnapshotRunSnapshot,
|
||||
type InternetPathRunSnapshot,
|
||||
@@ -40,6 +42,19 @@ import {
|
||||
type SpeedScheduledRunSnapshot,
|
||||
type TrafficRunSnapshot,
|
||||
} from "@/lib/scheduler-run-snapshot"
|
||||
import {
|
||||
AlertEngineRuleDiagGrid,
|
||||
PingSnapshotGrid,
|
||||
ResourcesSnapshotGrid,
|
||||
ServersRestPingSnapshotGrid,
|
||||
SpeedSnapshotGrid,
|
||||
TrafficSnapshotGrid,
|
||||
} from "@/components/data-grids/snapshot-data-grid"
|
||||
import {
|
||||
DataCollectionSchedulerDataGrid,
|
||||
type SchedulerJobGridRow,
|
||||
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
@@ -79,54 +94,12 @@ function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels:
|
||||
return errors
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "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 fmtMs(ms: number): string {
|
||||
if (ms < 1000) return `${ms} мс`
|
||||
const s = ms / 1000
|
||||
return s < 60 ? `${s.toFixed(1)} с` : `${Math.floor(s / 60)} м ${Math.round(s % 60)} с`
|
||||
}
|
||||
|
||||
function fmtUptimeSec(sec: number): string {
|
||||
if (sec <= 0) return "—"
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
if (d > 0) return `${d}д ${h}ч`
|
||||
if (h > 0) return `${h}ч ${m}м`
|
||||
return `${m}м`
|
||||
}
|
||||
|
||||
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "traffic") {
|
||||
const t = snap as TrafficRunSnapshot
|
||||
@@ -144,44 +117,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Сэмплы на момент <span className="font-mono tabular-nums">{new Date(t.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||||
<th className="px-3 py-2 font-medium">Сервер</th>
|
||||
<th className="px-3 py-2 font-medium">Хост</th>
|
||||
<th className="px-3 py-2 font-medium">Результат</th>
|
||||
<th className="px-3 py-2 font-medium text-right">IF</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Σ RX</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Σ TX</th>
|
||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{t.servers.map((s) => (
|
||||
<tr key={s.serverId} className="hover:bg-muted/30">
|
||||
<td className="px-3 py-2 font-medium">{s.name}</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground">{s.host}</td>
|
||||
<td className="px-3 py-2">
|
||||
{s.ok ? (
|
||||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
|
||||
ok
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
|
||||
ошибка
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{s.interfaces ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{s.sumRxMbps != null ? `${s.sumRxMbps} Мбит/с` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{s.sumTxMbps != null ? `${s.sumTxMbps} Мбит/с` : "—"}</td>
|
||||
<td className="px-3 py-2 text-destructive max-w-[220px] truncate" title={s.error}>{s.error ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<TrafficSnapshotGrid servers={t.servers} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -201,59 +139,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Сэмплы на <span className="font-mono tabular-nums">{new Date(u.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||||
<th className="px-3 py-2 font-medium">Сервер</th>
|
||||
<th className="px-3 py-2 font-medium">Статус</th>
|
||||
<th className="px-3 py-2 font-medium text-right">CPU %</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Память</th>
|
||||
<th className="px-3 py-2 font-medium text-right">% RAM</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Диск своб.</th>
|
||||
<th className="px-3 py-2 font-medium">Uptime</th>
|
||||
<th className="px-3 py-2 font-medium">Плата / ROS</th>
|
||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{u.servers.map((s) => (
|
||||
<tr key={s.serverId} className="hover:bg-muted/30">
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
<span className="block font-mono text-[10px] text-muted-foreground">{s.host}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
s.status === "online" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
||||
s.status === "offline" && "border-destructive/50 text-destructive",
|
||||
)}
|
||||
>
|
||||
{s.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{s.cpuLoadPct ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums whitespace-nowrap">
|
||||
{s.memUsedMb != null && s.memTotalMb != null ? `${s.memUsedMb} / ${s.memTotalMb} МБ` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{s.memUsedPct != null ? `${s.memUsedPct}%` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums whitespace-nowrap">
|
||||
{s.diskFreeMb != null && s.diskTotalMb != null ? `${s.diskFreeMb} / ${s.diskTotalMb} МБ` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 tabular-nums">{s.uptimeSeconds != null ? fmtUptimeSec(s.uptimeSeconds) : "—"}</td>
|
||||
<td className="px-3 py-2 max-w-[140px]">
|
||||
<span className="block truncate" title={s.boardName}>{s.boardName || "—"}</span>
|
||||
<span className="block truncate text-muted-foreground font-mono text-[10px]" title={s.rosVersion}>{s.rosVersion || ""}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-destructive max-w-[160px] truncate" title={s.error}>{s.error ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<ResourcesSnapshotGrid servers={u.servers} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -274,42 +162,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
GET <span className="font-mono">/system/identity</span> на{" "}
|
||||
<span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||||
<th className="px-3 py-2 font-medium">Сервер</th>
|
||||
<th className="px-3 py-2 font-medium">Хост</th>
|
||||
<th className="px-3 py-2 font-medium">Результат</th>
|
||||
<th className="px-3 py-2 font-medium text-right">RTT REST</th>
|
||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{s.servers.map((row) => (
|
||||
<tr key={row.serverId} className="hover:bg-muted/30">
|
||||
<td className="px-3 py-2 font-medium">{row.name}</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground">{row.host}</td>
|
||||
<td className="px-3 py-2">
|
||||
{row.ok ? (
|
||||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
|
||||
ok
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
|
||||
недоступен
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{row.latencyMs != null ? `${row.latencyMs} мс` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-destructive max-w-[220px] truncate" title={row.error}>{row.error ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<ServersRestPingSnapshotGrid servers={s.servers} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -332,41 +187,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Сэмплы на <span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span> — только пробы, для которых записан замер в этом тике
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||||
<th className="px-3 py-2 font-medium">Проба</th>
|
||||
<th className="px-3 py-2 font-medium">Цель</th>
|
||||
<th className="px-3 py-2 font-medium">Источник</th>
|
||||
<th className="px-3 py-2 font-medium">IF</th>
|
||||
<th className="px-3 py-2 font-medium text-right">RTT</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Loss</th>
|
||||
<th className="px-3 py-2 font-medium">Статус</th>
|
||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{p.probes.map((x) => (
|
||||
<tr key={`${x.probeId}-${x.target}`} className="hover:bg-muted/30">
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-medium">{x.name}</span>
|
||||
<span className="block font-mono text-[10px] text-muted-foreground">{x.probeId}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono">{x.target}</td>
|
||||
<td className="px-3 py-2">{x.srcServerName}</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground">{x.srcInterface || "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{x.rttMs != null ? `${x.rttMs} мс` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{x.lossPct}%</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="outline" className="text-[10px]">{x.status}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-destructive max-w-[180px] truncate" title={x.error}>{x.error ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<PingSnapshotGrid probes={p.probes} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -377,56 +200,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||||
<th className="px-3 py-2 font-medium">Проба</th>
|
||||
<th className="px-3 py-2 font-medium">Маршрут</th>
|
||||
<th className="px-3 py-2 font-medium">Интерфейсы</th>
|
||||
<th className="px-3 py-2 font-medium">Протокол</th>
|
||||
<th className="px-3 py-2 font-medium text-right">TX</th>
|
||||
<th className="px-3 py-2 font-medium text-right">RX</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Ping RTT</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Loss</th>
|
||||
<th className="px-3 py-2 font-medium">Результат</th>
|
||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{s.runs.map((x) => (
|
||||
<tr key={x.probeId} className="hover:bg-muted/30">
|
||||
<td className="px-3 py-2 font-mono">{x.probeId}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
{x.srcServerName} <span className="text-muted-foreground">→</span> {x.dstServerName}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[10px]">
|
||||
<span className="block">{x.srcInterface || "—"}</span>
|
||||
<span className="block text-muted-foreground">{x.dstInterface || "—"}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">{x.protocol} / {x.direction} / {x.durationSec}s</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{x.txAvgMbps != null ? `${Number(x.txAvgMbps).toFixed(1)}` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{x.rxAvgMbps != null ? `${Number(x.rxAvgMbps).toFixed(1)}` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{x.pingRttMs != null ? `${x.pingRttMs} мс` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{x.pingLossPct != null ? `${x.pingLossPct}%` : "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
{x.ok ? (
|
||||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">ok</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">ошибка</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 max-w-[200px]">
|
||||
<span className="text-destructive block truncate" title={x.error}>{x.error ?? ""}</span>
|
||||
{x.pingError ? (
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 block truncate" title={x.pingError ?? ""}>ping: {x.pingError}</span>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<SpeedSnapshotGrid runs={s.runs} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -566,36 +342,6 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
}
|
||||
if (snap.job === "alert_engine") {
|
||||
const a = snap as AlertEngineRunSnapshot
|
||||
const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => {
|
||||
switch (t) {
|
||||
case "problem":
|
||||
return "проблема"
|
||||
case "recovery":
|
||||
return "восстановление"
|
||||
case "neutral":
|
||||
return "нейтрально"
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
const blockedRu = (b: AlertEngineRuleDiagSnapshot["blocked"]) => {
|
||||
switch (b) {
|
||||
case "no_hit":
|
||||
return "условие не выполнено"
|
||||
case "stability":
|
||||
return "стабильность (confirmStabilitySec)"
|
||||
case "cooldown":
|
||||
return "cooldown"
|
||||
case "no_telegram":
|
||||
return "нет Telegram"
|
||||
case "dedupe_positive":
|
||||
return "дедуп восстановления"
|
||||
case "in_group":
|
||||
return "в группе (отдельно не шлём)"
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -636,40 +382,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
|
||||
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px] border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1 pr-2 font-medium">ID правила</th>
|
||||
<th className="py-1 pr-2 font-medium">Сработало</th>
|
||||
<th className="py-1 pr-2 font-medium">Тип срабатывания</th>
|
||||
<th className="py-1 pr-2 font-medium">Стабильность</th>
|
||||
<th className="py-1 pr-2 font-medium">Кулдаун</th>
|
||||
<th className="py-1 pr-2 font-medium">Telegram</th>
|
||||
<th className="py-1 pr-2 font-medium">Сообщение</th>
|
||||
<th className="py-1 font-medium">Причина блока</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{a.ruleDiag.map((d) => (
|
||||
<tr key={d.ruleId} className="border-b border-border/60 font-mono">
|
||||
<td className="py-1 pr-2 max-w-[140px] truncate" title={d.ruleId}>
|
||||
{d.ruleId}
|
||||
</td>
|
||||
<td className="py-1 pr-2">{d.evalHit ? "Да" : "Нет"}</td>
|
||||
<td className="py-1 pr-2">{transitionRu(d.hitTransition)}</td>
|
||||
<td className="py-1 pr-2">{d.stabilityOk ? "Да" : "Нет"}</td>
|
||||
<td className="py-1 pr-2">{d.cooldownOk ? "Да" : "Нет"}</td>
|
||||
<td className="py-1 pr-2">{d.telegramOk ? "Да" : "Нет"}</td>
|
||||
<td className="py-1 pr-2 max-w-[280px] truncate text-muted-foreground" title={d.hitMessage ?? ""}>
|
||||
{d.hitMessage ?? "—"}
|
||||
</td>
|
||||
<td className="py-1 text-muted-foreground">{blockedRu(d.blocked)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataPageCard className="border-0 shadow-none bg-transparent">
|
||||
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1088,6 +803,130 @@ export default function DataCollectionPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const schedulerGridRows = useMemo<SchedulerJobGridRow[]>(() => {
|
||||
return SCHEDULER_JOB_KEYS.map((jobKey) => {
|
||||
const j = schedulerJobsByKey[jobKey]
|
||||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
|
||||
const enabled = fixedSchedule
|
||||
? Boolean(j?.enabled ?? true)
|
||||
: jobKey === "traffic"
|
||||
? draftTrafficEnabled
|
||||
: jobKey === "servers_rest_ping"
|
||||
? draftServersApiEnabled
|
||||
: jobKey === "uptime_resources"
|
||||
? draftResourcesEnabled
|
||||
: jobKey === "uptime_ping"
|
||||
? draftPingEnabled
|
||||
: jobKey === "uptime_speed"
|
||||
? draftSpeedEnabled
|
||||
: jobKey === "certificates_renew"
|
||||
? draftCertRenewEnabled
|
||||
: draftInternetPathEnabled
|
||||
const intervalValue = fixedSchedule
|
||||
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||
: jobKey === "traffic"
|
||||
? trafficIntervalDraft
|
||||
: jobKey === "servers_rest_ping"
|
||||
? serversApiIntervalDraft
|
||||
: jobKey === "uptime_resources"
|
||||
? uptimeResourceIntervalDraft
|
||||
: jobKey === "uptime_ping"
|
||||
? uptimeIntervalDraft
|
||||
: jobKey === "uptime_speed"
|
||||
? uptimeSpeedIntervalDraft
|
||||
: jobKey === "certificates_renew"
|
||||
? certRenewIntervalDraft
|
||||
: internetPathIntervalDraft
|
||||
const onIntervalChange = fixedSchedule
|
||||
? () => {}
|
||||
: jobKey === "traffic"
|
||||
? setTrafficIntervalDraft
|
||||
: jobKey === "servers_rest_ping"
|
||||
? setServersApiIntervalDraft
|
||||
: jobKey === "uptime_resources"
|
||||
? setUptimeResourceIntervalDraft
|
||||
: jobKey === "uptime_ping"
|
||||
? setUptimeIntervalDraft
|
||||
: jobKey === "uptime_speed"
|
||||
? setUptimeSpeedIntervalDraft
|
||||
: jobKey === "certificates_renew"
|
||||
? setCertRenewIntervalDraft
|
||||
: setInternetPathIntervalDraft
|
||||
const defaultInterval = fixedSchedule
|
||||
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||
: jobKey === "traffic"
|
||||
? 30
|
||||
: jobKey === "servers_rest_ping"
|
||||
? 120
|
||||
: jobKey === "uptime_resources"
|
||||
? 300
|
||||
: jobKey === "uptime_ping"
|
||||
? 15
|
||||
: jobKey === "uptime_speed"
|
||||
? 60
|
||||
: jobKey === "certificates_renew"
|
||||
? 21600
|
||||
: 300
|
||||
|
||||
return {
|
||||
id: jobKey,
|
||||
jobKey,
|
||||
label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey,
|
||||
description: SCHEDULER_JOB_DESCRIPTIONS[jobKey],
|
||||
fixedSchedule,
|
||||
enabled,
|
||||
intervalValue,
|
||||
intervalReadOnly: fixedSchedule,
|
||||
intervalDisabled: !enabled && !fixedSchedule,
|
||||
defaultInterval,
|
||||
job: j,
|
||||
onEnabledChange: fixedSchedule
|
||||
? undefined
|
||||
: (nextEnabled) => {
|
||||
void handleJobEnabledChange(jobKey, nextEnabled)
|
||||
},
|
||||
onIntervalChange,
|
||||
onRunNow: async () => {
|
||||
setRunNowJobKey(jobKey)
|
||||
setCollectorError(null)
|
||||
try {
|
||||
await apiFetch(`/api/scheduler/jobs/${encodeURIComponent(jobKey)}/run-now`, {
|
||||
method: "POST",
|
||||
})
|
||||
await loadCollectors()
|
||||
} catch (e) {
|
||||
setCollectorError(e instanceof Error ? e.message : "Ошибка запуска")
|
||||
} finally {
|
||||
setRunNowJobKey(null)
|
||||
}
|
||||
},
|
||||
runNowLoading: runNowJobKey === jobKey,
|
||||
saveBusy: schedulerSaveBusy,
|
||||
}
|
||||
})
|
||||
}, [
|
||||
apiFetch,
|
||||
certRenewIntervalDraft,
|
||||
draftCertRenewEnabled,
|
||||
draftInternetPathEnabled,
|
||||
draftPingEnabled,
|
||||
draftResourcesEnabled,
|
||||
draftServersApiEnabled,
|
||||
draftSpeedEnabled,
|
||||
draftTrafficEnabled,
|
||||
handleJobEnabledChange,
|
||||
internetPathIntervalDraft,
|
||||
loadCollectors,
|
||||
runNowJobKey,
|
||||
schedulerJobsByKey,
|
||||
schedulerSaveBusy,
|
||||
serversApiIntervalDraft,
|
||||
trafficIntervalDraft,
|
||||
uptimeIntervalDraft,
|
||||
uptimeResourceIntervalDraft,
|
||||
uptimeSpeedIntervalDraft,
|
||||
])
|
||||
|
||||
const enabledJobsCount = useMemo(() => {
|
||||
const jobs = uptimeCollector?.scheduler?.jobs
|
||||
if (jobs?.length) return jobs.filter((job) => job.enabled).length
|
||||
@@ -1195,30 +1034,24 @@ export default function DataCollectionPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full">
|
||||
{!prefsHydrated && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Загрузка настроек подключения</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Читаем режим данных и адрес API из локальных настроек.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<OpsPanel
|
||||
title="Загрузка настроек подключения"
|
||||
description="Читаем режим данных и адрес API из локальных настроек."
|
||||
>
|
||||
<div />
|
||||
</OpsPanel>
|
||||
)}
|
||||
|
||||
{prefsHydrated && !isLive && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Нужен live-режим</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Планировщик и журнал читаются только с бекенда. Включите «Живые» данные и проверьте URL бекенда в настройках.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-5 pb-5">
|
||||
<OpsPanel
|
||||
title="Нужен live-режим"
|
||||
description="Планировщик и журнал читаются только с бекенда. Включите «Живые» данные и проверьте URL бекенда в настройках."
|
||||
contentClassName="px-5 pb-5"
|
||||
>
|
||||
<Link href="/settings" className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-8")}>
|
||||
Открыть настройки
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OpsPanel>
|
||||
)}
|
||||
|
||||
{isLive && collectorError && (
|
||||
@@ -1233,202 +1066,31 @@ export default function DataCollectionPage() {
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-5 py-4 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||
<p className="text-xl font-semibold tabular-nums mt-0.5 truncate">{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="min-w-0 flex-1 flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-xl leading-none font-bold tabular-nums truncate">{s.value}</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
|
||||
</div>
|
||||
<div className="shrink-0 mt-0.5">{s.icon}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border pb-4">
|
||||
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<DataPageCard>
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<p className="text-base font-medium">Планировщик сбора данных</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-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-3">Задача</th>
|
||||
<th className="text-center font-medium px-3 py-3 w-[1%]">Вкл</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 w-[1%]">Сейчас</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{SCHEDULER_JOB_KEYS.map((jobKey) => {
|
||||
const j = schedulerJobsByKey[jobKey]
|
||||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
|
||||
const en = fixedSchedule
|
||||
? Boolean(j?.enabled ?? true)
|
||||
: jobKey === "traffic"
|
||||
? draftTrafficEnabled
|
||||
: jobKey === "servers_rest_ping"
|
||||
? draftServersApiEnabled
|
||||
: jobKey === "uptime_resources"
|
||||
? draftResourcesEnabled
|
||||
: jobKey === "uptime_ping"
|
||||
? draftPingEnabled
|
||||
: jobKey === "uptime_speed"
|
||||
? draftSpeedEnabled
|
||||
: jobKey === "certificates_renew"
|
||||
? draftCertRenewEnabled
|
||||
: draftInternetPathEnabled
|
||||
const iv = fixedSchedule
|
||||
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||
: jobKey === "traffic"
|
||||
? trafficIntervalDraft
|
||||
: jobKey === "servers_rest_ping"
|
||||
? serversApiIntervalDraft
|
||||
: jobKey === "uptime_resources"
|
||||
? uptimeResourceIntervalDraft
|
||||
: jobKey === "uptime_ping"
|
||||
? uptimeIntervalDraft
|
||||
: jobKey === "uptime_speed"
|
||||
? uptimeSpeedIntervalDraft
|
||||
: jobKey === "certificates_renew"
|
||||
? certRenewIntervalDraft
|
||||
: internetPathIntervalDraft
|
||||
const setIv = fixedSchedule
|
||||
? () => {}
|
||||
: jobKey === "traffic"
|
||||
? setTrafficIntervalDraft
|
||||
: jobKey === "servers_rest_ping"
|
||||
? setServersApiIntervalDraft
|
||||
: jobKey === "uptime_resources"
|
||||
? setUptimeResourceIntervalDraft
|
||||
: jobKey === "uptime_ping"
|
||||
? setUptimeIntervalDraft
|
||||
: jobKey === "uptime_speed"
|
||||
? setUptimeSpeedIntervalDraft
|
||||
: jobKey === "certificates_renew"
|
||||
? setCertRenewIntervalDraft
|
||||
: setInternetPathIntervalDraft
|
||||
const defSec = fixedSchedule
|
||||
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||
: jobKey === "traffic"
|
||||
? 30
|
||||
: jobKey === "servers_rest_ping"
|
||||
? 120
|
||||
: jobKey === "uptime_resources"
|
||||
? 300
|
||||
: jobKey === "uptime_ping"
|
||||
? 15
|
||||
: jobKey === "uptime_speed"
|
||||
? 60
|
||||
: jobKey === "certificates_renew"
|
||||
? 21600
|
||||
: 300
|
||||
return (
|
||||
<tr key={jobKey} className="hover:bg-muted/40">
|
||||
<td className="px-5 py-3 align-top">
|
||||
<span className="font-medium">{SCHEDULER_JOB_LABELS[jobKey] ?? jobKey}</span>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
|
||||
{SCHEDULER_JOB_DESCRIPTIONS[jobKey]}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono mt-1">{jobKey}</p>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center align-top">
|
||||
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
||||
<Toggle
|
||||
checked={en}
|
||||
disabled={fixedSchedule || schedulerSaveBusy}
|
||||
onChange={(v) => {
|
||||
if (fixedSchedule || schedulerSaveBusy) return
|
||||
void handleJobEnabledChange(jobKey, v)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-28 align-top">
|
||||
<Input
|
||||
value={iv}
|
||||
onChange={(e) => setIv(e.target.value)}
|
||||
className="h-8 text-sm tabular-nums"
|
||||
inputMode="numeric"
|
||||
readOnly={fixedSchedule}
|
||||
disabled={!en && !fixedSchedule}
|
||||
placeholder={String(defSec)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground align-top">
|
||||
{j?.lastFinishedAt ? new Date(j.lastFinishedAt).toLocaleString("ru-RU") : "—"}
|
||||
{j?.lastDurationMs != null && (
|
||||
<span className="block text-[11px]">{j.lastDurationMs} мс</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{j?.running ? (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
выполняется
|
||||
</Badge>
|
||||
) : null}
|
||||
{j?.lastStatus ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
j.lastStatus === "ok" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
||||
j.lastStatus === "error" && "border-destructive/50 text-destructive",
|
||||
)}
|
||||
>
|
||||
{j.lastStatus}
|
||||
</Badge>
|
||||
) : null}
|
||||
{j?.lastError ? (
|
||||
<span
|
||||
className="text-[10px] text-destructive max-w-[200px] truncate block"
|
||||
title={j.lastError}
|
||||
>
|
||||
{j.lastError}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right align-top">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={j?.running || runNowJobKey !== null}
|
||||
onClick={async () => {
|
||||
setRunNowJobKey(jobKey)
|
||||
setCollectorError(null)
|
||||
try {
|
||||
await apiFetch(`/api/scheduler/jobs/${encodeURIComponent(jobKey)}/run-now`, {
|
||||
method: "POST",
|
||||
})
|
||||
await loadCollectors()
|
||||
} catch (e) {
|
||||
setCollectorError(e instanceof Error ? e.message : "Ошибка запуска")
|
||||
} finally {
|
||||
setRunNowJobKey(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-3.5", runNowJobKey === jobKey && "animate-spin")} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
</p>
|
||||
</div>
|
||||
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
||||
<Separator />
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
||||
@@ -1537,17 +1199,12 @@ export default function DataCollectionPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DataPageCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Журнал прогонов</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<OpsPanel
|
||||
title="Журнал прогонов"
|
||||
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
||||
headerRight={
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
|
||||
Задача
|
||||
@@ -1566,8 +1223,9 @@ export default function DataCollectionPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-0">
|
||||
}
|
||||
contentClassName="px-0 pb-0"
|
||||
>
|
||||
{schedulerRuns.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-10 px-4">Пока нет прогонов</p>
|
||||
) : (
|
||||
@@ -1624,8 +1282,7 @@ export default function DataCollectionPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OpsPanel>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+46
-56
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+51
-181
@@ -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"
|
||||
@@ -12,6 +16,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
|
||||
|
||||
@@ -375,13 +380,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(
|
||||
@@ -736,13 +734,14 @@ function InterfacesTab({
|
||||
const ra = readStoredRouteOptimizerSettings()
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
||||
})
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const data = await r.json() as BackendOspfOptimizeResponse
|
||||
const data = await requestJson<BackendOspfOptimizeResponse>(
|
||||
backendUrl,
|
||||
`/api/servers/${filterServerId}/ospf/optimize`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
||||
},
|
||||
)
|
||||
const byKey: Record<string, number> = {}
|
||||
data.interfaces.forEach((row) => {
|
||||
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
||||
@@ -812,7 +811,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 +876,8 @@ function InterfacesTab({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -926,12 +927,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 +979,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 +1007,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 +1051,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 +1067,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 && (
|
||||
@@ -1251,8 +1122,7 @@ export default function OspfPage() {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/ospf/all`)
|
||||
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
|
||||
void requestJson<BackendOspfAll>(backendUrl, "/api/ospf/all")
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
|
||||
@@ -1483,12 +1353,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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
Раздел работает в режиме "Живые данные". Переключи источник данных в настройках.
|
||||
</Card>
|
||||
<Frame dense className="w-full">
|
||||
<FramePanel className="p-6 text-sm text-muted-foreground">
|
||||
Раздел работает в режиме "Живые данные". Переключи источник данных в настройках.
|
||||
</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">
|
||||
Нет маршрутов в БД для этого сервера. Нажми "Router => DB" для загрузки.
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+140
-328
@@ -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="[email protected]" 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)
|
||||
@@ -984,8 +875,11 @@ export default function SettingsPage() {
|
||||
await evo.saveSettings(patch)
|
||||
setEvoKeyDraft("")
|
||||
markSaved()
|
||||
toast.success("Настройки EvoBGP сохранены")
|
||||
} catch (e) {
|
||||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
|
||||
const msg = e instanceof Error ? e.message : "Ошибка сохранения"
|
||||
setEvoSaveErr(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setEvoSaveBusy(false)
|
||||
}
|
||||
@@ -1031,7 +925,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 +940,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 +1043,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 +1081,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 +1114,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 +1166,7 @@ export default function SettingsPage() {
|
||||
>
|
||||
Открыть оптимизатор маршрутов
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OpsPanel>
|
||||
|
||||
</div>
|
||||
)
|
||||
@@ -1309,24 +1175,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 +1202,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 +1251,7 @@ export default function SettingsPage() {
|
||||
label="Подставлять данные EvoBGP"
|
||||
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
||||
>
|
||||
<Toggle
|
||||
<FormToggle
|
||||
checked={evoEnabledDraft}
|
||||
onChange={(v) => setEvoEnabledDraft(v)}
|
||||
/>
|
||||
@@ -1484,48 +1351,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="Отправка уведомлений на [email protected]">
|
||||
<Toggle checked={notifEmail} onChange={setNotifEmail} />
|
||||
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
|
||||
</SettingRow>
|
||||
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="[email protected]" /></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 +1402,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 +1459,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 +1485,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 +1514,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 +1540,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 +1580,7 @@ export default function SettingsPage() {
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OpsPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1853,7 +1653,6 @@ export default function SettingsPage() {
|
||||
onCancel={() => {
|
||||
if (dbRestoreBusy) return
|
||||
setDbRestoreFile(null)
|
||||
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1864,6 +1663,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { servers as mockServers } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import {
|
||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||
} from "lucide-react"
|
||||
@@ -259,12 +260,14 @@ function Terminal({
|
||||
if (isLive && server.backendId !== null) {
|
||||
setExecuting(true)
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
})
|
||||
const data = await res.json() as { output?: string; error?: string }
|
||||
const data = await requestJson<{ output?: string; error?: string }>(
|
||||
backendUrl,
|
||||
`/api/servers/${server.backendId}/exec`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
},
|
||||
)
|
||||
const text = data.output ?? data.error ?? "(empty response)"
|
||||
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
|
||||
text.split("\n").forEach(line =>
|
||||
@@ -427,7 +430,7 @@ interface BackendServer {
|
||||
}
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
// Server list state
|
||||
@@ -437,14 +440,13 @@ export default function TerminalPage() {
|
||||
|
||||
// Load servers from backend when in live mode
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
if (!isLive || !prefsHydrated) return
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setServersLoading(true)
|
||||
fetch(`${backendUrl}/api/servers`)
|
||||
.then(r => r.json() as Promise<BackendServer[]>)
|
||||
.then(data => {
|
||||
void requestJson<BackendServer[]>(backendUrl, "/api/servers")
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
setLiveServers(data.map(s => ({
|
||||
uid: String(s.id),
|
||||
@@ -462,7 +464,7 @@ export default function TerminalPage() {
|
||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, refreshKey])
|
||||
}, [isLive, backendUrl, refreshKey, prefsHydrated])
|
||||
|
||||
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
|
||||
|
||||
|
||||
+17
-11
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
+485
-412
@@ -1,39 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, 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 { servers as mockServers } from "@/lib/data"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import {
|
||||
WireguardDataGrid,
|
||||
type WgIfaceWithServer,
|
||||
} from "@/components/data-grids/wireguard-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
createWireGuardInterface,
|
||||
createWireGuardPeer,
|
||||
deleteWireGuardInterface,
|
||||
deleteWireGuardPeer,
|
||||
exportWireGuard,
|
||||
importWireGuard,
|
||||
listWireGuard,
|
||||
patchWireGuardInterface,
|
||||
} from "@/shared/api/wireguard"
|
||||
import type { WgIfaceDto } from "@mmapp/contracts/wireguard"
|
||||
import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg-create-sheet"
|
||||
import { WgImportSheet } from "@/components/wireguard/wg-import-sheet"
|
||||
import { WgExportSheet } from "@/components/wireguard/wg-export-sheet"
|
||||
import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet"
|
||||
import { toast } from "sonner"
|
||||
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,
|
||||
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── collect all WireGuard interfaces from all servers ────────────────────────
|
||||
|
||||
interface WgIfaceWithServer extends WireGuardInterface {
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverCountry: string
|
||||
}
|
||||
|
||||
function collectInterfaces(): WgIfaceWithServer[] {
|
||||
function collectMockInterfaces(): WgIfaceWithServer[] {
|
||||
const result: WgIfaceWithServer[] = []
|
||||
for (const srv of servers) {
|
||||
for (const srv of mockServers) {
|
||||
for (const wg of srv.wireGuardIfaces ?? []) {
|
||||
result.push({
|
||||
...wg,
|
||||
@@ -46,293 +52,341 @@ function collectInterfaces(): WgIfaceWithServer[] {
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── 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 {
|
||||
const lines: string[] = []
|
||||
lines.push(`# WireGuard — ${iface.name} · ${iface.serverName}`)
|
||||
lines.push(`# RouterOS 7.x`)
|
||||
lines.push(``)
|
||||
lines.push(`/interface wireguard add \\`)
|
||||
lines.push(` name=${iface.name} \\`)
|
||||
lines.push(` listen-port=${iface.listenPort} \\`)
|
||||
lines.push(` mtu=${iface.mtu} \\`)
|
||||
if (iface.comment) lines.push(` comment="${iface.comment}" \\`)
|
||||
if (!iface.enabled) lines.push(` disabled=yes \\`)
|
||||
lines.push(``)
|
||||
for (const p of iface.peers) {
|
||||
lines.push(`/interface wireguard peers add \\`)
|
||||
lines.push(` interface=${iface.name} \\`)
|
||||
lines.push(` public-key="${p.publicKey}" \\`)
|
||||
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
|
||||
if (p.endpoint) lines.push(` endpoint-address=${p.endpoint.split(":")[0]} \\`)
|
||||
if (p.endpoint) lines.push(` endpoint-port=${p.endpoint.split(":")[1] ?? "13231"} \\`)
|
||||
if (p.persistent) lines.push(` persistent-keepalive=25 \\`)
|
||||
if (p.comment) lines.push(` comment="${p.comment}" \\`)
|
||||
lines.push(``)
|
||||
function dtoToRow(d: WgIfaceDto): WgIfaceWithServer {
|
||||
return {
|
||||
id: d.id,
|
||||
rosId: d.rosId,
|
||||
name: d.name,
|
||||
listenPort: d.listenPort,
|
||||
mtu: d.mtu,
|
||||
publicKey: d.publicKey,
|
||||
privateKey: d.privateKey,
|
||||
address: d.address,
|
||||
peers: d.peers.map((p) => ({
|
||||
id: p.id,
|
||||
rosId: p.rosId,
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedIps,
|
||||
endpoint: p.endpoint,
|
||||
latestHandshake: p.latestHandshake,
|
||||
transferRx: p.transferRx,
|
||||
transferTx: p.transferTx,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
persistent: p.persistent,
|
||||
comment: p.comment,
|
||||
disabled: p.disabled,
|
||||
name: p.name,
|
||||
clientAddress: p.clientAddress,
|
||||
clientDns: p.clientDns,
|
||||
clientEndpoint: p.clientEndpoint,
|
||||
})),
|
||||
comment: d.comment,
|
||||
enabled: d.enabled,
|
||||
status: d.status,
|
||||
serverId: d.serverId,
|
||||
serverName: d.serverName,
|
||||
serverCountry: d.serverCountry ?? "UN",
|
||||
}
|
||||
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 BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
country: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// ─── 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>
|
||||
)
|
||||
function mapBackendServer(s: BackendServer): Server {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: "—",
|
||||
os: "—",
|
||||
site: "",
|
||||
country: s.country || "UN",
|
||||
asn: "",
|
||||
type: "exit-node",
|
||||
enabled: s.enabled,
|
||||
status: "online",
|
||||
latency: null,
|
||||
sessions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ExportSheet({ open, iface, onClose }: {
|
||||
open: boolean; iface: WgIfaceWithServer | null; onClose: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const code = useMemo(() => iface ? generateWgRsc(iface) : "", [iface])
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
function parseEndpoint(endpoint: string): { address?: string; port?: number } {
|
||||
const t = endpoint.trim()
|
||||
if (!t) return {}
|
||||
const idx = t.lastIndexOf(":")
|
||||
if (idx <= 0) return { address: t }
|
||||
return {
|
||||
address: t.slice(0, idx),
|
||||
port: Number.parseInt(t.slice(idx + 1), 10) || undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle>Экспорт WireGuard</SheetTitle>
|
||||
<SheetDescription>RouterOS 7.x · /interface wireguard + peers</SheetDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
||||
{copied
|
||||
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
|
||||
: <><CopyIcon className="size-3.5" />Копировать</>}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
||||
{code.split("\n").map((line, i) => {
|
||||
const isComment = line.startsWith("#")
|
||||
const isCmd = line.trimStart().startsWith("/interface")
|
||||
const isParam = /^\s+[a-z]/.test(line)
|
||||
return (
|
||||
<span key={i} className={
|
||||
isComment ? "text-muted-foreground"
|
||||
: isCmd ? "text-sky-400"
|
||||
: isParam ? "text-violet-300"
|
||||
: "text-foreground"
|
||||
}>
|
||||
{line}{"\n"}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||||
<Button className="flex-1" onClick={handleCopy}>
|
||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function WireGuardPage() {
|
||||
const allIfaces = useMemo(() => collectInterfaces(), [])
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
||||
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
|
||||
const [liveExport, setLiveExport] = useState<{
|
||||
rsc?: string
|
||||
conf?: string
|
||||
peerConf?: string
|
||||
} | null>(null)
|
||||
const [exportBusy, setExportBusy] = useState(false)
|
||||
|
||||
const loadLive = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const [wg, servers] = await Promise.all([
|
||||
listWireGuard(backendUrl),
|
||||
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
||||
])
|
||||
setLiveIfaces(wg.interfaces.map(dtoToRow))
|
||||
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
|
||||
if (wg.failures?.length) {
|
||||
toast.warning(
|
||||
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
|
||||
setLiveIfaces([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isLive, backendUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => {
|
||||
setLiveIfaces([])
|
||||
setLiveServers([])
|
||||
})
|
||||
return
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void loadLive()
|
||||
})
|
||||
}, [isLive, loadLive])
|
||||
|
||||
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
|
||||
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return allIfaces
|
||||
if (!search) return displayIfaces
|
||||
const q = search.toLowerCase()
|
||||
return allIfaces.filter((i) =>
|
||||
i.name.includes(q) ||
|
||||
i.serverName.toLowerCase().includes(q) ||
|
||||
i.peers.some((p) => p.allowedIps.some((a) => a.includes(q)) || (p.endpoint ?? "").includes(q))
|
||||
return displayIfaces.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(q) ||
|
||||
i.serverName.toLowerCase().includes(q) ||
|
||||
i.peers.some(
|
||||
(p) =>
|
||||
p.allowedIps.some((a) => a.includes(q)) ||
|
||||
(p.endpoint ?? "").includes(q),
|
||||
),
|
||||
)
|
||||
}, [allIfaces, search])
|
||||
}, [displayIfaces, search])
|
||||
|
||||
const totalPeers = allIfaces.reduce((s, i) => s + i.peers.length, 0)
|
||||
const onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0)
|
||||
const upIfaces = allIfaces.filter((i) => i.status === "up").length
|
||||
const totalPeers = displayIfaces.reduce((s, i) => s + i.peers.length, 0)
|
||||
const onlinePeers = displayIfaces.reduce(
|
||||
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length,
|
||||
0,
|
||||
)
|
||||
const upIfaces = displayIfaces.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
|
||||
})
|
||||
const serverOptions = displayServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
}))
|
||||
|
||||
async function handleCreate(form: WgCreateFormState) {
|
||||
if (!isLive) {
|
||||
toast.info("Создание на роутер доступно только в live-режиме")
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const ep = parseEndpoint(form.peerEndpoint)
|
||||
await createWireGuardInterface(backendUrl, {
|
||||
serverId: form.serverId,
|
||||
name: form.name.trim(),
|
||||
listenPort: Number.parseInt(form.listenPort, 10) || 13231,
|
||||
mtu: Number.parseInt(form.mtu, 10) || 1420,
|
||||
comment: form.comment || undefined,
|
||||
address: form.address.trim() || undefined,
|
||||
disabled: !form.enabled,
|
||||
peer: form.peerEnabled && form.peerPublicKey.trim()
|
||||
? {
|
||||
publicKey: form.peerPublicKey.trim(),
|
||||
allowedAddresses: form.peerAllowedIps
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
endpointAddress: ep.address,
|
||||
endpointPort: ep.port,
|
||||
persistentKeepalive: Number.parseInt(form.peerKeepalive, 10) || undefined,
|
||||
comment: form.peerComment || undefined,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
toast.success(`Интерфейс ${form.name} создан`)
|
||||
setCreateOpen(false)
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка создания")
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(args: {
|
||||
serverId: string
|
||||
content: string
|
||||
format: "auto" | "rsc" | "conf"
|
||||
dryRun: boolean
|
||||
}) {
|
||||
if (!isLive) {
|
||||
toast.info("Импорт на роутер доступен только в live-режиме")
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await importWireGuard(backendUrl, {
|
||||
serverId: args.serverId,
|
||||
content: args.content,
|
||||
format: args.format,
|
||||
dryRun: args.dryRun,
|
||||
})
|
||||
toast.success(
|
||||
res.applied
|
||||
? `Импортировано: ${res.applied.interfaceName} (+${res.applied.peersCreated} пиров)`
|
||||
: "Импорт выполнен",
|
||||
)
|
||||
setImportOpen(false)
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка импорта")
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(iface: WgIfaceWithServer) {
|
||||
if (!isLive || !iface.rosId) {
|
||||
toast.info("Доступно только в live-режиме")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, {
|
||||
disabled: iface.enabled,
|
||||
})
|
||||
toast.success(iface.enabled ? "Отключено" : "Включено")
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(iface: WgIfaceWithServer) {
|
||||
if (!isLive || !iface.rosId) {
|
||||
toast.info("Доступно только в live-режиме")
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`Удалить интерфейс ${iface.name} на ${iface.serverName}?`)) return
|
||||
try {
|
||||
await deleteWireGuardInterface(backendUrl, iface.serverId, iface.rosId)
|
||||
toast.success("Удалено")
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка удаления")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddPeer(form: WgPeerFormState) {
|
||||
if (!isLive || !peerIface) {
|
||||
toast.info("Доступно только в live-режиме")
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const ep = parseEndpoint(form.endpoint)
|
||||
await createWireGuardPeer(backendUrl, {
|
||||
serverId: peerIface.serverId,
|
||||
interfaceName: peerIface.name,
|
||||
publicKey: form.publicKey.trim(),
|
||||
allowedAddresses: form.allowedIps
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
endpointAddress: ep.address,
|
||||
endpointPort: ep.port,
|
||||
persistentKeepalive: Number.parseInt(form.keepalive, 10) || undefined,
|
||||
comment: form.comment || undefined,
|
||||
})
|
||||
toast.success("Пир добавлен")
|
||||
setPeerIface(null)
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка")
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePeer(iface: WgIfaceWithServer, peerId: string) {
|
||||
if (!isLive) {
|
||||
toast.info("Доступно только в live-режиме")
|
||||
return
|
||||
}
|
||||
if (!window.confirm("Удалить пира?")) return
|
||||
try {
|
||||
await deleteWireGuardPeer(backendUrl, iface.serverId, peerId)
|
||||
toast.success("Пир удалён")
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
|
||||
if (!exportIface || !isLive) return
|
||||
setExportBusy(true)
|
||||
try {
|
||||
const res = await exportWireGuard(backendUrl, {
|
||||
serverId: exportIface.serverId,
|
||||
interfaceName: exportIface.name,
|
||||
format,
|
||||
includePrivateKey: format !== "peer-conf",
|
||||
})
|
||||
setLiveExport((prev) => ({
|
||||
...prev,
|
||||
...(format === "rsc"
|
||||
? { rsc: res.content }
|
||||
: format === "conf"
|
||||
? { conf: res.content }
|
||||
: { peerConf: res.content }),
|
||||
}))
|
||||
toast.success("Конфиг загружен с роутера")
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка экспорта")
|
||||
} finally {
|
||||
setExportBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -341,8 +395,24 @@ export default function WireGuardPage() {
|
||||
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm">
|
||||
<PlusIcon className="size-4" />Новый интерфейс
|
||||
{isLive && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={() => void loadLive()}
|
||||
>
|
||||
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Обновить
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />
|
||||
Импорт
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon className="size-4" />
|
||||
Новый интерфейс
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -350,142 +420,145 @@ export default function WireGuardPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* KPI */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Интерфейсов", value: allIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
|
||||
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "Интерфейсов", value: displayIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> },
|
||||
{ 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>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
||||
<ShieldCheckIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-sky-600 dark:text-sky-400">WireGuard — рекомендуемый туннельный протокол в RouterOS 7.x</p>
|
||||
<p className="font-medium text-sky-600 dark:text-sky-400">
|
||||
WireGuard — live-интеграция RouterOS 7.x
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Доступен с RouterOS 7.1+. Более высокая производительность и безопасность по сравнению с GRE+IPsec.
|
||||
Ключи генерируются командой <code className="font-mono bg-muted px-1 rounded">/interface/wireguard/print</code>.
|
||||
{isLive
|
||||
? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера."
|
||||
: "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."}
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, серверу, IP…"
|
||||
countLabel={`${filtered.length} интерфейсов`}
|
||||
/>
|
||||
<WireguardDataGrid
|
||||
interfaces={filtered}
|
||||
onExport={(iface) => {
|
||||
setLiveExport(null)
|
||||
setExportIface(iface)
|
||||
}}
|
||||
onAddPeer={setPeerIface}
|
||||
onToggle={handleToggle}
|
||||
onDelete={handleDelete}
|
||||
onDeletePeer={handleDeletePeer}
|
||||
onExportPeer={(iface) => {
|
||||
setLiveExport(null)
|
||||
setExportIface(iface)
|
||||
}}
|
||||
/>
|
||||
</DataPageCard>
|
||||
|
||||
<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">
|
||||
{[
|
||||
{
|
||||
title: "Создать интерфейс",
|
||||
lines: [
|
||||
"/interface wireguard add \\",
|
||||
" name=wg0 \\",
|
||||
" listen-port=13231 \\",
|
||||
" mtu=1420",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Добавить пира",
|
||||
lines: [
|
||||
"/interface wireguard peers add \\",
|
||||
" interface=wg0 \\",
|
||||
' public-key="<ключ>" \\',
|
||||
" allowed-address=10.0.0.2/32 \\",
|
||||
" endpoint-address=1.2.3.4 \\",
|
||||
" persistent-keepalive=25",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Назначить IP",
|
||||
lines: [
|
||||
"/ip address add \\",
|
||||
" address=10.210.0.1/30 \\",
|
||||
" interface=wg0",
|
||||
"",
|
||||
"# Статус:",
|
||||
"/interface wireguard print",
|
||||
],
|
||||
},
|
||||
].map((b) => (
|
||||
<div key={b.title}>
|
||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
|
||||
{b.title}
|
||||
</p>
|
||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
|
||||
{b.lines.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||
{[
|
||||
{
|
||||
title: "Создать интерфейс",
|
||||
lines: [
|
||||
"/interface wireguard add \\",
|
||||
" name=wg0 \\",
|
||||
" listen-port=13231 \\",
|
||||
" mtu=1420",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Добавить пира",
|
||||
lines: [
|
||||
"/interface wireguard peers add \\",
|
||||
" interface=wg0 \\",
|
||||
' public-key="<ключ>" \\',
|
||||
" allowed-address=10.0.0.2/32 \\",
|
||||
" endpoint-address=1.2.3.4 \\",
|
||||
" persistent-keepalive=25",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Назначить IP",
|
||||
lines: [
|
||||
"/ip address add \\",
|
||||
" address=10.210.0.1/30 \\",
|
||||
" interface=wg0",
|
||||
"",
|
||||
"# Статус:",
|
||||
"/interface wireguard print",
|
||||
],
|
||||
},
|
||||
].map((b) => (
|
||||
<div key={b.title}>
|
||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
|
||||
{b.lines.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</OpsPanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExportSheet
|
||||
<WgCreateSheet
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
servers={serverOptions}
|
||||
busy={busy}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
<WgImportSheet
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
servers={serverOptions}
|
||||
busy={busy}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
<WgPeerSheet
|
||||
open={!!peerIface}
|
||||
iface={peerIface}
|
||||
busy={busy}
|
||||
onOpenChange={(v) => { if (!v) setPeerIface(null) }}
|
||||
onSubmit={handleAddPeer}
|
||||
/>
|
||||
<WgExportSheet
|
||||
open={!!exportIface}
|
||||
iface={exportIface}
|
||||
onClose={() => setExportIface(null)}
|
||||
onClose={() => {
|
||||
setExportIface(null)
|
||||
setLiveExport(null)
|
||||
}}
|
||||
liveContent={liveExport}
|
||||
liveBusy={exportBusy}
|
||||
onRequestLiveExport={isLive ? handleLiveExport : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
export default function AccessDeniedPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<h1 className="text-lg font-semibold">Нет доступа</h1>
|
||||
<p className="text-muted-foreground max-w-md text-sm">
|
||||
У вашей учётной записи нет приложения MikrotikManager (`mm`) или
|
||||
необходимых прав. Обратитесь к администратору auth-portal.
|
||||
</p>
|
||||
<a
|
||||
href="/auth/callback"
|
||||
className="text-primary text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Войти снова
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
})
|
||||
}
|
||||
@@ -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" })
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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%);
|
||||
|
||||
@@ -6,3 +6,10 @@ PORT=8000
|
||||
|
||||
# Allowed CORS origin (Next.js frontend)
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Portal SSO (false = open API for local/dev)
|
||||
AUTH_REQUIRED=false
|
||||
# Same HS256 secret as auth-portal JWT_SECRET when AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=dev-secret-change-me
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=http://localhost:5175
|
||||
|
||||
+19
-2
@@ -5,10 +5,23 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
# Do not set NODE_ENV=production here — npm would omit typescript needed for the build stage.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/contracts/package.json packages/contracts/
|
||||
COPY backend/package.json backend/
|
||||
RUN npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --include-workspace-root --ignore-scripts \
|
||||
# Drop root frontend deps (Next/React/UI) so backend image stays lean.
|
||||
RUN node -e "\
|
||||
const fs=require('fs');\
|
||||
const p=JSON.parse(fs.readFileSync('package.json','utf8'));\
|
||||
p.dependencies={};\
|
||||
p.devDependencies={};\
|
||||
delete p.scripts;\
|
||||
p.workspaces=['packages/*','backend'];\
|
||||
fs.writeFileSync('package.json', JSON.stringify(p,null,2)+'\\n');\
|
||||
"
|
||||
# Prefer npm ci; if lockfile rejects stripped root package.json, fall back to install.
|
||||
RUN (npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts \
|
||||
|| npm install --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts) \
|
||||
&& npm rebuild better-sqlite3
|
||||
|
||||
FROM deps AS build
|
||||
@@ -18,7 +31,9 @@ COPY packages/contracts packages/contracts
|
||||
COPY backend backend
|
||||
RUN npm run build -w @mmapp/contracts \
|
||||
&& npm run build -w mikrotik-manager-backend \
|
||||
&& npm prune --omit=dev
|
||||
&& npm prune --omit=dev \
|
||||
# npm may nest workspace deps (e.g. dotenv) under backend/node_modules — keep dir for COPY
|
||||
&& mkdir -p backend/node_modules
|
||||
|
||||
FROM node:22-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
@@ -32,6 +47,8 @@ COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/packages/contracts ./packages/contracts
|
||||
COPY --from=build /app/backend/dist ./backend/dist
|
||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
||||
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||
RUN mkdir -p /app/data
|
||||
EXPOSE 8000
|
||||
CMD ["node", "backend/dist/index.js"]
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
"@mmapp/contracts": "1.0.0",
|
||||
"acme-client": "^5.4.0",
|
||||
@@ -21,7 +24,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fastify": "^5.8.5",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"undici": "^8.1.0",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
@@ -29,6 +32,8 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.15.3",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jose": "^6.2.11",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
+46
-2
@@ -3,17 +3,61 @@ import { z } from "zod"
|
||||
|
||||
config()
|
||||
|
||||
function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
||||
if (v === undefined || v === "") return fallback
|
||||
return v === "1" || v.toLowerCase() === "true"
|
||||
}
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production"
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_PATH: z.string().default("./mikrotik.db"),
|
||||
PORT: z.coerce.number().int().positive().default(8000),
|
||||
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
||||
AUTH_REQUIRED: z.boolean().default(false),
|
||||
AUTH_JWT_SECRET: z.string().default(""),
|
||||
AUTH_ISSUER: z.string().default("https://auth.shnt.top"),
|
||||
AUTH_PORTAL_URL: z.string().default("http://localhost:5175"),
|
||||
})
|
||||
|
||||
const parsed = envSchema.safeParse(process.env)
|
||||
const raw = {
|
||||
DATABASE_PATH: process.env.DATABASE_PATH,
|
||||
PORT: process.env.PORT,
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
||||
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||
AUTH_JWT_SECRET:
|
||||
process.env.AUTH_JWT_SECRET?.trim() ||
|
||||
process.env.JWT_SECRET?.trim() ||
|
||||
(isProd ? "" : "dev-secret-change-me"),
|
||||
AUTH_ISSUER:
|
||||
process.env.AUTH_ISSUER?.trim() ||
|
||||
process.env.ISSUER?.trim() ||
|
||||
"https://auth.shnt.top",
|
||||
AUTH_PORTAL_URL: (
|
||||
process.env.AUTH_PORTAL_URL ??
|
||||
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL ??
|
||||
"http://localhost:5175"
|
||||
).replace(/\/$/, ""),
|
||||
}
|
||||
|
||||
const parsed = envSchema.safeParse(raw)
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export const env = parsed.data
|
||||
if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
|
||||
console.error("❌ AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export const env = {
|
||||
DATABASE_PATH: parsed.data.DATABASE_PATH,
|
||||
PORT: parsed.data.PORT,
|
||||
CORS_ORIGIN: parsed.data.CORS_ORIGIN,
|
||||
authRequired: parsed.data.AUTH_REQUIRED,
|
||||
jwtSecret: parsed.data.AUTH_JWT_SECRET || "dev-secret-change-me",
|
||||
authIssuer: parsed.data.AUTH_ISSUER,
|
||||
authPortalUrl: parsed.data.AUTH_PORTAL_URL,
|
||||
}
|
||||
|
||||
+103
-60
@@ -1,11 +1,12 @@
|
||||
import Fastify from "fastify"
|
||||
import Fastify, { type FastifyInstance } from "fastify"
|
||||
import cors from "@fastify/cors"
|
||||
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
||||
import { env } from "./config.js"
|
||||
import authPlugin, { requireAuth } from "./plugins/auth.js"
|
||||
import serversRoutes from "./routes/servers.js"
|
||||
import bgpRoutes from "./routes/bgp.js"
|
||||
import ospfRoutes from "./routes/ospf.js"
|
||||
import execRoutes from "./routes/exec.js"
|
||||
import bgpRoutes from "./routes/bgp.js"
|
||||
import ospfRoutes from "./routes/ospf.js"
|
||||
import execRoutes from "./routes/exec.js"
|
||||
import filtersRoutes from "./routes/filters.js"
|
||||
import recursiveRoutes from "./routes/recursive-routes.js"
|
||||
import trafficRoutes from "./routes/traffic.js"
|
||||
@@ -22,71 +23,113 @@ import backupsRoutes from "./routes/backups.js"
|
||||
import certificatesRoutes from "./routes/certificates.js"
|
||||
import systemDatabaseRoutes from "./routes/system-database.js"
|
||||
import eventsRoutes from "./routes/events.js"
|
||||
import wireguardRoutes from "./routes/wireguard.js"
|
||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||
|
||||
// ── app factory ────────────────────────────────────────────────────────────────
|
||||
export async function buildApp(opts?: {
|
||||
logger?: boolean
|
||||
startScheduler?: boolean
|
||||
}): Promise<FastifyInstance> {
|
||||
const usePrettyLogger =
|
||||
opts?.logger !== false && process.env.NODE_ENV !== "production"
|
||||
const app = Fastify({
|
||||
bodyLimit: 512 * 1024 * 1024,
|
||||
requestTimeout: 10 * 60 * 1000,
|
||||
logger:
|
||||
opts?.logger === false
|
||||
? false
|
||||
: usePrettyLogger
|
||||
? {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "HH:MM:ss",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
},
|
||||
}
|
||||
: true,
|
||||
})
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
|
||||
},
|
||||
},
|
||||
})
|
||||
app.setValidatorCompiler(validatorCompiler)
|
||||
app.setSerializerCompiler(serializerCompiler)
|
||||
|
||||
// Use Zod for request validation and response serialization
|
||||
app.setValidatorCompiler(validatorCompiler)
|
||||
app.setSerializerCompiler(serializerCompiler)
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
})
|
||||
|
||||
// CORS — allow Next.js frontend
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
/** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
})
|
||||
await app.register(authPlugin)
|
||||
|
||||
// ── routes ─────────────────────────────────────────────────────────────────────
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.APP_VERSION ?? "dev",
|
||||
}))
|
||||
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.APP_VERSION ?? "dev",
|
||||
}))
|
||||
app.get("/api/auth/config", async () => ({
|
||||
required: env.authRequired,
|
||||
portal_url: env.authPortalUrl,
|
||||
issuer: env.authIssuer,
|
||||
}))
|
||||
|
||||
await app.register(serversRoutes, { prefix: "/api/servers" })
|
||||
await app.register(bgpRoutes, { prefix: "/api" })
|
||||
await app.register(ospfRoutes, { prefix: "/api" })
|
||||
await app.register(execRoutes, { prefix: "/api" })
|
||||
await app.register(filtersRoutes, { prefix: "/api" })
|
||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||
await app.register(trafficRoutes, { prefix: "/api" })
|
||||
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||
await app.register(networkRoutes, { prefix: "/api" })
|
||||
await app.register(internetPathRoutes, { prefix: "/api" })
|
||||
await app.register(evobgpRoutes, { prefix: "/api" })
|
||||
await app.register(probesRoutes, { prefix: "/api" })
|
||||
await app.register(schedulerRoutes, { prefix: "/api" })
|
||||
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
||||
await app.register(alertsRoutes, { prefix: "/api" })
|
||||
await app.register(backupsRoutes, { prefix: "/api" })
|
||||
await app.register(certificatesRoutes, { prefix: "/api" })
|
||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||
await app.register(eventsRoutes, { prefix: "/api" })
|
||||
if (env.authRequired) {
|
||||
app.addHook("preHandler", async (request, reply) => {
|
||||
const pathname = request.url.split("?")[0] ?? request.url
|
||||
if (!pathname.startsWith("/api/")) return
|
||||
if (pathname === "/api/auth/config") return
|
||||
await requireAuth(request, reply)
|
||||
if (reply.sent) return
|
||||
})
|
||||
}
|
||||
|
||||
refreshScheduler()
|
||||
app.addHook("onClose", async () => {
|
||||
stopScheduler()
|
||||
})
|
||||
await app.register(serversRoutes, { prefix: "/api/servers" })
|
||||
await app.register(bgpRoutes, { prefix: "/api" })
|
||||
await app.register(ospfRoutes, { prefix: "/api" })
|
||||
await app.register(execRoutes, { prefix: "/api" })
|
||||
await app.register(filtersRoutes, { prefix: "/api" })
|
||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||
await app.register(trafficRoutes, { prefix: "/api" })
|
||||
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||
await app.register(networkRoutes, { prefix: "/api" })
|
||||
await app.register(internetPathRoutes, { prefix: "/api" })
|
||||
await app.register(evobgpRoutes, { prefix: "/api" })
|
||||
await app.register(probesRoutes, { prefix: "/api" })
|
||||
await app.register(schedulerRoutes, { prefix: "/api" })
|
||||
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
||||
await app.register(alertsRoutes, { prefix: "/api" })
|
||||
await app.register(backupsRoutes, { prefix: "/api" })
|
||||
await app.register(certificatesRoutes, { prefix: "/api" })
|
||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||
await app.register(eventsRoutes, { prefix: "/api" })
|
||||
await app.register(wireguardRoutes, { prefix: "/api" })
|
||||
|
||||
// ── start ──────────────────────────────────────────────────────────────────────
|
||||
if (opts?.startScheduler !== false) {
|
||||
refreshScheduler()
|
||||
app.addHook("onClose", async () => {
|
||||
stopScheduler()
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await app.listen({ port: env.PORT, host: "0.0.0.0" })
|
||||
console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
|
||||
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
return app
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
(process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js"))
|
||||
|
||||
if (isMain) {
|
||||
try {
|
||||
const app = await buildApp()
|
||||
await app.listen({ port: env.PORT, host: "0.0.0.0" })
|
||||
console.log(
|
||||
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
|
||||
)
|
||||
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/wireguard"),
|
||||
"mm:network:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("POST", "/api/wireguard/interfaces"),
|
||||
"mm:network:write",
|
||||
)
|
||||
|
||||
console.log("permissions.test.ts: ok")
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
|
||||
* Format: mm:<section>:<read|write|admin>
|
||||
*/
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
granted: readonly string[],
|
||||
required: string,
|
||||
): boolean {
|
||||
if (granted.includes(required)) return true
|
||||
const parts = required.split(":")
|
||||
if (parts.length !== 3) return false
|
||||
const [app, section, action] = parts
|
||||
if (action === "read") {
|
||||
return (
|
||||
granted.includes(`${app}:${section}:write`) ||
|
||||
granted.includes(`${app}:${section}:admin`)
|
||||
)
|
||||
}
|
||||
if (action === "write") {
|
||||
return granted.includes(`${app}:${section}:admin`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
methods: string[]
|
||||
match: (path: string) => boolean
|
||||
permission: string
|
||||
}
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/system") ||
|
||||
p.startsWith("/api/scheduler") ||
|
||||
p.startsWith("/api/evobgp"),
|
||||
permission: "mm:settings:admin",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
|
||||
permission: "mm:dashboard:read",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/servers"),
|
||||
permission: "mm:servers:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/servers"),
|
||||
permission: "mm:servers:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/filters"),
|
||||
permission: "mm:filters:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/filters"),
|
||||
permission: "mm:filters:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/bgp"),
|
||||
permission: "mm:bgp:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/bgp"),
|
||||
permission: "mm:bgp:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/uptime"),
|
||||
permission: "mm:uptime:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/uptime"),
|
||||
permission: "mm:uptime:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/traffic"),
|
||||
permission: "mm:traffic:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/traffic"),
|
||||
permission: "mm:traffic:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/alerts"),
|
||||
permission: "mm:alerts:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/alerts"),
|
||||
permission: "mm:alerts:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/backups"),
|
||||
permission: "mm:backups:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/backups"),
|
||||
permission: "mm:backups:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/certificates"),
|
||||
permission: "mm:certificates:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/certificates"),
|
||||
permission: "mm:certificates:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/network") ||
|
||||
p.startsWith("/api/ospf") ||
|
||||
p.startsWith("/api/recursive") ||
|
||||
p.startsWith("/api/probes") ||
|
||||
p.startsWith("/api/internet-path") ||
|
||||
p.startsWith("/api/exec") ||
|
||||
p.startsWith("/api/wireguard"),
|
||||
permission: "mm:network:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/network") ||
|
||||
p.startsWith("/api/ospf") ||
|
||||
p.startsWith("/api/recursive") ||
|
||||
p.startsWith("/api/probes") ||
|
||||
p.startsWith("/api/internet-path") ||
|
||||
p.startsWith("/api/exec") ||
|
||||
p.startsWith("/api/wireguard"),
|
||||
permission: "mm:network:write",
|
||||
},
|
||||
]
|
||||
|
||||
/** Resolve required permission for method+path, or null if public / unknown. */
|
||||
export function permissionForRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
): string | null {
|
||||
const m = method.toUpperCase()
|
||||
const pathname = path.split("?")[0] ?? path
|
||||
for (const rule of RULES) {
|
||||
if (!rule.methods.includes(m)) continue
|
||||
if (rule.match(pathname)) return rule.permission
|
||||
}
|
||||
if (pathname.startsWith("/api/")) return "mm:dashboard:read"
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Smoke: AUTH_REQUIRED gate via Fastify inject.
|
||||
* Run: AUTH_REQUIRED=true AUTH_JWT_SECRET=test-secret-at-least-8 tsx src/plugins/auth.smoke.test.ts
|
||||
*/
|
||||
import assert from "node:assert/strict"
|
||||
import { SignJWT } from "jose"
|
||||
|
||||
process.env.AUTH_REQUIRED = "true"
|
||||
process.env.AUTH_JWT_SECRET = "test-secret-at-least-8"
|
||||
process.env.AUTH_ISSUER = "https://auth.test.local"
|
||||
process.env.AUTH_PORTAL_URL = "http://localhost:5175"
|
||||
process.env.CORS_ORIGIN = "http://localhost:3000"
|
||||
process.env.DATABASE_PATH = ":memory:"
|
||||
process.env.NODE_ENV = "test"
|
||||
|
||||
// Dynamic import after env is set
|
||||
const { buildApp } = await import("../index.js")
|
||||
|
||||
const secret = new TextEncoder().encode("test-secret-at-least-8")
|
||||
|
||||
async function mint(payload: Record<string, unknown>): Promise<string> {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuer("https://auth.test.local")
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret)
|
||||
}
|
||||
|
||||
const app = await buildApp({ logger: false, startScheduler: false })
|
||||
|
||||
const health = await app.inject({ method: "GET", url: "/health" })
|
||||
assert.equal(health.statusCode, 200)
|
||||
|
||||
const cfg = await app.inject({ method: "GET", url: "/api/auth/config" })
|
||||
assert.equal(cfg.statusCode, 200)
|
||||
assert.equal(cfg.json().required, true)
|
||||
|
||||
const noToken = await app.inject({ method: "GET", url: "/api/sidebar-counts" })
|
||||
assert.equal(noToken.statusCode, 401)
|
||||
|
||||
const badApp = await mint({
|
||||
sub: "u1",
|
||||
email: "[email protected]",
|
||||
name: "A",
|
||||
apps: ["cdn"],
|
||||
permissions: ["cdn:dashboard:read"],
|
||||
})
|
||||
const forbiddenApp = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/sidebar-counts",
|
||||
headers: { authorization: `Bearer ${badApp}` },
|
||||
})
|
||||
assert.equal(forbiddenApp.statusCode, 403)
|
||||
|
||||
const okToken = await mint({
|
||||
sub: "u1",
|
||||
email: "[email protected]",
|
||||
name: "A",
|
||||
apps: ["mm"],
|
||||
permissions: ["mm:dashboard:read"],
|
||||
})
|
||||
const ok = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/sidebar-counts",
|
||||
headers: { authorization: `Bearer ${okToken}` },
|
||||
})
|
||||
// May be 200 or 500 if DB missing — must not be 401/403
|
||||
assert.notEqual(ok.statusCode, 401)
|
||||
assert.notEqual(ok.statusCode, 403)
|
||||
|
||||
await app.close()
|
||||
console.log("auth.smoke.test.ts: ok")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user