Compare commits

..
3721 changed files with 434287 additions and 26527 deletions

No files matched your search

-67
View File
@@ -1,67 +0,0 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
-43
View File
@@ -1,43 +0,0 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
## Operational surfaces (settings / profile / admin)
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
-60
View File
@@ -1,60 +0,0 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
-408
View File
@@ -1,408 +0,0 @@
# ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useTable({
features: dataGridFeatures,
data,
columns,
})
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FramePanel>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<div className="p-5">Content</div>
<FrameFooter>Footer</FrameFooter>
</FramePanel>
</Frame>
```
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
## base vs radix - write for the project's base
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
- `"style": "base-nova"` -> **Base UI**
- `"style": "radix-nova"` -> **Radix UI**
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
-45
View File
@@ -1,45 +0,0 @@
# Craft: make ReUI UI exceptional, not generic
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
## Have a point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
## Brutally clear hierarchy
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
## Spacing rhythm and deliberate density
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
## Cover the real states (the usual miss)
A surface isn't done at the happy path. Compose, and wire:
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
## Responsive by default
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
## Motion, subtly
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
## Real, activated content
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
## Avoid the AI tells
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
## The bar
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
-39
View File
@@ -1,39 +0,0 @@
# Icons (ReUI delta over shadcn)
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remix` -> `@remixicon/react`
- `hugeicons` -> `@hugeicons/react`
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
```
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
The `icon-stack` component composes multiple icons into a stacked display.
-22
View File
@@ -1,22 +0,0 @@
# Quality gates (security, accessibility, scroll)
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
-35
View File
@@ -1,35 +0,0 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
## Free vs premium
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
-26
View File
@@ -1,26 +0,0 @@
# Styling (ReUI delta over shadcn)
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
## ReUI extended semantic tokens
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
- `--success` / `--success-foreground`
- `--info` / `--info-foreground`
- `--warning` / `--warning-foreground`
- `--destructive-foreground` (paired with shadcn's `--destructive`)
- `--invert` / `--invert-foreground` (inverted surfaces)
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
**Incorrect:** `<span className="text-green-600">Active</span>`
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
## Blocks follow your theme
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
## Density and typography rhythm
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
-54
View File
@@ -1,54 +0,0 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
-58
View File
@@ -1,58 +0,0 @@
# 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
+14 -4
View File
@@ -54,6 +54,12 @@ function formatSemver(parts) {
function bumpVersion(version, bump) { function bumpVersion(version, bump) {
const parts = parseSemver(version) const parts = parseSemver(version)
if (bump === "major") {
parts.major += 1
parts.minor = 0
parts.patch = 0
return formatSemver(parts)
}
if (bump === "minor") { if (bump === "minor") {
parts.minor += 1 parts.minor += 1
parts.patch = 0 parts.patch = 0
@@ -66,8 +72,8 @@ function bumpVersion(version, bump) {
function parseCommit(subject, body = "") { function parseCommit(subject, body = "") {
const match = CONVENTIONAL_RE.exec(subject.trim()) const match = CONVENTIONAL_RE.exec(subject.trim())
const breaking = const breaking =
/BREAKING CHANGE/i.test(body) || /BREAKING[ -]CHANGE:/i.test(body) ||
(match?.[1] && subject.includes(`${match[1]}!:`)) (match !== null && /^[^:]+!:\s/.test(subject.trim()))
if (!match) { if (!match) {
return { type: "other", scope: null, description: subject.trim(), bump: "patch" } return { type: "other", scope: null, description: subject.trim(), bump: "patch" }
@@ -78,7 +84,9 @@ function parseCommit(subject, body = "") {
const description = match[3].trim() const description = match[3].trim()
let bump = "patch" let bump = "patch"
if (breaking || MINOR_TYPES.has(type)) { if (breaking) {
bump = "major"
} else if (MINOR_TYPES.has(type)) {
bump = "minor" bump = "minor"
} else if (PATCH_TYPES.has(type)) { } else if (PATCH_TYPES.has(type)) {
bump = "patch" bump = "patch"
@@ -251,7 +259,9 @@ function main() {
return return
} }
const bump = commits.some((commit) => commit.bump === "minor") ? "minor" : "patch" const bump = commits.some((commit) => commit.bump === "major")
? "major"
: commits.some((commit) => commit.bump === "minor") ? "minor" : "patch"
const version = bumpVersion(baseFromTag, bump) const version = bumpVersion(baseFromTag, bump)
const tag = `v${version}` const tag = `v${version}`
const publishedAt = new Date().toISOString() const publishedAt = new Date().toISOString()
+61
View File
@@ -0,0 +1,61 @@
import assert from "node:assert/strict"
import { execFileSync } from "node:child_process"
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { basename, dirname, join, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import test from "node:test"
const source = fileURLToPath(new URL("./compute-release.mjs", import.meta.url))
const cases = [
{ name: "исправление повышает patch", commits: [["fix(ui): исправить отображение"]], version: "1.47.7", bump: "patch" },
{ name: "новая возможность повышает minor", commits: [["feat(ui): добавить раздел"]], version: "1.48.0", bump: "minor" },
{ name: "breaking со scope повышает major", commits: [["chore(release)!: подготовить вторую основную версию"]], version: "2.0.0", bump: "major" },
{ name: "breaking без scope повышает major", commits: [["feat!: заменить контракт"]], version: "2.0.0", bump: "major" },
{ name: "footer со пробелом повышает major", commits: [["fix(api): заменить контракт", "BREAKING CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
{ name: "footer с дефисом повышает major", commits: [["fix(api): заменить контракт", "BREAKING-CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
{ name: "major имеет приоритет в смешанной истории", commits: [["chore(release)!: подготовить выпуск"], ["feat(ui): добавить раздел"], ["fix(ui): исправить отображение"]], version: "2.0.0", bump: "major" },
{ name: "после второй версии сохраняется patch", base: "2.0.0", commits: [["fix(ui): исправить отображение"]], version: "2.0.1", bump: "patch" },
{ name: "после второй версии сохраняется minor", base: "2.0.0", commits: [["feat(ui): добавить раздел"]], version: "2.1.0", bump: "minor" },
{ name: "готовый тег не выпускается повторно", base: "2.0.0", commits: [], version: "2.0.0", bump: "none" },
]
for (const scenario of cases) test(scenario.name, () => {
const parent = resolve(tmpdir())
const root = mkdtempSync(join(parent, "mm-release-"))
const git = (...args) => execFileSync("git", args, { cwd: root, stdio: "pipe" })
try {
mkdirSync(join(root, ".ci/scripts"), { recursive: true })
copyFileSync(source, join(root, ".ci/scripts/compute-release.mjs"))
git("init", "--initial-branch=main")
git("config", "user.name", "Release Test")
git("config", "user.email", "release@example.invalid")
git("config", "core.hooksPath", ".git/hooks")
writeFileSync(join(root, "fixture"), "baseline")
git("add", "fixture")
git("commit", "-m", "chore(test): создать исходную базу")
git("tag", `v${scenario.base ?? "1.47.6"}`)
for (const [index, [subject, body]] of scenario.commits.entries()) {
writeFileSync(join(root, "fixture"), `change ${index}`)
git("add", "fixture")
git("commit", "-m", subject, ...(body ? ["-m", body] : []))
}
execFileSync(process.execPath, [join(root, ".ci/scripts/compute-release.mjs")], {
cwd: root, stdio: "pipe",
env: { ...process.env, BASE_VERSION: "1.0.0", RELEASE_OUTPUT_DIR: ".ci/release", GITHUB_OUTPUT: join(root, "outputs"), GITEA_REPOSITORY: "test/repo", GITEA_SERVER_URL: "https://example.invalid" },
})
const manifest = JSON.parse(readFileSync(join(root, ".ci/release/release-manifest.json"), "utf8"))
const output = readFileSync(join(root, "outputs"), "utf8")
assert.equal(manifest.version, scenario.version)
assert.equal(manifest.tag, `v${scenario.version}`)
assert.equal(manifest.releaseUrl, `https://example.invalid/test/repo/releases/tag/v${scenario.version}`)
assert.match(output, new RegExp(`^bump=${scenario.bump}$`, "m"))
assert.match(output, new RegExp(`^should_release=${scenario.bump !== "none"}$`, "m"))
if (scenario.bump !== "none") assert.match(readFileSync(join(root, ".ci/release/release_notes.md"), "utf8"), new RegExp(`# v${scenario.version.replaceAll(".", "\\.")}`))
} finally {
assert.equal(dirname(resolve(root)), parent)
assert.ok(basename(root).startsWith("mm-release-"))
rmSync(root, { recursive: true, force: true })
}
})
-1
View File
@@ -1 +0,0 @@
{"version":"0.0.1","configurations":[{"name":"MikrotikManager","runtimeExecutable":"npm","runtimeArgs":["run","dev"],"port":3000,"autoPort":true}]}
-70
View File
@@ -1,70 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npm install *)",
"mcp__Claude_Preview__preview_start",
"Bash(npx tsc *)",
"Bash(npm run *)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/dashboard)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000__TRACKED_VAR__)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3001__TRACKED_VAR__)",
"PowerShell(netstat -ano | Select-String \":300\" | Where-Object { $_ -match \"LISTENING\" })",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
"WebFetch(domain:git.shx.one)",
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
"Bash(node -e ' *)",
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
"mcp__Claude_in_Chrome__tabs_context_mcp",
"Bash(npx eslint *)",
"Bash(grep -E \"^C:\\\\\\\\\\\\\\\\| [0-9]+:[0-9]+ error\")",
"Bash(grep -E \"\\(\\\\\\\\\\\\\\\\.*\\\\.tsx$\\)|\\(error\\)\")",
"Bash(python -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(m['ruleId'], m['line'], m['message'][:60]\\) for f in data for m in f['messages'] if m['severity']==2]\")",
"Bash(python -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(m['ruleId'], m['line'], m['message'][:80]\\) for f in data for m in f['messages'] if m['severity']==2]\")",
"Bash(npx next *)",
"Bash(dir \"C:\\\\Users\\\\shats\\\\Dev\\\\MikrotikManager-3\\\\app\\\\\\(main\\)\")",
"Bash(Get-ChildItem \"C:\\\\Users\\\\shats\\\\Dev\\\\MikrotikManager-3\\\\app\\\\\\(main\\)\" -Directory)",
"Bash(Select-Object Name)",
"Bash(Sort-Object Name)",
"Bash(awk 'NR>=508 && NR<=730' \"app/\\(main\\)/servers/page.tsx\")",
"Bash(grep -n \"Fragment\\\\|<>\\\\s*$\" \"app/\\(main\\)/filters/page.tsx\")",
"Bash(grep -n \"\\\\.map\\\\|Fragment\\\\|<>\\\\s*$\" \"app/\\(main\\)/ospf/page.tsx\")",
"Bash(grep -v \"//.*console\\\\|ErrorRecord\\\\|console\\\\.error.*err\\\\|any>\\\\|: any\\\\|as any\\\\|any\\\\[\\\\]\\\\|Record<.*any\\\\|Promise<any\")",
"Bash(curl -s http://localhost:8000/api/servers)",
"Bash(curl -s -X POST http://localhost:8000/api/servers/1/poll)",
"Bash(curl -s http://localhost:8000/api/servers/1)",
"Bash(curl -s http://localhost:8000/api/servers/1/snapshots)",
"Bash(curl -s -X DELETE http://localhost:8000/api/servers/1 -w \"\\\\nHTTP %{http_code}\")",
"Bash(curl -s http://localhost:8000/api/servers/999)",
"Bash(curl -s -X POST http://localhost:8000/api/servers -H \"Content-Type: application/json\" -d \"{}\")",
"Bash(curl -s -X POST http://localhost:8000/api/servers/test-connection -H 'Content-Type: application/json' -d '{\"host\":\"192.168.0.254\",\"port\":80,\"useSsl\":false,\"verifySsl\":false,\"apiPath\":\"/rest\",\"username\":\"admin\",\"password\":\"\"}')",
"Bash(curl -s -X POST http://localhost:8000/api/servers -H 'Content-Type: application/json' -d '{\"host\":\"192.168.0.254\",\"port\":80,\"useSsl\":false,\"verifySsl\":false,\"username\":\"admin\",\"password\":\"test123\",\"name\":\"RB5009 Home\",\"type\":\"home-router\",\"site\":\"MSK\",\"country\":\"RU\",\"asn\":\"\",\"comment\":\"Тестовый роутер\",\"enabled\":true}')",
"Bash(python3 -m json.tool)",
"Bash(curl -s -X POST http://localhost:8000/api/servers -H 'Content-Type: application/json' -d '{\"host\":\"192.168.0.254\",\"port\":80,\"useSsl\":false,\"verifySsl\":false,\"username\":\"admin\",\"password\":\"test123\",\"name\":\"RB5009 Home\",\"type\":\"home-router\",\"site\":\"MSK\",\"country\":\"RU\"}')",
"Bash(node -e \"const d=require\\('fs'\\).readFileSync\\('/dev/stdin','utf8'\\); const a=JSON.parse\\(d\\); a.forEach\\(s=>console.log\\(s.id, s.host, s.name\\)\\)\")",
"Bash(curl -s http://localhost:8000/api/bgp/sessions)",
"Bash(curl -s \"http://localhost:8000/api/bgp/sessions/raw/3\")",
"Bash(node -e \"0\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/neighbor\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/instance\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/area\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/interface-template\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/ip/route?.type=ospf\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/ip/route\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://192.168.0.254/rest/routing/ospf/neighbor\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://192.168.0.254/rest/routing/ospf/instance\")",
"Bash(curl -s http://localhost:8000/api/ospf/all)",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://192.168.0.254/rest/routing/bfd/session\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/bfd/session\")",
"Bash(node -e \"const j=JSON.parse\\(require\\('fs'\\).readFileSync\\(0,'utf8'\\)\\); console.log\\('bfd:', JSON.stringify\\(j.bfdSessions, null, 2\\)\\)\")",
"Bash(xargs grep *)",
"Bash(npx shadcn@latest add popover --yes)"
]
},
"env": {
"REUI_LICENSE_KEY": "REUI-EAF4-EA40-6C5C-5D86"
}
}
-67
View File
@@ -1,67 +0,0 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
-43
View File
@@ -1,43 +0,0 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
## Operational surfaces (settings / profile / admin)
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
-60
View File
@@ -1,60 +0,0 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
-408
View File
@@ -1,408 +0,0 @@
# ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useTable({
features: dataGridFeatures,
data,
columns,
})
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FramePanel>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<div className="p-5">Content</div>
<FrameFooter>Footer</FrameFooter>
</FramePanel>
</Frame>
```
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
## base vs radix - write for the project's base
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
- `"style": "base-nova"` -> **Base UI**
- `"style": "radix-nova"` -> **Radix UI**
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
-45
View File
@@ -1,45 +0,0 @@
# Craft: make ReUI UI exceptional, not generic
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
## Have a point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
## Brutally clear hierarchy
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
## Spacing rhythm and deliberate density
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
## Cover the real states (the usual miss)
A surface isn't done at the happy path. Compose, and wire:
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
## Responsive by default
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
## Motion, subtly
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
## Real, activated content
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
## Avoid the AI tells
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
## The bar
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
-39
View File
@@ -1,39 +0,0 @@
# Icons (ReUI delta over shadcn)
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remix` -> `@remixicon/react`
- `hugeicons` -> `@hugeicons/react`
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
```
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
The `icon-stack` component composes multiple icons into a stacked display.
-22
View File
@@ -1,22 +0,0 @@
# Quality gates (security, accessibility, scroll)
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
-35
View File
@@ -1,35 +0,0 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
## Free vs premium
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
-26
View File
@@ -1,26 +0,0 @@
# Styling (ReUI delta over shadcn)
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
## ReUI extended semantic tokens
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
- `--success` / `--success-foreground`
- `--info` / `--info-foreground`
- `--warning` / `--warning-foreground`
- `--destructive-foreground` (paired with shadcn's `--destructive`)
- `--invert` / `--invert-foreground` (inverted surfaces)
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
**Incorrect:** `<span className="text-green-600">Active</span>`
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
## Blocks follow your theme
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
## Density and typography rhythm
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
-54
View File
@@ -1,54 +0,0 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
-58
View File
@@ -1,58 +0,0 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp
-16
View File
@@ -1,16 +0,0 @@
# 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
-21
View File
@@ -1,21 +0,0 @@
{"sessionId":"00844d","runId":"initial","hypothesisId":"H6","location":"components/debug-runtime-probe.tsx","message":"cursor-ref-count-on-mount","data":{"pathname":"/bgp","count":80},"timestamp":1777738439411}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H5","location":"components/debug-runtime-probe.tsx","message":"route-change","data":{"pathname":"/bgp","search":""},"timestamp":1777738439411}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738439416}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738439428}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"live","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738439603}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-start","data":{"url":"http://localhost:8000"},"timestamp":1777738439605}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-result","data":{"url":"http://localhost:8000","ok":true,"status":200},"timestamp":1777738439621}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508126}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508134}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508145}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508145}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"live","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508204}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-start","data":{"url":"http://localhost:8000"},"timestamp":1777738508205}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-result","data":{"url":"http://localhost:8000","ok":true,"status":200},"timestamp":1777738508228}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508553}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508560}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508571}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508572}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"live","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508662}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-start","data":{"url":"http://localhost:8000"},"timestamp":1777738508663}
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-result","data":{"url":"http://localhost:8000","ok":true,"status":200},"timestamp":1777738508676}
-34
View File
@@ -1,34 +0,0 @@
---
description: "Generate Commit Message: русский subject, fix vs feat, Conventional Commits"
alwaysApply: true
---
# Git commit message (Generate Commit Message)
При **Generate Commit Message**, автогенерации subject и предложении `git commit`:
## Обязательно
- Subject и body — **только на русском** (после `:` в conventional commit).
- Локальный hook `.githooks/commit-msg` **отклоняет** коммиты без кириллицы в subject.
- Префикс и scope — английский: `fix(ui):`, `chore(rules):`.
## Тип: сначала не `feat`
- **`fix`** — баг, регрессия, правка вёрстки/модалки, некорректное поведение, CI/CD, мелкий дефект.
- **`chore` / `docs` / `ci` / `build`** — правила Cursor, README, workflow, зависимости, рефакторинг без новой UX-фичи.
- **`feat`** — только **новая** пользовательская возможность (страница, API, сценарий), которой не было.
Если сомневаешься между `feat` и `fix` — выбирай **`fix`** или **`chore`**, не `feat`.
## Примеры
- `fix(ui): выровнять модалку сводки релиза`
- `fix(ci): исправить сборку release manifest`
- `chore(rules): усилить правила русских commit message`
- `feat(releases): добавить страницу релизов`
## Запрещено
- `feat: update layout`, `fix: add modal`, `chore: update rules` — английский subject.
- `feat` для правки UI, правил, документации и багфиксов.
-44
View File
@@ -1,44 +0,0 @@
---
description: "Быстрый запуск локального frontend и backend"
alwaysApply: true
---
# Локальный запуск проекта
## Требования
- **Node.js 22**, npm с workspaces.
- Первый раз (или после смены зависимостей): `npm install` из корня репозитория.
- Backend: скопировать `backend/.env.example` → `backend/.env` (по умолчанию `PORT=8000`, `CORS_ORIGIN=http://localhost:3000`).
## Запуск (два процесса)
Из корня репозитория поднять **два** long-running процесса в **отдельных** терминалах:
```powershell
npm run dev
npm --prefix backend run dev
```
| Сервис | URL | Проверка |
|--------|-----|----------|
| Frontend (Next.js 16, Turbopack) | http://localhost:3000 | открыть в браузере |
| Backend (Fastify) | http://localhost:8000 | `GET /health` |
Проверка backend в PowerShell:
```powershell
Invoke-WebRequest -Uri http://localhost:8000/health -UseBasicParsing | Select-Object -ExpandProperty Content
```
Ожидаемый ответ: `{"status":"ok",...}`.
## Поведение агента
Если пользователь просит «запусти проект», «запусти фронт и бэк» или похожее:
1. Сначала проверить уже запущенные терминалы — не дублировать процессы.
2. Запустить обе команды выше как фоновые long-running процессы.
3. Дождаться готовности: frontend — `Ready`, backend — `Server listening` / успешный `/health`.
Подробности архитектуры и env — `README.md`, раздел «Запуск».
-131
View File
@@ -1,131 +0,0 @@
---
description: "Fastify 5 + Zod + Drizzle + SQLite — модульная архитектура, слои, валидация, ошибки (ответы на русском)"
globs: "**/{features,shared,lib,plugins}/**/*.ts,**/app.ts,**/server.ts"
alwaysApply: false
---
# Backend: Fastify 5 + TypeScript (ESM) + Zod + Drizzle + SQLite
**Язык:** все ответы пользователю — **только на русском**.
**Стек:** TypeScript (`"type": "module"`), **Fastify 5.x**, **Zod** + `@fastify/type-provider-zod`, **SQLite** (`better-sqlite3`) + **Drizzle ORM**, `@fastify/cors`, `pino-pretty`, `undici`, `dotenv`.
**Цель:** масштабируемый модульный backend, предсказуемый DX, строгое разделение ответственности.
---
## 1. Архитектура (критично)
- **Feature-based модули**, не монолитные файлы. Каждая фича — изолированная папка.
Структура фичи:
```text
/features/<feature-name>/
route.ts — регистрация маршрутов Fastify
controller.ts — разбор запроса/ответа, вызов сервиса
service.ts — бизнес-логика (переиспользуемая)
schema.ts — Zod-схемы (params, query, body, response)
repository.ts — только Drizzle-запросы к БД
types.ts — опционально
```
**Запреты:** не смешивать слои; **нет** бизнес-логики в `route`; **нет** SQL/Drizzle в `controller`; сервисы не должны дублировать обязанности репозитория.
---
## 2. Fastify
- Регистрация фич через **плагины** и инкапсулированные модули.
- Везде **async/await**; декорации инстанса Fastify — только при реальной необходимости.
---
## 3. Валидация (Zod обязательна)
- **Все входы** — через Zod; `@fastify/type-provider-zod` для типобезопасности.
- Явно задавать схемы: **params**, **query**, **body**, **response** (где уместно).
- Нет «сырого» входа; схемы **переиспользовать** между слоями (импорт из `schema.ts`).
---
## 4. База данных (Drizzle)
- Любой доступ к БД — **только** из `repository.ts` / слоя репозитория.
- Типизированные запросы Drizzle; **без** raw SQL, если нет веской причины.
- Запросы — простые, композируемые; логику БД не выносить наружу репозитория.
- Учитывать возможную смену БД: избегать sqlite-специфичных костылей вне репозитория/миграций.
---
## 5. Ошибки
- Единый формат ошибок API (код/сообщение для клиента).
- Обрабатывать: ошибки валидации, БД, внешних HTTP (`undici`).
- **Не** отдавать клиенту внутренние детали (стейки, SQL, секреты).
---
## 6. Логирование
- **Pino**; в dev — **pino-pretty**.
- Логировать ошибки и значимые действия; не засорять лог шумом.
---
## 7. Внешние HTTP (undici)
- Вызовы только из **service** (или выделенного клиента, вызываемого из сервиса).
- Таймауты, обработка ошибок сети/статусов, без утечки сырого ответа наружу.
---
## 8. Конфигурация окружения
- `dotenv` для локальной разработки; **валидация env** (предпочтительно через Zod-схему).
---
## 9. Организация кода
- Небольшие файлы, одна ответственность на файл.
- Композиция вместо копипаста; общее — в `/shared/` и `/lib/`.
---
## 10. Масштабируемость
- Расширяемые границы модулей, слабая связанность, явные контракты между слоями.
---
## 11. Процесс изменений (обязательно)
Для каждого изменения в ответе указать: что сделано; зачем (архитектура + практики); затронутые файлы; минимальный полный дифф без лишнего рефакторинга.
---
## 12. Формат ответа агента
1. Что изменено
2. Почему (архитектура + практики)
3. Структура файлов (если новая фича)
4. Код (несколько файлов при необходимости)
5. Замечания / улучшения
---
## 13. Антипаттерны (строго запрещено)
- Толстые route handlers
- Смешение БД и бизнес-логики в одном месте
- Пропуск валидации входа
- Глобальное мутабельное состояние
- Дублирование запросов к БД
- Хардкод секретов и конфигурации (всё через env/конфиг)
---
## Роль агента
Вести себя как **senior backend engineer**: чистый модульный код, строгое разделение слоёв, долгосрочная поддерживаемость и предсказуемый процесс разработки.
-38
View File
@@ -1,38 +0,0 @@
---
description: "[Multi-agent] Роль ANALYZER — анализ кодовой базы перед реализацией, без кода"
alwaysApply: false
---
# Агент: ANALYZER (анализатор)
Ты выполняешь **только фазу анализа** перед любыми изменениями кода.
## Язык
- Все ответы пользователю — **на русском**.
## Обязанности
- Искать по **всему** репозиторию релевантные места.
- Зафиксировать:
- похожие компоненты и дубликаты по смыслу;
- существующие UI-паттерны, **особенно в зоне `/servers`** (`app/(main)/servers` и связанные компоненты);
- общие хуки, утилиты, UI-обёртки;
- стратегии загрузки данных (RSC, route handlers, server actions, клиентский fetch — что уже принято в проекте).
- Явно отметить **риски дублирования** и конфликты с текущими абстракциями.
## Запреты
- **Нельзя** писать реализацию (патчи, готовый production-код, большие вставки «как сделать»). Допустимы короткие псевдокод-намёки уровня «расширить X», без полной реализации.
- **Нельзя** предлагать «с нуля», если в кодовой базе уже есть переиспользуемое решение — приоритет **reuse**.
## Формат вывода (строго)
1. **Релевантные файлы** (пути).
2. **Существующие паттерны** (кратко, с привязкой к файлам).
3. **Что переиспользовать** (компоненты, хуки, утилиты).
4. **Риски и конфликты** (дубли, расхождение с `/servers`, ломкие зависимости).
## Цель фазы
Дать Implementer’у однозначный вход: **что трогать, что не плодить, откуда копировать поведение**.
-30
View File
@@ -1,30 +0,0 @@
---
description: "[Multi-agent] Глобальные правила пайплайна (Next 16.2.4, Turbopack, shadcn) — подключать с ролями"
alwaysApply: false
---
# Глобальные правила (все агенты пайплайна)
**Язык:** отвечать пользователю **только на русском**.
## Стек и документация
- Next.js **16.2.4** (App Router, **Turbopack**), React, **shadcn/ui**.
- Сверять решения с **официальной** документацией Next.js и shadcn для версий проекта. Не придумывать API и недокументированные паттерны.
## Встроенные возможности Next.js (предпочтительно)
- App Router, Server Components, Server Actions, Route Handlers — вместо обходных кастомных путей, если задача покрывается фреймворком.
## Эталон UI
- **`app/(main)/servers`** — эталон **поведения** CRUD. Оболочка ops: **Frame + DataGrid**, не Card-shell. Не копировать `DataPageCard` на новые экраны.
## Качество и совместимость
- Устранять: ошибки сборки, TypeScript, гидратации, предупреждения о плохих практиках; учитывать **Turbopack** в dev.
## Связь с проектом
- Полные стандарты проекта (формат ответа, чеклисты) — в правиле **`next-shadcn-production.mdc`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют.
- Сообщения коммитов: **`commit-messages-ru.mdc`** (язык и `fix` vs `feat`), semver bump — **`release-versioning.mdc`**.
-37
View File
@@ -1,37 +0,0 @@
---
description: "[Multi-agent] Роль IMPLEMENTER — реализация по результатам анализа, /servers, shadcn"
alwaysApply: false
---
# Агент: IMPLEMENTER (реализатор)
Ты пишешь **production-ready** изменения **на основе** отчёта Analyzer (и уточнённой задачи пользователя).
## Язык
- Все ответы пользователю — **на русском**.
## Обязанности
- Внедрять фичу **минимально**, но **полностью**: все затронутые файлы (типы, UI, API, actions/handlers) согласованы.
- **Переиспользовать** существующие абстракции из отчёта анализа и кодовой базы.
- Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция).
- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики).
- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент.
- При завершении задачи предлагать subject по **`commit-messages-ru.mdc`**: правки UI/багов → `fix`, инфраструктура/правила → `chore`, новая фича → `feat`; описание **на русском**.
## Формат вывода (строго)
1. **Что изменено** (файлы и суть).
2. **Почему так** — связка Next.js + shadcn/ui + эталон **`/servers`** + принятые в репозитории паттерны.
3. **Код** — диффы или полные фрагменты по всем затронутым файлам.
4. По возможности: **Server vs Client** для новых/изменённых границ компонентов.
## Запреты
- Не оставлять «полфичи» (забытые типы, сломанные импорты, несогласованный API).
- Не копипастить большие куски без привязки к существующим компонентам, если анализ уже указал переиспользование.
## Согласование с проектом
- Если активно правило **`next-shadcn-production.mdc`**, выдерживай его структуру ответа (шаги 0–6) и требования к проверкам.
-58
View File
@@ -1,58 +0,0 @@
---
description: "[Multi-agent] Оркестратор — последовательность Analyzer→Implementer→Reviewer→UI Guardian"
alwaysApply: false
---
# Оркестратор пайплайна (multi-agent)
Используй это правило, когда нужно прогнать задачу через **все роли по очереди**. Подключай вместе с **`ma-global.mdc`** и при необходимости **`next-shadcn-production.mdc`**.
## Язык
- На каждой фазе ответы пользователю — **на русском**.
## Порядок фаз (не нарушать)
1. **Analyzer** — правило **`ma-analyzer.mdc`**: только анализ, без реализации.
2. **Implementer** — правило **`ma-implementer.mdc`**: код по результатам фазы 1.
3. **Reviewer** — правило **`ma-reviewer.mdc`**: строгое ревью результата фазы 2.
4. **UI Guardian** — правило **`ma-ui-guardian.mdc`**: только сверка с **`/servers`**.
## Как исполнять в одном чате
- Выполни **фазу 1** целиком и остановись на выходном формате Analyzer.
- Затем **фаза 2** с явной ссылкой: «реализуй согласно отчёту Analyzer выше».
- Затем **фаза 3** по артефакту фазы 2.
- Затем **фаза 4** по UI-части изменений.
Если пользователь просит «только одну фазу» — выполни только её и напомни номер следующей.
## Промпт-шаблон для пользователя (скопировать в чат)
```text
Запусти multi-agent пайплайн для задачи ниже.
Фазы строго по порядку: Analyzer → Implementer → Reviewer → UI Guardian.
Язык ответов — русский. Стек: Next.js 16.2.4 App Router + Turbopack, shadcn/ui.
Эталон UI: app/(main)/servers.
Следуй правилам ma-global и соответствующим ma-* ролям.
Задача:
<опишите фичу или баг>
```
## Файлы ролей в `.cursor/rules/`
| Роль | Файл |
|------------|-------------------------|
| Глобальные | `ma-global.mdc` |
| Analyzer | `ma-analyzer.mdc` |
| Implementer| `ma-implementer.mdc` |
| Reviewer | `ma-reviewer.mdc` |
| UI Guardian| `ma-ui-guardian.mdc` |
| Оркестратор| `ma-orchestrator.mdc` |
## Ожидаемый результат пайплайна
- Согласованность с кодовой базой и **максимальный reuse**.
- UI, выровненный под **`/servers`**.
- Отсутствие известных блокеров по Next.js, гидратации, TS и заметных anti-patterns после фазы Reviewer.
-42
View File
@@ -1,42 +0,0 @@
---
description: "[Multi-agent] Роль REVIEWER — строгая проверка Next/shadcn/a11y/гидратации/Turbopack"
alwaysApply: false
---
# Агент: REVIEWER (ревьюер)
Ты **валидируешь** предложенные или уже внесённые изменения. Режим: **строгий**.
## Язык
- Все ответы пользователю — **на русском**.
## Что проверять
- **Next.js:** App Router, границы RSC/Client, данные ближе к серверу, кэширование/revalidate где уместно, корректные route handlers и server actions.
- **shadcn/ui:** композиция, токены/классы проекта, отсутствие «левого» самописного UI там, где хватает shadcn.
- **Производительность:** лишние клиентские деревья, waterfall fetch, чрезмерные `'use client'` границы.
- **a11y:** семантика, фокус, aria, клавиатура (в разумных пределах контекста правки).
- **Гидратация:** несовпадение разметки/случайные значения на сервере и клиенте, неправильное использование браузерных API в RSC.
- **Turbopack:** не предлагать паттерны, ломающие dev-сборку Next 16 + Turbopack; учитывать типичные предупреждения toolchain.
## Обязательные находки
- Ошибочное **смешение Server/Client** (хуки в RSC, серверные модули в клиенте без обёртки и т.д.).
- **Over-fetching** и дублирующая логика запросов.
- **Дублирование** логики/UI там, где уже есть общий модуль или паттерн `/servers`.
## Формат вывода (строго)
Таблица или списки:
1. **Проблема** (кратко).
2. **Серьёзность:** блокер / высокая / средняя / низкая.
3. **Рекомендация по исправлению** (конкретные шаги или патч-уровень).
Если дефектов нет — явно напиши **«Блокирующих замечаний нет»** и перечисли **что именно** было проверено.
## Запреты
- Нельзя «мягко пропускать» очевидные проблемы ради скорости.
- Нельзя ограничиваться общими фразами — каждая **рекомендация должна быть actionable**.
-43
View File
@@ -1,43 +0,0 @@
---
description: "[Multi-agent] Роль UI GUARDIAN — сверка UI с CRUD /servers и оболочкой Frame+DataGrid"
alwaysApply: false
---
# Агент: UI GUARDIAN (хранитель UI)
Ты отвечаешь **только** за **визуальную и UX-согласованность**.
## Язык
- Все ответы пользователю — **на русском**.
## Эталон
Два слоя (не смешивать):
1. **Поведение CRUD** — каталог **`app/(main)/servers`**: фильтры, таблица, диалоги, формы, плотность, паттерны кнопок.
2. **Оболочка ops** — **ReUI Frame + DataGrid**, не shadcn `Card` как page shell. KPI — `IconTile` `variant="elevated"` `className="size-10.5"`. Preview: [frame](https://reui.io/docs/components/base/frame) · [data-grid](https://reui.io/docs/components/base/data-grid) · [stats-12](https://reui.io/preview/base/stats-12) · [icon-tile](https://reui.io/docs/components/base/icon-tile).
`DataPageCard` — ops list shell на ReUI Frame; новые экраны не копируют Card-shell.
## Обязанности
- Сравнить **каждое** спорное UI-решение: CRUD — с `/servers`; оболочка — с Frame+DataGrid.
- Требовать **переиспользование** существующих компонентов/обёрток, если они покрывают задачу.
## Правила
- **Нельзя** принимать новый Card page-shell без явной технической необходимости (назвать её).
- **Нельзя** предлагать «новый стиль» ради вариации — только выравнивание с эталоном или расширение существующих примитивов.
## Формат вывода (строго)
1. **Несоответствия** эталону (конкретно: CRUD vs Frame-shell).
2. **Обязательные правки** (что поменять).
3. **Что переиспользовать**: файлы/компоненты и паттерн.
Если расхождений нет — явно: **«Расхождений с эталоном CRUD /servers и Frame+DataGrid не выявлено»** и кратко перечисли проверенные аспекты.
## Граница ответственности
- Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности** и **reuse** UI.
-84
View File
@@ -1,84 +0,0 @@
---
description: Next.js App Router + shadcn/ui — эталон /servers, поиск по репозиторию, формат ответа и проверки
alwaysApply: true
---
# Next.js 16 (App Router, Turbopack) + shadcn/ui — правила проекта
Стек ориентира: **Next.js 16.2.4** (App Router, Turbopack), **React**, **shadcn/ui**. При расхождении с документацией — верифицировать по официальным источникам для вашей версии.
**Язык:** все ответы пользователю — **только на русском**.
**Git / Generate Commit Message:** subject и body — **на русском**; `feat` только для новой UX-фичи, иначе `fix`/`chore`. См. **`commit-messages-ru.mdc`** и **`release-versioning.mdc`**.
## 1. Документация в первую очередь
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
- Не выдумывать API и «недокументированные» паттерны; предпочитать стабильные, описанные в доках решения.
## 2. Эталон UI/UX: `/servers` (CRUD) + Frame+DataGrid (оболочка)
- **`app/(main)/servers`** — эталон **поведения** CRUD: фильтры, сортировка, пагинация, диалоги, формы, плотность.
- **Оболочка ops-списков / dashboard / KPI:** **ReUI Frame + DataGrid**, не shadcn `Card` как page shell. Preview: [frame](https://reui.io/docs/components/base/frame) · [data-grid](https://reui.io/docs/components/base/data-grid) · [stats-12](https://reui.io/preview/base/stats-12) · [icon-tile](https://reui.io/docs/components/base/icon-tile).
- `DataPageCard` — ops list shell на ReUI Frame (`components/data-page-card.tsx`). Внутренние панели — `OpsPanel` / Frame, не shadcn Card как page shell.
- Переиспользовать паттерны `/servers` для CRUD; **не** вводить Card-grid как новый SoT.
## 3. Большой репозиторий (критично)
- **Перед изменениями** искать по проекту: похожие компоненты, существующие паттерны, общие утилиты, хуки, UI-абстракции.
- **Не дублировать:** если похожее решение уже есть — расширять или переиспользовать, а не писать с нуля.
## 4. Согласованность между файлами
При правке фичи проверять и при необходимости обновлять **все** связанное: компоненты, хуки, route handlers / server actions, типы, стили/обёртки UI — не только открытый файл.
## 5. Зависимости и влияние
Перед реализацией: от чего зависит целевой файл, где используется, какие побочные эффекты; избегать ломающих изменений без явной необходимости.
## 6. Нативные возможности фреймворка
- Next.js: App Router, Server Components, Server Actions, Route Handlers — по умолчанию предпочтительнее кастомных обходных путей.
- UI: компоненты **shadcn/ui**; не плодить самописное, если стандартное решение покрывает задачу.
## 7. Практики качества
- Рекомендации Next.js: загрузка данных, рендеринг, производительность.
- Минимизировать `'use client'` там, где достаточно серверных компонентов.
- Композиция shadcn/ui и доступность (a11y).
## 8. Валидация каждого изменения (обязательно)
В ответе явно указать:
- **Почему** это согласуется с Next.js, shadcn/ui, CRUD `/servers` и оболочкой Frame+DataGrid.
- **Server vs Client Component** и обоснование.
- Что **переиспользовано** из проекта (паттерны/компоненты).
- Какие **файлы проанализированы**.
## 9. Turbopack и консоль разработки
После правок: проверить сборку/типы (по возможности запустить проверки проекта), ошибки гидратации, предупреждения; критичное исправить; не игнорировать предупреждения о плохих практиках.
## 10. Правки кода
- Минимальные, но **полные** (все зависимые места обновлены).
- Не рефакторить несвязанную логику.
## 11. Формат ответа (обязательная структура)
1. **Шаг 0:** список проанализированных файлов.
2. **Шаг 1:** что изменено.
3. **Шаг 2:** почему (Next.js + shadcn/ui + CRUD `/servers` + Frame+DataGrid + паттерны кодовой базы).
4. **Шаг 3:** анализ влияния (что ещё затронуто).
5. **Шаг 4:** код (диффы или несколько файлов).
6. **Шаг 5:** проверка dev/консоли (ошибки, предупреждения, исправления).
7. **Шаг 6:** опциональные улучшения.
## 12. Дублирование и антипаттерны
Явно указывать, если найдено дублирование или антипаттерн; ссылаться на существующую реализацию в проекте; предлагать переиспользование или рефакторинг вместо копипаста.
## Цель
Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, CRUD как на `/servers`, оболочка Frame+DataGrid (не Card-shell), безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности.
-45
View File
@@ -1,45 +0,0 @@
---
description: "Conventional Commits, semver релизов MikrotikManager и русские git commit message (Generate Commit Message)"
alwaysApply: true
---
# Версионирование релизов
## Generate Commit Message (критично)
Для **Generate Commit Message** в Cursor, предложения subject в чате, `git commit` и любой автогенерации сообщения коммита:
- Формат: `тип(scope): описание на русском` — префикс Conventional Commits и scope **на английском**, текст после `:` **только на русском**.
- Body и footer (если нужны) — **только на русском**.
- В описании после `:` не использовать английские слова: `add`, `update`, `implement`, `rename` и т.п.
- **`feat` не по умолчанию.** Сначала проверь `fix` / `chore` / `ci` / `docs`; `feat` — только новая пользовательская возможность.
- Примеры: `fix(ui): выровнять модалку релиза`; `chore(rules): усилить правила commit message`; `feat(releases): добавить страницу релизов`.
- Детальный чеклист типов — **`commit-messages-ru.mdc`**.
- Версию **не** править вручную в UI, `package.json` и git-тегах; bump делает CI через [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs).
- Базовая линия: `v1.0.0`. Формат отображения: `v1.2.3`.
- **Язык коммитов:** subject и body — **только на русском**; префикс Conventional Commits (`feat`, `fix`, `chore` и т.д.) и scope — на английском.
## Patch (`1.0.x`)
`fix:`, `chore:`, `docs:`, `refactor:`, `style:`, `test:`, `build:`, `ci:`
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов, **на русском**.
- Scope по желанию: `fix(servers):`, `chore(ci):`.
## Minor (`1.x.0`)
`feat:`, `feat!`, footer `BREAKING CHANGE` — только для заметного core-функционала.
## Примеры
- `fix(servers): исправить опрос API после таймаута` — patch
- `feat(filters): добавить синхронизацию BGP in` — minor
- `chore(ci): добавить job prepare-release` — хорошо
- `feat: add release modal` — плохо (subject не на русском)
## Антипаттерны
WIP, «update», subject на английском, смешение несвязанных изменений, маскировка фич под `fix`/`chore`, ручной bump в sidebar.
Подробности — раздел CI/CD в `README.md`.
-73
View File
@@ -1,73 +0,0 @@
---
description: Use the ReUI registry (blocks, primitives, icons) correctly
globs: ["**/*.tsx","**/*.ts"]
alwaysApply: false
---
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
-67
View File
@@ -1,67 +0,0 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
-43
View File
@@ -1,43 +0,0 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
## Operational surfaces (settings / profile / admin)
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
-60
View File
@@ -1,60 +0,0 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
-408
View File
@@ -1,408 +0,0 @@
# ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useTable({
features: dataGridFeatures,
data,
columns,
})
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FramePanel>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<div className="p-5">Content</div>
<FrameFooter>Footer</FrameFooter>
</FramePanel>
</Frame>
```
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
## base vs radix - write for the project's base
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
- `"style": "base-nova"` -> **Base UI**
- `"style": "radix-nova"` -> **Radix UI**
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
-45
View File
@@ -1,45 +0,0 @@
# Craft: make ReUI UI exceptional, not generic
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
## Have a point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
## Brutally clear hierarchy
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
## Spacing rhythm and deliberate density
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
## Cover the real states (the usual miss)
A surface isn't done at the happy path. Compose, and wire:
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
## Responsive by default
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
## Motion, subtly
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
## Real, activated content
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
## Avoid the AI tells
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
## The bar
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
-39
View File
@@ -1,39 +0,0 @@
# Icons (ReUI delta over shadcn)
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remix` -> `@remixicon/react`
- `hugeicons` -> `@hugeicons/react`
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
```
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
The `icon-stack` component composes multiple icons into a stacked display.
-22
View File
@@ -1,22 +0,0 @@
# Quality gates (security, accessibility, scroll)
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
-35
View File
@@ -1,35 +0,0 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
## Free vs premium
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
-26
View File
@@ -1,26 +0,0 @@
# Styling (ReUI delta over shadcn)
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
## ReUI extended semantic tokens
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
- `--success` / `--success-foreground`
- `--info` / `--info-foreground`
- `--warning` / `--warning-foreground`
- `--destructive-foreground` (paired with shadcn's `--destructive`)
- `--invert` / `--invert-foreground` (inverted surfaces)
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
**Incorrect:** `<span className="text-green-600">Active</span>`
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
## Blocks follow your theme
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
## Density and typography rhythm
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
-54
View File
@@ -1,54 +0,0 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
-58
View File
@@ -1,58 +0,0 @@
# 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
+5
View File
@@ -0,0 +1,5 @@
deploy/netflow-agent/*.sh text eol=lf
deploy/netflow-agent/*.py text eol=lf
# Migration hashes authenticate exact reviewed bytes, including existing CRLF files.
# Preserve those bytes on every checkout instead of applying core.autocrlf.
backend/drizzle/*.sql -text
+102 -9
View File
@@ -25,6 +25,9 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Test release version calculation
run: node --test .ci/scripts/compute-release.test.mjs
- name: Compute release metadata - name: Compute release metadata
id: release id: release
shell: bash shell: bash
@@ -54,16 +57,31 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}" NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
docker rm -f "$NAME" mm-pg 2>/dev/null || true docker rm -f "$NAME" 2>/dev/null || true
HOST_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()') # Docker-backed Gitea runners execute this step in a separate container.
# Join its network; a published host loopback port is not reachable here.
NETWORKS=$(docker inspect --format '{{range $name, $network := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$(cat /etc/hostname)" 2>/dev/null || true)
if [ -n "$NETWORKS" ]; then
test "$(printf '%s\n' "$NETWORKS" | wc -l)" -eq 1
NETWORK_ARGS=(--network "$NETWORKS" --network-alias "$NAME")
PG_HOST="$NAME"
PG_PORT=5432
else
# Host-based runners can use a Docker-assigned ephemeral host port.
NETWORK_ARGS=(-p 127.0.0.1::5432)
PG_HOST=127.0.0.1
fi
docker run -d --name "$NAME" --rm \ docker run -d --name "$NAME" --rm \
-e POSTGRES_USER=mmapp \ -e POSTGRES_USER=mmapp \
-e POSTGRES_PASSWORD=mmapp \ -e POSTGRES_PASSWORD=mmapp \
-e POSTGRES_DB=mmapp \ -e POSTGRES_DB=mmapp \
-p "127.0.0.1:${HOST_PORT}:5432" \ "${NETWORK_ARGS[@]}" \
postgres:18-alpine postgres:18-alpine
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}" echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
echo "DATABASE_URL=postgres://mmapp:mmapp@127.0.0.1:${HOST_PORT}/mmapp" >> "${GITHUB_ENV}" if [ "$PG_HOST" = 127.0.0.1 ]; then
PG_PORT=$(docker port "$NAME" 5432/tcp | awk -F: '{print $NF}')
fi
echo "DATABASE_URL=postgres://mmapp:mmapp@${PG_HOST}:${PG_PORT}/mmapp" >> "${GITHUB_ENV}"
for i in $(seq 1 40); do for i in $(seq 1 40); do
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
exit 0 exit 0
@@ -76,15 +94,27 @@ jobs:
- name: Install and test backend - name: Install and test backend
shell: bash shell: bash
env:
REQUIRE_TEST_POSTGRES: "1"
run: | run: |
set -euo pipefail set -euo pipefail
npm ci npm ci
node --input-type=module <<'NODE'
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 8000 });
try {
await pool.query('SELECT 1');
console.log('PostgreSQL reachable from the test process');
} finally {
await pool.end();
}
NODE
npm run test --prefix backend npm run test --prefix backend
- name: Stop PostgreSQL - name: Stop PostgreSQL
if: always() if: always()
shell: bash shell: bash
run: docker rm -f "${PG_CONTAINER:-}" mm-pg 2>/dev/null || true run: docker rm -f "${PG_CONTAINER:-}" 2>/dev/null || true
backend-image: backend-image:
needs: needs:
@@ -123,8 +153,13 @@ jobs:
NODE NODE
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/" cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
cp -R packages/contracts/src "$STAGING/packages/contracts/" cp -R packages/contracts/src "$STAGING/packages/contracts/"
cp backend/package.json backend/tsconfig.json "$STAGING/backend/" cp backend/package.json backend/tsconfig.json backend/mcp-coverage.json "$STAGING/backend/"
cp -R backend/src "$STAGING/backend/" cp -R backend/src "$STAGING/backend/"
mkdir -p "$STAGING/backend/scripts/writer-profile"
cp backend/scripts/build-database-operator.mjs backend/scripts/database-writer-bootstrap-operator.mjs backend/scripts/database-writer-profile-operator.mts "$STAGING/backend/scripts/"
cp backend/scripts/writer-profile/operator-files.mjs backend/scripts/writer-profile/operator-contract.mjs backend/scripts/writer-profile/publisher.mjs backend/scripts/writer-profile/context.mjs backend/scripts/writer-profile/catalogue.mts backend/scripts/writer-profile/catalogue-value-bridge.ts backend/scripts/writer-profile/catalogue-projection.ts backend/scripts/writer-profile/catalogue-leaves.ts backend/scripts/writer-profile/application-writer-profile-catalogue.ts backend/scripts/writer-profile/application-writer-profile-catalogue-values.ts backend/scripts/writer-profile/application-writer-profile-catalogue-parent-policy.ts backend/scripts/writer-profile/application-writer-profile-canonical.ts backend/scripts/writer-profile/fixed44-build-sql.ts backend/scripts/writer-profile/fixed44-build-definition.ts backend/scripts/writer-profile/expanded44-data-policy.ts "${STAGING}/backend/scripts/writer-profile/"
mkdir -p "$STAGING/deploy/netflow-agent"
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
if [ -d backend/drizzle ]; then if [ -d backend/drizzle ]; then
cp -R backend/drizzle "$STAGING/backend/" cp -R backend/drizzle "$STAGING/backend/"
fi fi
@@ -299,12 +334,69 @@ jobs:
org.opencontainers.image.revision=${{ gitea.sha }} org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }} org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
netflow-agent-image:
needs: prepare-release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure netflow-agent image name
shell: bash
run: |
set -euo pipefail
owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-netflow-agent" >> "${GITHUB_ENV}"
- name: Test controller and installer
run: |
python3 -m unittest discover -s deploy/netflow-agent -p "test_*.py" -v
bash -n deploy/netflow-agent/install.sh
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Verify image with real WireGuard
run: |
docker build --target test -t mm-netflow-agent-test deploy/netflow-agent
docker run --rm --cap-drop ALL --cap-add NET_ADMIN --read-only --tmpfs /tmp -e FLOW_AGENT_KERNEL_TEST=1 --entrypoint python3 mm-netflow-agent-test -m unittest discover -s /app -p "test_agent.py" -v
- name: Build and push netflow-agent image
uses: docker/build-push-action@v5
with:
context: deploy/netflow-agent
file: deploy/netflow-agent/Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
cache-from: type=gha,scope=netflow-agent
cache-to: type=gha,mode=max,scope=netflow-agent
labels: |
org.opencontainers.image.title=mmapp-netflow-agent
org.opencontainers.image.description=MikroTik Manager local WireGuard controller
org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
publish-release: publish-release:
needs: needs:
- prepare-release - prepare-release
- backend-image - backend-image
- frontend-image - frontend-image
- updater-image - updater-image
- netflow-agent-image
if: needs.prepare-release.outputs.should_release == 'true' if: needs.prepare-release.outputs.should_release == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -386,17 +478,18 @@ jobs:
fi fi
notify-webhook: notify-webhook:
if: ${{ secrets.DEPLOY_WEBHOOK_URL != '' }}
needs: needs:
- prepare-release - prepare-release
- backend-image - backend-image
- frontend-image - frontend-image
- netflow-agent-image
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
steps: steps:
- name: Notify deploy webhook - name: Notify deploy webhook
if: env.WEBHOOK_URL != ''
shell: bash shell: bash
env:
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
run: | run: |
set -euo pipefail set -euo pipefail
curl --fail --silent --show-error --request POST \ curl --fail --silent --show-error --request POST \
+276
View File
@@ -0,0 +1,276 @@
name: MCP isolated acceptance preview images
'on':
workflow_dispatch:
inputs:
expected_sha:
description: Exact reviewed full commit SHA on the selected ref; includes all accepted source changes
required: true
type: string
auth_portal_origin:
description: Canonical isolated browser-facing Auth Portal HTTP(S) origin; no path, credentials or query
required: true
type: string
default: http://127.0.0.1:55175
permissions:
contents: read
packages: write
jobs:
backend-preview:
if: ${{ gitea.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 40
env:
COMPONENT: backend
EXPECTED_SHA: ${{ inputs.expected_sha }}
DISPATCH_SHA: ${{ gitea.sha }}
DISPATCH_EVENT: ${{ gitea.event_name }}
REPOSITORY: ${{ gitea.repository }}
RUN_ID: ${{ gitea.run_id }}
RUN_ATTEMPT: ${{ gitea.run_attempt }}
steps:
- name: Checkout exact dispatch commit
uses: actions/checkout@v4
with:
ref: ${{ gitea.sha }}
persist-credentials: false
- name: Use existing Linux Node22 build toolchain
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Verify source and isolated preview destination
shell: bash
run: |
set -euo pipefail
test "$DISPATCH_EVENT" = workflow_dispatch
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
git diff --exit-code
git diff --cached --exit-code
[[ "$RUN_ID" =~ ^[0-9]+$ && "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]
owner=$(printf '%s' "$REPOSITORY" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
name=$(printf '%s' "$REPOSITORY" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
[[ "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ && "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
echo "IMAGE_NAME=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance" >> "$GITHUB_ENV"
echo "PREVIEW_TAG=mcp-acceptance-$EXPECTED_SHA-$RUN_ID-$RUN_ATTEMPT" >> "$GITHUB_ENV"
echo "PREVIEW_CACHE=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance-buildcache:node-buildcache" >> "$GITHUB_ENV"
mkdir -p .ci/acceptance-evidence
node <<'NODE'
const fs=require('node:fs'),cp=require('node:child_process'),crypto=require('node:crypto');
const files=cp.execFileSync('git',['ls-files','-z'],{encoding:'utf8'}).split('\0').filter(Boolean);
const inputs=files.map(path=>{const info=fs.lstatSync(path);if(!info.isFile()||info.isSymbolicLink())throw Error('Regular tracked source inputs required');const data=fs.readFileSync(path);return {path,sha256:crypto.createHash('sha256').update(data).digest('hex'),bytes:data.length}});
fs.writeFileSync('.ci/acceptance-evidence/source.json',JSON.stringify({format:'mcp-preview-source-v1',repository:process.env.REPOSITORY,sourceSha:process.env.EXPECTED_SHA,sourceTree:cp.execFileSync('git',['rev-parse','HEAD^{tree}'],{encoding:'utf8'}).trim(),platform:'linux/amd64',component:process.env.COMPONENT,runId:process.env.RUN_ID,runAttempt:process.env.RUN_ATTEMPT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,inputs},null,2)+'\n');
NODE
- name: Prepare existing scoped Docker context
shell: bash
run: |
set -euo pipefail
STAGING=".ci/docker/backend"
rm -rf "$STAGING"
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
cp package-lock.json "$STAGING/"
node <<'NODE'
const fs = require("node:fs")
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
pkg.workspaces = ["packages/*", "backend"]
pkg.dependencies = {}
pkg.devDependencies = {}
delete pkg.scripts
fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
NODE
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
cp -R packages/contracts/src "$STAGING/packages/contracts/"
cp backend/package.json backend/tsconfig.json backend/mcp-coverage.json "$STAGING/backend/"
cp -R backend/src "$STAGING/backend/"
mkdir -p "$STAGING/deploy/netflow-agent"
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
if [ -d backend/drizzle ]; then
cp -R backend/drizzle "$STAGING/backend/"
fi
mkdir -p "$STAGING/backend/scripts/writer-profile"
cp backend/scripts/build-database-operator.mjs backend/scripts/database-writer-bootstrap-operator.mjs backend/scripts/database-writer-profile-operator.mts "$STAGING/backend/scripts/"
cp backend/scripts/writer-profile/operator-files.mjs backend/scripts/writer-profile/operator-contract.mjs backend/scripts/writer-profile/publisher.mjs backend/scripts/writer-profile/context.mjs backend/scripts/writer-profile/catalogue.mts backend/scripts/writer-profile/catalogue-value-bridge.ts backend/scripts/writer-profile/catalogue-projection.ts backend/scripts/writer-profile/catalogue-leaves.ts backend/scripts/writer-profile/application-writer-profile-catalogue.ts backend/scripts/writer-profile/application-writer-profile-catalogue-values.ts backend/scripts/writer-profile/application-writer-profile-catalogue-parent-policy.ts backend/scripts/writer-profile/application-writer-profile-canonical.ts backend/scripts/writer-profile/fixed44-build-sql.ts backend/scripts/writer-profile/fixed44-build-definition.ts backend/scripts/writer-profile/expanded44-data-policy.ts "${STAGING}/backend/scripts/writer-profile/"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to existing Gitea registry
uses: docker/login-action@v3
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Build and push isolated acceptance image
id: build
uses: docker/build-push-action@v5
with:
context: .ci/docker/backend
file: backend/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.IMAGE_NAME }}:${{ env.PREVIEW_TAG }}
build-args: APP_VERSION=${{ env.PREVIEW_TAG }}
cache-from: type=gha,scope=mcp-acceptance-backend
cache-to: type=gha,scope=mcp-acceptance-backend,mode=max
provenance: mode=min
sbom: true
labels: |-
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.version=${{ env.PREVIEW_TAG }}
io.mmapp.acceptance-only=true
- name: Record exact digest and build metadata
shell: bash
env:
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
BUILD_METADATA: ${{ steps.build.outputs.metadata }}
run: |
set -euo pipefail
node <<'NODE'
const fs=require('node:fs'),crypto=require('node:crypto');
const digest=process.env.IMAGE_DIGEST;if(!/^sha256:[a-f0-9]{64}$/.test(digest))throw Error('Exact registry digest required');
const metadata=JSON.parse(process.env.BUILD_METADATA);const source=fs.readFileSync('.ci/acceptance-evidence/source.json');
fs.writeFileSync('.ci/acceptance-evidence/image.json',JSON.stringify({format:'mcp-preview-image-v1',sourceSha:process.env.EXPECTED_SHA,sourceProofSha256:crypto.createHash('sha256').update(source).digest('hex'),image:process.env.IMAGE_NAME,tag:process.env.PREVIEW_TAG,digest,pinnedReference:process.env.IMAGE_NAME+'@'+digest,platform:'linux/amd64',component:process.env.COMPONENT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,buildMetadata:metadata,productionDeployed:false,wholeInstallation2GbAccepted:false},null,2)+'\n');
console.log('Acceptance image digest: '+process.env.IMAGE_NAME+'@'+digest);
NODE
- name: Retain source and image binding artifacts
uses: actions/upload-artifact@v3
with:
name: mcp-acceptance-${{ env.COMPONENT }}-${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}
path: .ci/acceptance-evidence/*.json
if-no-files-found: error
retention-days: 14
frontend-preview:
if: ${{ gitea.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 40
env:
COMPONENT: frontend
EXPECTED_SHA: ${{ inputs.expected_sha }}
DISPATCH_SHA: ${{ gitea.sha }}
DISPATCH_EVENT: ${{ gitea.event_name }}
REPOSITORY: ${{ gitea.repository }}
RUN_ID: ${{ gitea.run_id }}
RUN_ATTEMPT: ${{ gitea.run_attempt }}
AUTH_PORTAL_ORIGIN_REQUEST: ${{ inputs.auth_portal_origin }}
steps:
- name: Checkout exact dispatch commit
uses: actions/checkout@v4
with:
ref: ${{ gitea.sha }}
persist-credentials: false
- name: Use existing Linux Node22 build toolchain
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Validate explicit isolated portal origin
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs=require('node:fs'),assert=require('node:assert/strict');
const raw=process.env.AUTH_PORTAL_ORIGIN_REQUEST;
assert(typeof raw==='string'&&raw.length>0&&raw.length<=2048&&!/[\x00-\x20\x7f]/.test(raw),'Bounded canonical HTTP(S) origin required');
const origin=new URL(raw);
assert(['http:','https:'].includes(origin.protocol)&&!origin.username&&!origin.password&&origin.pathname==='/'&&!origin.search&&!origin.hash&&origin.origin!=='null'&&origin.origin===raw,'Canonical origin only; credentials/path/query/hash refused');
fs.appendFileSync(process.env.GITHUB_ENV,'AUTH_PORTAL_ORIGIN='+origin.origin+'\n');
NODE
- name: Verify source and isolated preview destination
shell: bash
run: |
set -euo pipefail
test "$DISPATCH_EVENT" = workflow_dispatch
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
git diff --exit-code
git diff --cached --exit-code
[[ "$RUN_ID" =~ ^[0-9]+$ && "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]
owner=$(printf '%s' "$REPOSITORY" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
name=$(printf '%s' "$REPOSITORY" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
[[ "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ && "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
echo "IMAGE_NAME=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance" >> "$GITHUB_ENV"
echo "PREVIEW_TAG=mcp-acceptance-$EXPECTED_SHA-$RUN_ID-$RUN_ATTEMPT" >> "$GITHUB_ENV"
echo "PREVIEW_CACHE=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance-buildcache:node-buildcache" >> "$GITHUB_ENV"
mkdir -p .ci/acceptance-evidence
node <<'NODE'
const fs=require('node:fs'),cp=require('node:child_process'),crypto=require('node:crypto');
const files=cp.execFileSync('git',['ls-files','-z'],{encoding:'utf8'}).split('\0').filter(Boolean);
const inputs=files.map(path=>{const info=fs.lstatSync(path);if(!info.isFile()||info.isSymbolicLink())throw Error('Regular tracked source inputs required');const data=fs.readFileSync(path);return {path,sha256:crypto.createHash('sha256').update(data).digest('hex'),bytes:data.length}});
fs.writeFileSync('.ci/acceptance-evidence/source.json',JSON.stringify({format:'mcp-preview-source-v1',repository:process.env.REPOSITORY,sourceSha:process.env.EXPECTED_SHA,sourceTree:cp.execFileSync('git',['rev-parse','HEAD^{tree}'],{encoding:'utf8'}).trim(),platform:'linux/amd64',component:process.env.COMPONENT,runId:process.env.RUN_ID,runAttempt:process.env.RUN_ATTEMPT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,inputs},null,2)+'\n');
NODE
- name: Prepare existing scoped Docker context
shell: bash
run: |
set -euo pipefail
STAGING=".ci/docker/frontend"
rm -rf "$STAGING"
mkdir -p "$STAGING/packages/contracts" "$STAGING/public"
cp package-lock.json next.config.ts tsconfig.json postcss.config.mjs eslint.config.mjs components.json "$STAGING/"
node <<'NODE'
const fs = require("node:fs")
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
pkg.workspaces = ["packages/*"]
fs.writeFileSync(".ci/docker/frontend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
NODE
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
cp -R packages/contracts/src "$STAGING/packages/contracts/"
for dir in app components lib shared entities public hooks; do
if [ -d "$dir" ]; then
cp -R "$dir" "$STAGING/"
fi
done
test -f "$STAGING/public/release-manifest.json"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to existing Gitea registry
uses: docker/login-action@v3
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Build and push isolated acceptance image
id: build
uses: docker/build-push-action@v5
with:
context: .ci/docker/frontend
file: Dockerfile.frontend
platforms: linux/amd64
push: true
tags: ${{ env.IMAGE_NAME }}:${{ env.PREVIEW_TAG }}
build-args: |-
BACKEND_INTERNAL_URL=http://backend:8000
NEXT_PUBLIC_BACKEND_URL=same-origin
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
NEXT_PUBLIC_AUTH_PORTAL_URL=${{ env.AUTH_PORTAL_ORIGIN }}
NEXT_PUBLIC_APP_VERSION=${{ env.PREVIEW_TAG }}
NEXT_PUBLIC_RELEASE_URL=
cache-from: type=gha,scope=mcp-acceptance-frontend
cache-to: type=gha,scope=mcp-acceptance-frontend,mode=max
provenance: mode=min
sbom: true
labels: |-
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.version=${{ env.PREVIEW_TAG }}
io.mmapp.acceptance-only=true
- name: Record exact digest and build metadata
shell: bash
env:
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
BUILD_METADATA: ${{ steps.build.outputs.metadata }}
run: |
set -euo pipefail
node <<'NODE'
const fs=require('node:fs'),crypto=require('node:crypto');
const digest=process.env.IMAGE_DIGEST;if(!/^sha256:[a-f0-9]{64}$/.test(digest))throw Error('Exact registry digest required');
const metadata=JSON.parse(process.env.BUILD_METADATA);const source=fs.readFileSync('.ci/acceptance-evidence/source.json');
fs.writeFileSync('.ci/acceptance-evidence/image.json',JSON.stringify({format:'mcp-preview-image-v1',sourceSha:process.env.EXPECTED_SHA,sourceProofSha256:crypto.createHash('sha256').update(source).digest('hex'),image:process.env.IMAGE_NAME,tag:process.env.PREVIEW_TAG,digest,pinnedReference:process.env.IMAGE_NAME+'@'+digest,platform:'linux/amd64',component:process.env.COMPONENT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,buildMetadata:metadata,productionDeployed:false,wholeInstallation2GbAccepted:false},null,2)+'\n');
console.log('Acceptance image digest: '+process.env.IMAGE_NAME+'@'+digest);
NODE
- name: Retain source and image binding artifacts
uses: actions/upload-artifact@v3
with:
name: mcp-acceptance-${{ env.COMPONENT }}-${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}
path: .ci/acceptance-evidence/*.json
if-no-files-found: error
retention-days: 14
+23
View File
@@ -31,6 +31,10 @@ pnpm-debug.log*
# coverage # coverage
coverage/ coverage/
test-results/
playwright-report/
test-results/
playwright-report/
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
@@ -48,6 +52,8 @@ Thumbs.db
.vercel/ .vercel/
tmp/ tmp/
temp/ temp/
.mcp-state/
backend/.mcp-state/
.ci/docker/ .ci/docker/
.claude/settings.local.json .claude/settings.local.json
@@ -55,3 +61,20 @@ temp/
.cursor/mcp.json .cursor/mcp.json
.mcp.json .mcp.json
opencode.json opencode.json
# Local codegraph process
.codegraph/daemon.pid
# Python controller tests
__pycache__/
*.pyc
# NetFlow agent host state (when Compose is installed in a checkout)
/data/netflow-wireguard/
/data/netflow-agent-socket/
# Compiled disposable memory-acceptance entry; not a production image artifact.
backend/dist-mcp-memory/
backend/dist-operator/
# Agent-local permissions can contain commands with deployment credentials.
/.claude/settings.local.json
-67
View File
@@ -1,67 +0,0 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
-43
View File
@@ -1,43 +0,0 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
## Operational surfaces (settings / profile / admin)
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
-60
View File
@@ -1,60 +0,0 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
-408
View File
@@ -1,408 +0,0 @@
# ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useTable({
features: dataGridFeatures,
data,
columns,
})
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FramePanel>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<div className="p-5">Content</div>
<FrameFooter>Footer</FrameFooter>
</FramePanel>
</Frame>
```
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
## base vs radix - write for the project's base
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
- `"style": "base-nova"` -> **Base UI**
- `"style": "radix-nova"` -> **Radix UI**
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
-45
View File
@@ -1,45 +0,0 @@
# Craft: make ReUI UI exceptional, not generic
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
## Have a point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
## Brutally clear hierarchy
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
## Spacing rhythm and deliberate density
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
## Cover the real states (the usual miss)
A surface isn't done at the happy path. Compose, and wire:
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
## Responsive by default
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
## Motion, subtly
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
## Real, activated content
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
## Avoid the AI tells
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
## The bar
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
-39
View File
@@ -1,39 +0,0 @@
# Icons (ReUI delta over shadcn)
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remix` -> `@remixicon/react`
- `hugeicons` -> `@hugeicons/react`
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
```
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
The `icon-stack` component composes multiple icons into a stacked display.
-22
View File
@@ -1,22 +0,0 @@
# Quality gates (security, accessibility, scroll)
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
-35
View File
@@ -1,35 +0,0 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
## Free vs premium
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
-26
View File
@@ -1,26 +0,0 @@
# Styling (ReUI delta over shadcn)
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
## ReUI extended semantic tokens
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
- `--success` / `--success-foreground`
- `--info` / `--info-foreground`
- `--warning` / `--warning-foreground`
- `--destructive-foreground` (paired with shadcn's `--destructive`)
- `--invert` / `--invert-foreground` (inverted surfaces)
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
**Incorrect:** `<span className="text-green-600">Active</span>`
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
## Blocks follow your theme
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
## Density and typography rhythm
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
-54
View File
@@ -1,54 +0,0 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
-58
View File
@@ -1,58 +0,0 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp
@@ -1,63 +0,0 @@
# Локальные GeoLite2-базы (Country + ASN) для потоков по странам и ASN
## Контекст
Сейчас страна и ASN для netflow-потоков резолвятся через внешний RIPEstat API (`backend/src/services/traffic-flow-ripe.ts`): лимит 30 новых префиксов/мин, очередь на 90, кэш в PG `flow_ip_meta`. Новые IP «дозревают» с задержкой, IPv6 не покрывается (кэш индексируется только по IPv4). Локальные mmdb-базы дают мгновенный синхронный lookup всех IP без внешних вызовов.
Решения (подтверждены):
- Источник — **MaxMind GeoLite2 через P3TERX-зеркало**: `https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb` и `.../GeoLite2-ASN.mmdb`. Без регистрации, ключей и tar-распаковки. Точность по стране у GeoLite2 и IPinfo паритетная (<1% ошибок у обоих, arXiv 2026); выбран P3TERX за надёжность зеркала (5.2k звёзд) и преемственность: текущий RIPE-путь и так читает GeoLite (`maxmind-geo-lite`), история в кэше остаётся консистентной.
- **RIPEstat остаётся fallback** (до первой загрузки баз / если lookup не дал результата).
- City-базу не качаем (lat/lng фронтенд не использует).
## Изменения
### 1. Зависимость
- `npm install -w mikrotik-manager-backend maxmind` — sync-чтение mmdb, встроенные TS-типы, без транзитивных зависимостей, Node 22 ок.
### 2. Новый сервис `backend/src/services/traffic-flow-geoip.ts`
(по конвенциям окружения traffic-flow-*: контракт → маршрут → сервис, без БД-логики в маршрутах)
- Каталог: `backend/storage/geoip/` (конвенция `storage/backups`), файлы `GeoLite2-Country.mmdb`, `GeoLite2-ASN.mmdb`.
- `initGeoip()` — открыть ридеры best-effort при старте (из `index.ts` рядом с `startTrafficFlowListener`), независимо от настроек автообновления: файлы есть — работают.
- `lookupGeoip(ip): FlowIpMeta | null` — синхронно: `country.iso_code` (fallback `registered_country.iso_code`) с валидацией `isIsoCountry`, ASN = `autonomous_system_number`, holder = `autonomous_system_organization`; приватные IP → negative-запись как в RIPE (`isNonPublicIp`); в PG не пишем (lookup и так быстрый). IPv6 поддержан ридером.
- `resolveFlowIp(ip)` — фасад: `lookupGeoip(ip) ?? lookupRipeCached(ip)`; главный экспорт для потребителей.
- `geoipStatus()` — loaded, даты сборки баз (метаданные mmdb). Тест-хук `setGeoipReadersForTests`. Смена ридеров после обновления — атомарная замена ссылок.
### 3. Коллектор `backend/src/services/geoip-update-collector.ts`
`collectGeoipUpdateOnce()` по образцу `certificate-renew-collector.ts`:
1. Conditional GET с ETag/If-None-Match из настроек → 304 = skip (фолбэк-сравнение: размер/содержимое).
2. Скачивание в `*.tmp` через глобальный `fetch` + AbortController с таймаутом (внешний HTTP из service-слоя — по правилу fastify-backend-drizzle).
3. Валидация: открыть ридер из tmp-файла, пробой 8.8.8.8 (страна US, ASN 15169).
4. `fs.rename` атомарная подмена, старый файл → `*.prev` (откат, если новый ридер не открылся).
5. Перезагрузка ридеров, статус в настройках; snapshot для `scheduler_runs` (checked/downloaded/skipped/bytes/error).
### 4. Планировщик (`backend/src/services/scheduler.ts`)
- `JOB_KEYS` += `geoip_update`; case в `runSchedulerJobBody`; блок в `refreshScheduler()` по образцу `certificates_renew`: интервал `Math.max(6ч, updateIntervalSec*1000)`, по умолчанию 7 дней (upstream обновляется еженедельно) + немедленный первый запуск при включённой настройке.
### 5. Схема и миграция
- `backend/src/db/schema.ts`: singleton `geoip_settings` — `enabled` (default true), `updateIntervalSec` (default 604800), `lastCheckAt`, `lastSuccessAt`, `lastError`, `countryBuildAt`, `asnBuildAt`, `etagsJson` (jsonb), `createdAt/updatedAt`.
- Миграция: `npm run db:generate` → файл в `backend/drizzle/`.
### 6. API + контракты
- `packages/contracts/src/geoip.ts`: zod-схемы настроек/статуса (все входы — Zod, по правилам проекта).
- Новый `backend/src/routes/geoip.ts`, регистрация в `index.ts` с prefix `/api`:
- `GET /api/geoip` — настройки + статус (ready, даты сборки, последняя проверка/ошибка);
- `PUT /api/geoip` — сохранить настройки, затем `refreshScheduler()`;
- `POST /api/geoip/update` — запустить загрузку сейчас (409, если уже идёт; флаг-гард как в коллекторах).
### 7. Интеграция в пайплайн (geoip-first, RIPE-fallback)
- `traffic-flow-engine.ts` (`queueParsedFlows`, ~строка 336): `lookupRipeCached` → `resolveFlowIp`. Логика misses не меняется: при готовом mmdb публичные IP (v4+v6) резолвятся сразу, очередь RIPE пустеет; до скачивания баз — прежнее поведение.
- Остальные вызовы `lookupRipeCached` → `resolveFlowIp` (grep: как минимум `traffic-flow-analytics.ts` ~258–271).
- `classifyFlowDst`/бренды не трогаем: holder из mmdb (org name) встаёт в существующие `HOLDER_BRANDS`-регулярки как есть.
### 8. Frontend (по next-shadcn-production / ui-guardian: только переиспользование)
- Секция «GeoIP-базы (GeoLite2)» внутри существующей `components/traffic/netflow-settings-panel.tsx`: статус (готово/не скачано, даты сборки Country/ASN, последняя проверка, ошибка), тумблер автообновления, интервал, кнопка «Обновить сейчас» с индикатором. Только уже используемые в панели примитивы (Switch/Button/поля) — никаких новых визуальных паттернов и Card-shell. API-клиент через существующие http-хелперы.
### 9. Хаускипинг, тесты, проверки
- `backend/.gitignore`: `storage/geoip/`.
- Тесты `backend/src/services/traffic-flow-geoip.test.ts` + скрипт `test:geoip` (по образцу `test:traffic-flow`): приоритет фасада (geoip hit → RIPE не зовётся; miss → fallback), negative на приватных IP, фильтрация EU/ZZ через `isIsoCountry`, коллектор с мокнутым fetch (304-skip, битый файл → подмены нет, `.prev` сохранён), dims по стране/ASN с засеянным ридером.
- Проверки после реализации (обязательно по правилам): типы/сборка бэка (`npm run build -w mikrotik-manager-backend`), типы фронта при правке UI (`npx tsc --noEmit`), `npm run test:geoip` и `test:traffic-flow`; предупреждения не игнорировать.
- Коммит: `feat(netflow): <subject по-русски>` — новая пользовательская фича (мгновенные страна/ASN в потоках), по commit-messages-ru.
- README: короткий раздел о GeoIP; примечание, что в Docker `storage/geoip` ephemeral без тома — базы перекачаются после пересоздания контейнера (~17 МБ); при желании смонтировать volume.
## Что это даёт
Страна и ASN появляются у потока мгновенно при ingest (включая IPv6), без ограничения скорости RIPE; dims `country`/`asn` в `flow_daily_dims`, аналитика (карта, топы, monthly) становятся полными сразу. Внешняя зависимость от stat.ripe.net остаётся только как fallback до первой загрузки баз.
+2 -1
View File
@@ -17,6 +17,7 @@
## ReUI ## ReUI
Skill: `.claude/skills/reui` / `.cursor/skills/reui` — версия `668fb463eb` (20 free components). Skill: `.agents/skills/reui` (Codex), `.claude/skills/reui`, `.cursor/skills/reui`, `.opencode/skills/reui` — версия `fe6742e29c` (22 free components).
Codex MCP: `.codex/config.toml`, стиль `base-nova`, авторизация через переменную окружения `REUI_LICENSE_KEY`. Для PRO-реестра shadcn тот же ключ хранится в `.env.local` (исключён из Git); реестр настроен в `components.json`. После изменения переменной окружения перезапустить Codex.
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills) · [Cursor](https://reui.io/docs/cursor). Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills) · [Cursor](https://reui.io/docs/cursor).
Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта. Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
+6 -2
View File
@@ -219,9 +219,9 @@ Backend держит локальные mmdb-базы MaxMind GeoLite2 (Country
### Версионирование и релизы ### Версионирование и релизы
- Базовая версия: **`v1.0.0`**. Линия semver: `1.x.y` (major `2.x` вне scope). - Базовая версия: **`v1.0.0`**. Поддерживаются patch, minor и major; выпуск `2.0.0` инициируется breaking-коммитом, версия вычисляется CI.
- Job **`prepare-release`** запускает [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs): коммиты с последнего тега `v*.*.*`, bump по **Conventional Commits**. - Job **`prepare-release`** запускает [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs): коммиты с последнего тега `v*.*.*`, bump по **Conventional Commits**.
- **`feat` / `feat!` / `BREAKING CHANGE`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`). - **`!` после типа/scope или footer `BREAKING CHANGE:` / `BREAKING-CHANGE:`** → major (`2.0.0` после `1.x`); **`feat`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
- Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:<sha>`. - Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:<sha>`.
- Первый релиз без тега `v1.0.0` возможен автоматически: CI берёт историю `HEAD`, считает bump от `1.0.0` и создаёт тег (например `v1.1.0` при `feat:`). - Первый релиз без тега `v1.0.0` возможен автоматически: CI берёт историю `HEAD`, считает bump от `1.0.0` и создаёт тег (например `v1.1.0` при `feat:`).
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`. - Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
@@ -568,3 +568,7 @@ docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
10. Проверить `curl` на `:8000/health` и `:3000/`. 10. Проверить `curl` на `:8000/health` и `:3000/`.
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`. 11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`. 12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
### Автоматизация NetFlow через Docker-агент
[Установка, схема взаимодействия и production-состав](docs/netflow-agent.md).
+318 -683
View File
File diff suppressed because it is too large. Load diff
+13 -28
View File
@@ -8,25 +8,22 @@ import { AsnsDataGrid } from "@/components/data-grids/asns-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog" import { FileImportDialog } from "@/components/file-import-dialog"
import { asns as mockAsns } from "@/lib/data" import { asns as mockAsns } from "@/lib/data"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react" import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
export default function AsnsPage() { export default function AsnsPage() {
const { mode } = useDataSource() const { mode, snapshot, loading, known } = useEvoCatalogView()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const useEvoCatalog = mode === "live" && enabled const useEvoCatalog = mode === "live"
const rows = useMemo(() => { const rows = useMemo(() => {
if (!useEvoCatalog) return mockAsns if (mode === "mock") return mockAsns
if (loading && !snapshot) return [] if (loading && !snapshot) return []
return snapshot?.asns ?? [] return snapshot?.asns ?? []
}, [useEvoCatalog, loading, snapshot]) }, [mode, loading, snapshot])
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search) return rows if (!search) return rows
@@ -35,7 +32,7 @@ export default function AsnsPage() {
(r) => (r) =>
r.asn.toLowerCase().includes(q) || r.asn.toLowerCase().includes(q) ||
r.org.toLowerCase().includes(q) || r.org.toLowerCase().includes(q) ||
String(r.prefixes).includes(q), (r.prefixes === null ? "" : String(r.prefixes)).includes(q),
) )
}, [rows, search]) }, [rows, search])
@@ -43,8 +40,7 @@ export default function AsnsPage() {
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Данные" }, { label: "ASN" }]} crumbs={[{ label: "Данные" }, { label: "ASN" }]}
actions={ actions={mode === "live" ? null : <>
<>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}> <Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт <UploadIcon className="size-4" />Импорт
</Button> </Button>
@@ -60,32 +56,21 @@ export default function AsnsPage() {
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Автономные системы — источники маршрутов для BGP-фильтров Автономные системы — источники маршрутов для BGP-фильтров
</p> </p>
{useEvoCatalog && ( <div className="mt-3"><EvoBgpCatalogStatus /></div>
<p className={cn(
"text-xs mt-2 flex items-center gap-1.5",
error ? "text-destructive" : "text-muted-foreground",
)}>
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
{error
? `EvoBGP: ${error}`
: snapshot?.fetchedAt
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
</p>
)}
</div> </div>
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по ASN, имени, префиксам…" searchPlaceholder="Поиск по ASN, имени, префиксам…"
countLabel={`${filtered.length} ASN`} countLabel={known ? `${filtered.length} ASN` : "Количество: —"}
/> />
<AsnsDataGrid {known || loading ? <AsnsDataGrid
asns={filtered} asns={filtered}
isLoading={useEvoCatalog && loading && !snapshot} isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog} pagination={useEvoCatalog}
/> emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
/> : <EvoBgpCatalogUnavailable />}
</DataPageCard> </DataPageCard>
</div> </div>
</div> </div>
+331 -250
View File
@@ -1,10 +1,11 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react" import { Suspense, useCallback, useEffect, useRef, useState } from "react"
import { Frame, FramePanel } from "@/components/reui/frame"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import type { Backup, Server } from "@/lib/data" import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data"
import type { Filter } from "@/components/reui/filters" import { useBackupsUrlState } from "@/hooks/use-backups-url-state"
import type { BackupStorageSettingsDto } from "@mmapp/contracts/backups" import { backupStorageSettingsDtoSchema, type BackupScheduleSettingsDto, type BackupStorageSettingsDto } from "@mmapp/contracts/backups"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert" import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -17,12 +18,13 @@ import {
RefreshCwIcon, RefreshCwIcon,
} from "lucide-react" } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers" import { serverListSchema } from "@mmapp/contracts/servers"
import { can } from "@/lib/auth"
import { userApiError } from "@/shared/api/users"
import { toFrontendServer } from "@/entities/server/model/mappers" import { toFrontendServer } from "@/entities/server/model/mappers"
import { import {
createBackupsAsync,
deleteBackup, deleteBackup,
getBackupJob, downloadBackup,
getBackupScheduleSettings, getBackupScheduleSettings,
getBackupStorageSettings, getBackupStorageSettings,
listBackups, listBackups,
@@ -33,9 +35,16 @@ import {
testBackupStorage, testBackupStorage,
type BackupItem, type BackupItem,
} from "@/shared/api/backups" } from "@/shared/api/backups"
import { requestBlob } from "@/shared/api/http-client" import { ApiClientError, requestJson } from "@/shared/api/http-client"
import { toast } from "sonner" import { toast } from "sonner"
import { useBackupRestoreGuard } from "@/lib/use-backup-restore-guard"
import { restoreWasNotApplied } from "@/lib/backup-restore-checkpoint"
import { Checkbox } from "@/components/ui/checkbox"
import { useBackupJob } from "@/lib/use-backup-job"
import { backupJobOutcome, backupJobResultError } from "@/lib/backup-job-poll"
import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
import { BackupsHistory } from "@/components/backups/backups-history" import { BackupsHistory } from "@/components/backups/backups-history"
import { backupFileMissing } from "@/lib/backup-file-state"
import { import {
BackupsSettings, BackupsSettings,
WEEK_DAYS, WEEK_DAYS,
@@ -48,27 +57,73 @@ import { BackupCreateSheet } from "@/components/backups/backup-create-sheet"
import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/backup-confirm-dialogs" import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/backup-confirm-dialogs"
type PageTab = "history" | "settings" type PageTab = "history" | "settings"
type KindFilter = "all" | "auto" | "manual"
export default function BackupsPage() { type Resource = "backups" | "servers" | "schedule" | "storage"
const { backendUrl } = useDataSource() type ResourceState = { known: boolean; error: string | null; receivedAt: string | null }
const [tab, setTab] = useState<PageTab>("history") const unknownResource = (): ResourceState => ({ known: false, error: null, receivedAt: null })
const [backupList, setBackupList] = useState<Backup[]>([])
const [kindFilter, setKindFilter] = useState<KindFilter>("all") export default function BackupsPage() { return <Suspense fallback={<p role="status" className="p-5">Загрузка контекста резервных копий…</p>}><BackupsSource /></Suspense> }
const [search, setSearch] = useState("") function BackupsSource() {
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([]) const { mode, backendUrl, prefsHydrated } = useDataSource()
const [liveServers, setLiveServers] = useState<Server[]>([]) if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
return <BackupsContent key={mode + ":" + backendUrl} />
}
function BackupsContent() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const canRead = can("mm:backups:read")
const canWrite = isLive && can("mm:backups:write")
const canReadServers = can("mm:servers:read")
const [resources, setResources] = useState<Record<Resource, ResourceState>>(() => ({ backups: unknownResource(), servers: unknownResource(), schedule: unknownResource(), storage: unknownResource() }))
const dialogTrigger = useRef<{ element: HTMLElement; rowId: string | null; label: string | null } | null>(null)
const refreshTrigger = useRef<HTMLButtonElement | null>(null)
const captureDialogFocus = () => {
const element = document.activeElement
dialogTrigger.current = element instanceof HTMLElement
? { element, rowId: element.closest("[data-row-id]")?.getAttribute("data-row-id") ?? null, label: element.getAttribute("aria-label") }
: null
}
const returnDialogFocus = () => {
const trigger = dialogTrigger.current
if (trigger?.element.isConnected) return trigger.element
// Cell renderers can remount when operation state changes; resolve the same row/action.
if (trigger?.rowId && trigger.label) {
const replacement = document.querySelector<HTMLElement>(`[data-row-id="${CSS.escape(trigger.rowId)}"] button[aria-label="${CSS.escape(trigger.label)}"]`)
if (replacement) return replacement
}
return refreshTrigger.current
}
const reader = useRef<AbortController | null>(null)
const revision = useRef(0)
const alive = useRef(true)
const scheduleDirty = useRef(false)
const storageDirty = useRef(false)
const operationLock = useRef(false)
const operationController = useRef<AbortController | null>(null)
const downloadControllers = useRef(new Map<string, AbortController>())
const settingsLock = useRef(false)
const settingsController = useRef<AbortController | null>(null)
const known = (resource: Resource) => !isLive || resources[resource].known
const current = (resource: Resource) => known(resource) && !resources[resource].error
const { tab, setTab, kindFilter, setKindFilter, search, setSearch, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, reset: resetContext } = useBackupsUrlState()
const [backupList, setBackupList] = useState<Backup[]>(isLive ? [] : mockBackups)
const [liveServers, setLiveServers] = useState<Server[]>(isLive ? [] : mockServers)
const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const restoreGuard = useBackupRestoreGuard(backendUrl, isLive)
const [restoreReviewed, setRestoreReviewed] = useState(false)
const [opBusy, setOpBusy] = useState(false) const [opBusy, setOpBusy] = useState(false)
const [opError, setOpError] = useState<string | null>(null) const [opError, setOpError] = useState<string | null>(null)
const [backupJobId, setBackupJobId] = useState<string | null>(null)
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false) const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
const [testBusy, setTestBusy] = useState(false) const [testBusy, setTestBusy] = useState(false)
const [syncBusy, setSyncBusy] = useState(false) const [syncBusy, setSyncBusy] = useState(false)
const [schedule, setSchedule] = useState<BackupScheduleForm>(defaultSchedule) const [schedule, setSchedule] = useState<BackupScheduleForm>(defaultSchedule)
const [storage, setStorage] = useState<BackupStorageForm>(defaultStorageForm) const [storage, setStorage] = useState<BackupStorageForm>(defaultStorageForm)
const [storageIsDirty, setStorageIsDirty] = useState(false)
const [savedSchedule, setSavedSchedule] = useState<BackupScheduleSettingsDto | null>(null)
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null) const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null) const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
const [allEnabled, setAllEnabled] = useState(true)
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set()) const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set())
const [manualOpen, setManualOpen] = useState(false) const [manualOpen, setManualOpen] = useState(false)
const [manualStep, setManualStep] = useState(1) const [manualStep, setManualStep] = useState(1)
@@ -77,256 +132,224 @@ export default function BackupsPage() {
const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null) const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null)
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null) const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
const setSched = <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => const setSched = <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => { scheduleDirty.current = true; setSchedule(s => ({ ...s, [k]: v })) }
setSchedule((s) => ({ ...s, [k]: v })) const setStore = <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) => { if (k !== "showPassword") { storageDirty.current = true; setStorageIsDirty(true) } setStorage(s => ({ ...s, [k]: v })) }
const setStore = <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) =>
setStorage((s) => ({ ...s, [k]: v }))
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => { const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
const kb = Math.max(1, Math.round(b.sizeBytes / 1024)) const kb = Math.round(b.sizeBytes / 1024)
return { return {
id: b.id, id: b.id,
server: b.serverName, server: b.serverName,
serverId: b.serverId == null ? null : String(b.serverId), serverId: b.serverId == null ? null : String(b.serverId),
filename: b.filename, filename: b.filename,
size: `${kb} КБ`, size: b.sizeBytes < 1024 ? `${b.sizeBytes} Б` : `${kb} КБ`,
sizeBytes: b.sizeBytes,
created: new Date(b.createdAt).toLocaleString("ru-RU"), created: new Date(b.createdAt).toLocaleString("ru-RU"),
kind: b.kind, kind: b.kind,
notes: b.notes ?? "", notes: b.notes ?? "",
storage: b.storage ?? "local", storage: b.storage,
uploadError: b.uploadError ?? undefined, uploadError: b.uploadError ?? undefined,
localFileStatus: b.localFileStatus,
} }
}, []) }, [])
const loadLive = useCallback(async () => { const loadLive = useCallback(async () => {
if (!isLive || !canRead || !alive.current) return
reader.current?.abort()
const abort = new AbortController(), version = ++revision.current
reader.current = abort
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)])
const active = () => alive.current && !abort.signal.aborted && revision.current === version
const success = (resource: Resource) => { if (active()) setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } })) }
const failure = (resource: Resource, error: unknown) => { if (active()) setResources(prev => ({ ...prev, [resource]: { ...prev[resource], error: userApiError(error) } })) }
setLoading(true) setLoading(true)
setOpError(null) await Promise.allSettled([
try { listBackups(backendUrl, { signal }).then(rows => { if (!active()) return; setBackupList(rows.map(mapApiBackupToUi)); success("backups") }).catch(error => failure("backups", error)),
const [serversRows, backupsRows, scheduleRow, storageRow] = await Promise.all([ canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
listServers(backendUrl), const rows = serverListSchema.parse(payload)
listBackups(backendUrl), if (!active()) return
getBackupScheduleSettings(backendUrl), setLiveServers(rows.map(row => ({ ...toFrontendServer(row), status: row.status ?? "unknown" }))); success("servers")
getBackupStorageSettings(backendUrl), }).catch(error => failure("servers", error)) : Promise.resolve(),
]) getBackupScheduleSettings(backendUrl, { signal }).then(row => {
const mappedServers = serversRows.map((s) => toFrontendServer(s)) if (!active()) return
setLiveServers(mappedServers) if (!scheduleDirty.current) { setSchedule(row); setSelectedServers(new Set(row.serverIds)); setAllEnabled(row.serverIds.length === 0) }
setBackupList(backupsRows.map(mapApiBackupToUi)) setSavedSchedule(row); setScheduleLastError(row.lastError ?? null); success("schedule")
setSchedule({ }).catch(error => failure("schedule", error)),
enabled: scheduleRow.enabled, getBackupStorageSettings(backendUrl, { signal }).then(row => {
frequency: scheduleRow.frequency, if (!active()) return
hour: scheduleRow.hour, setSavedStorage(row)
minute: scheduleRow.minute, if (!storageDirty.current) setStorage(prev => ({ ...prev, ...row, s3SecretAccessKey: "" }))
weekDay: scheduleRow.weekDay, success("storage")
monthDay: scheduleRow.monthDay, }).catch(error => failure("storage", error)),
keepCount: scheduleRow.keepCount, ])
format: scheduleRow.format, if (active()) setLoading(false)
}) }, [backendUrl, isLive, canRead, canReadServers, mapApiBackupToUi])
setSavedStorage(storageRow)
setScheduleLastError(scheduleRow.lastError ?? null)
setStorage((prev) => ({
...prev,
provider: storageRow.provider,
s3Endpoint: storageRow.s3Endpoint,
s3Region: storageRow.s3Region,
s3Bucket: storageRow.s3Bucket,
s3Prefix: storageRow.s3Prefix,
s3AccessKeyId: storageRow.s3AccessKeyId,
s3SecretAccessKey: "",
s3ForcePathStyle: storageRow.s3ForcePathStyle,
keepLocalCopy: storageRow.keepLocalCopy,
}))
const serverIds = scheduleRow.serverIds.length > 0
? scheduleRow.serverIds
: mappedServers.map((s) => s.id)
setSelectedServers(new Set(serverIds.filter((id) => mappedServers.some((s) => s.id === id))))
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
} finally {
setLoading(false)
}
}, [backendUrl, mapApiBackupToUi])
useEffect(() => { useEffect(() => {
void loadLive() alive.current = true
const downloads = downloadControllers.current
queueMicrotask(() => { if (alive.current) void loadLive() })
return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort(); operationController.current?.abort(); downloads.forEach(controller => controller.abort()) }
}, [loadLive]) }, [loadLive])
async function handleSave() { const job = useBackupJob(backendUrl, isLive, canRead, baseCanCreate && !opBusy && !scheduleSaveBusy, loadLive)
if (scheduleSaveBusy) return const checkpoint = job.checkpoint
setScheduleSaveBusy(true) const jobTerminal = !!checkpoint?.job && ["done", "failed"].includes(checkpoint.job.status)
setOpError(null) const backupJobId = checkpoint && !jobTerminal ? checkpoint.request.requestId : null
try { const persistedJobError = checkpoint?.job && jobTerminal ? backupJobResultError(checkpoint.job) : null
await putBackupScheduleSettings(backendUrl, { const canCreate = baseCanCreate && job.hydrated && !checkpoint && !job.storageError
enabled: schedule.enabled,
frequency: schedule.frequency,
hour: schedule.hour,
minute: schedule.minute,
weekDay: schedule.weekDay,
monthDay: schedule.monthDay,
keepCount: schedule.keepCount,
format: "rsc",
serverIds: [...selectedServers],
})
const saved = await putBackupStorageSettings(backendUrl, {
provider: storage.provider,
s3Endpoint: storage.s3Endpoint,
s3Region: storage.s3Region,
s3Bucket: storage.s3Bucket,
s3Prefix: storage.s3Prefix,
s3AccessKeyId: storage.s3AccessKeyId,
s3SecretAccessKey: storage.s3SecretAccessKey || undefined,
s3ForcePathStyle: storage.s3ForcePathStyle,
keepLocalCopy: storage.keepLocalCopy,
})
setSavedStorage(saved)
setStorage((prev) => ({ ...prev, s3SecretAccessKey: "" }))
toast.success("Настройки сохранены")
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка сохранения настроек")
} finally {
setScheduleSaveBusy(false)
}
}
function startSettingsMutation() {
if (!alive.current || settingsLock.current || operationLock.current || backupJobId) return null
settingsLock.current = true
const abort = new AbortController()
settingsController.current = abort
reader.current?.abort(); revision.current += 1; setLoading(false)
setScheduleSaveBusy(true); setOpError(null)
return abort
}
function finishSettingsMutation(abort: AbortController) {
if (settingsController.current !== abort) return
settingsLock.current = false; settingsController.current = null
if (alive.current) { setScheduleSaveBusy(false); setTestBusy(false); setSyncBusy(false) }
}
function settingsConfirmed(resource: Resource) {
setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
}
async function handleSave() {
if (!canWrite || !current("schedule") || !current("servers") || !canReadServers) return
const abort = startSettingsMutation(); if (!abort) return
try {
const payload = backupSchedulePayload(schedule, [...selectedServers], allEnabled)
if (payload.serverIds?.some(id => !liveServers.some(server => server.id === id))) throw new Error("Выбранный сервер отсутствует; обновите или измените выбор")
const result = await putBackupScheduleSettings(backendUrl, payload, { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
setSavedSchedule(result); setSchedule(result); setScheduleLastError(result.lastError ?? null); scheduleDirty.current = false
setSelectedServers(new Set(result.serverIds)); setAllEnabled(result.serverIds.length === 0); settingsConfirmed("schedule")
toast.success("Расписание сохранено")
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
finally { finishSettingsMutation(abort) }
}
async function handleSaveStorage() {
if (!canWrite || !current("storage")) return
const abort = startSettingsMutation(); if (!abort) return
try {
const result = await putBackupStorageSettings(backendUrl, backupStoragePayload(storage, savedStorage?.secretConfigured), { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
setSavedStorage(result); setStorage(prev => ({ ...prev, ...result, s3SecretAccessKey: "" })); storageDirty.current = false; setStorageIsDirty(false); settingsConfirmed("storage")
toast.success("Хранилище сохранено")
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
finally { finishSettingsMutation(abort) }
}
async function handleTestStorage() { async function handleTestStorage() {
if (!canWrite || !current("storage")) return
if (storageDirty.current) { setOpError("Сначала сохраните хранилище; проверка использует подтверждённые настройки"); return }
if (savedStorage?.provider !== "s3") { setOpError("Проверка доступна для сохранённого хранилища S3"); return }
const abort = startSettingsMutation(); if (!abort) return
setTestBusy(true) setTestBusy(true)
try { try {
await putBackupStorageSettings(backendUrl, { const result = await testBackupStorage(backendUrl, { signal: abort.signal })
provider: storage.provider, if (!alive.current || abort.signal.aborted) return
s3Endpoint: storage.s3Endpoint, setSavedStorage(result); settingsConfirmed("storage")
s3Region: storage.s3Region, if (result.lastTestError || !result.lastTestAt || result.provider !== "s3") throw new Error(result.lastTestError || "Проверка S3 не подтверждена")
s3Bucket: storage.s3Bucket, toast.success("Доступ к S3 подтверждён")
s3Prefix: storage.s3Prefix, } catch (error) {
s3AccessKeyId: storage.s3AccessKeyId, if (alive.current && !abort.signal.aborted) {
s3SecretAccessKey: storage.s3SecretAccessKey || undefined, if (error instanceof ApiClientError && error.payload && typeof error.payload === "object" && "settings" in error.payload) {
s3ForcePathStyle: storage.s3ForcePathStyle, const parsed = backupStorageSettingsDtoSchema.safeParse(error.payload.settings)
keepLocalCopy: storage.keepLocalCopy, if (parsed.success) setSavedStorage(parsed.data)
}) }
const result = await testBackupStorage(backendUrl) setOpError(userApiError(error))
setSavedStorage(result)
toast.success("S3 доступен")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка проверки S3")
try {
setSavedStorage(await getBackupStorageSettings(backendUrl))
} catch {
/* ignore */
} }
} finally {
setTestBusy(false)
} }
finally { finishSettingsMutation(abort) }
} }
async function handleSyncStorage() { async function handleSyncStorage() {
if (!canWrite || !current("storage")) return
if (storageDirty.current) { setOpError("Сначала сохраните хранилище; синхронизация использует подтверждённые настройки"); return }
if (savedStorage?.provider !== "s3") return
const abort = startSettingsMutation(); if (!abort) return
setSyncBusy(true) setSyncBusy(true)
try { try {
const result = await syncBackupsFromStorage(backendUrl) const result = await syncBackupsFromStorage(backendUrl, { signal: abort.signal })
toast.success(`Импортировано: ${result.imported}, пропущено: ${result.skipped}`) if (!alive.current || abort.signal.aborted) return
toast.success("Импортировано: " + result.imported + ", пропущено: " + result.skipped)
await loadLive() await loadLive()
} catch (e) { } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
toast.error(e instanceof Error ? e.message : "Ошибка синхронизации") finally { finishSettingsMutation(abort) }
} finally {
setSyncBusy(false)
}
} }
async function handleManualBackup() { async function handleManualBackup() {
if (manualServers.size === 0 || opBusy) return if (checkpoint) { await job.run(); return }
setOpError(null) if (!canCreate || !manualServers.size || settingsLock.current || opBusy) return
try { if ([...manualServers].some(id => !liveServers.some(server => server.id === id))) { setOpError("Выбранный сервер отсутствует; обновите выбор"); return }
const res = await createBackupsAsync(backendUrl, { await job.run({ serverIds: [...manualServers], notes: manualNotes || undefined })
serverIds: [...manualServers],
notes: manualNotes || undefined,
})
setBackupJobId(res.jobId)
setManualOpen(false)
setManualServers(new Set())
setManualNotes("")
setTab("history")
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка создания бэкапа")
}
} }
useEffect(() => {
if (!backupJobId) return
toast.info("Бэкап выполняется в фоне...")
let cancelled = false
const timer = setInterval(() => {
void (async () => {
try {
const job = await getBackupJob(backendUrl, backupJobId)
if (cancelled) return
if (job.status === "done") {
clearInterval(timer)
setBackupJobId(null)
if (job.failures.length > 0) {
setOpError(`Часть бэкапов не создалась: ${job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ")}`)
} else {
toast.success("Бэкап успешно завершён")
}
await loadLive()
} else if (job.status === "failed") {
clearInterval(timer)
setBackupJobId(null)
setOpError(job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ") || "Ошибка фоновой задачи бэкапа")
await loadLive()
}
} catch (e) {
clearInterval(timer)
setBackupJobId(null)
setOpError(e instanceof Error ? e.message : "Ошибка опроса задачи бэкапа")
}
})()
}, 1200)
return () => {
cancelled = true
clearInterval(timer)
}
}, [backendUrl, backupJobId, loadLive])
useEffect(() => { useEffect(() => {
if (opError) toast.error(opError) if (opError) toast.error(opError)
}, [opError]) }, [opError])
function startOperation() {
if (!canWrite || !alive.current || operationLock.current || settingsLock.current || backupJobId) return null
operationLock.current = true
const abort = new AbortController(); operationController.current = abort
reader.current?.abort(); revision.current += 1; setLoading(false)
setOpBusy(true); setOpError(null)
return abort
}
function finishOperation(abort: AbortController) {
if (operationController.current !== abort) return
operationLock.current = false; operationController.current = null
if (alive.current) setOpBusy(false)
}
async function handleDelete() { async function handleDelete() {
if (!deleteTarget) return if (!deleteTarget) return
setOpBusy(true) const target = deleteTarget, abort = startOperation(); if (!abort) return
setOpError(null)
try { try {
await deleteBackup(backendUrl, deleteTarget.id) await deleteBackup(backendUrl, target.id, { signal: abort.signal }, backupFileMissing(target))
setDeleteTarget(null) if (!alive.current || abort.signal.aborted) return
setBackupList(previous => previous.filter(backup => backup.id !== target.id)); setDeleteTarget(null)
toast.success("API подтвердил удаление резервной копии")
await loadLive() await loadLive()
} catch (e) { } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
setOpError(e instanceof Error ? e.message : "Ошибка удаления бэкапа") finally { finishOperation(abort) }
} finally {
setOpBusy(false)
}
} }
async function handleRestore() { async function handleRestore() {
if (!restoreTarget) return if (!restoreTarget || !restoreTarget.serverId || !restoreGuard.canStart) return
setOpBusy(true) if (backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget)) {
setOpError(null) setOpError("Локальный файл отсутствует. Создайте новую резервную копию.")
try { return
await restoreBackup(backendUrl, restoreTarget.id)
toast.success(`Конфигурация восстановлена на ${restoreTarget.server}`)
setRestoreTarget(null)
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка восстановления")
} finally {
setOpBusy(false)
} }
const target = restoreTarget, abort = startOperation(); if (!abort) return
let sent = false
try {
if (!restoreGuard.begin({ backupId: target.id, serverId: Number(target.serverId), filename: target.filename, serverName: target.server })) return
sent = true
const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal }, Number(target.serverId))
if (!alive.current || abort.signal.aborted) return
if (result.filename !== target.filename) throw new Error("Ответ восстановления относится к другому файлу")
toast.success("API подтвердил импорт " + result.filename + " на " + result.serverName)
restoreGuard.clear(); setRestoreTarget(null)
} catch (error) {
if (alive.current && !abort.signal.aborted) {
if (sent && restoreWasNotApplied(error)) restoreGuard.clear()
setOpError(userApiError(error) + (sent && !restoreWasNotApplied(error) ? ". Результат импорта неизвестен; проверьте роутер перед повтором" : ""))
}
}
finally { finishOperation(abort) }
} }
async function handleDownload(id: string, filename: string) {
async function handleDownload(id: string, fallbackFilename: string) { if (!isLive || !canRead || !alive.current || downloadControllers.current.has(id)) return
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`) const abort = new AbortController(); downloadControllers.current.set(id, abort)
const blob = await res.blob() try {
const url = URL.createObjectURL(blob) const blob = await downloadBackup(backendUrl, id, { signal: abort.signal })
const a = document.createElement("a") if (!alive.current || abort.signal.aborted) return
a.href = url const url = URL.createObjectURL(blob)
a.download = fallbackFilename try {
a.click() const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click()
URL.revokeObjectURL(url) } finally { URL.revokeObjectURL(url) }
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
finally { if (downloadControllers.current.get(id) === abort) downloadControllers.current.delete(id) }
} }
const autoCount = backupList.filter((b) => b.kind === "auto").length const autoCount = backupList.filter((b) => b.kind === "auto").length
@@ -334,19 +357,21 @@ export default function BackupsPage() {
const s3Count = backupList.filter((b) => b.storage === "s3" || b.storage === "both").length const s3Count = backupList.filter((b) => b.storage === "s3" || b.storage === "both").length
const schedSummary = (() => { const schedSummary = (() => {
if (!schedule.enabled) return "Расписание отключено" const confirmed = isLive ? savedSchedule : defaultSchedule
const t = `${String(schedule.hour).padStart(2, "0")}:${String(schedule.minute).padStart(2, "0")}` if (!confirmed) return "Расписание пока неизвестно"
if (schedule.frequency === "daily") return `Каждый день в ${t}` if (!confirmed.enabled) return "Расписание отключено"
if (schedule.frequency === "weekly") return `Каждую неделю (${WEEK_DAYS[schedule.weekDay]}) в ${t}` const time = String(confirmed.hour).padStart(2, "0") + ":" + String(confirmed.minute).padStart(2, "0")
return `${schedule.monthDay}-го числа каждого месяца в ${t}` if (confirmed.frequency === "daily") return "Каждый день в " + time
if (confirmed.frequency === "weekly") return "Каждую неделю (" + WEEK_DAYS[Number(confirmed.weekDay)] + ") в " + time
return confirmed.monthDay + "-го числа каждого месяца в " + time
})() })()
const confirmedStorage = isLive ? savedStorage : defaultStorageForm
const destinationLabel = storage.provider === "s3" const destinationLabel = !confirmedStorage ? "Хранилище пока неизвестно" : confirmedStorage.provider === "s3"
? (storage.keepLocalCopy ? "локально и в S3" : "в S3") ? (confirmedStorage.keepLocalCopy ? "локально и в S3" : "в S3")
: "локально (storage/backups)" : "локально (storage/backups)"
const statusDetail = [ const statusDetail = [
backupJobId ? null : `${destinationLabel} · ${selectedServers.size} из ${liveServers.length} серверов`, backupJobId ? null : `${destinationLabel} · ${known("servers") ? liveServers.length + " серверов в каталоге" : "Серверы пока неизвестны"}`,
scheduleLastError, scheduleLastError,
savedStorage?.lastTestError, savedStorage?.lastTestError,
].filter(Boolean).join(" · ") ].filter(Boolean).join(" · ")
@@ -358,6 +383,8 @@ export default function BackupsPage() {
: "default" : "default"
function openCreate(all: boolean) { function openCreate(all: boolean) {
if (checkpoint) { setManualStep(3); setManualOpen(true); return }
if (!canCreate) return
setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set()) setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set())
setManualNotes("") setManualNotes("")
setManualStep(1) setManualStep(1)
@@ -365,7 +392,7 @@ export default function BackupsPage() {
} }
return ( return (
<div className="flex flex-col h-full"> <div className="flex min-h-0 min-w-0 flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]} crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
actions={ actions={
@@ -375,7 +402,7 @@ export default function BackupsPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => openCreate(true)} onClick={() => openCreate(true)}
disabled={loading} disabled={!canCreate || loading || job.busy}
> >
<RefreshCwIcon className="size-4" /> <RefreshCwIcon className="size-4" />
Снять со всех Снять со всех
@@ -384,45 +411,69 @@ export default function BackupsPage() {
type="button" type="button"
size="sm" size="sm"
onClick={() => openCreate(false)} onClick={() => openCreate(false)}
disabled={loading} disabled={checkpoint ? !canRead : !canCreate || loading || job.busy}
> >
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />
Новый бэкап {checkpoint ? "Задача резервного копирования" : "Новый бэкап"}
</Button> </Button>
</> </>
} }
/> />
<div className="flex-1 overflow-y-auto p-6"> <div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{urlError && <Alert variant="warning" aria-label="Контекст резервных копий"><AlertTitle>Не удалось прочитать ссылку</AlertTitle><AlertDescription>{urlError}</AlertDescription><AlertAction><Button type="button" variant="outline" onClick={resetContext}>Сбросить контекст</Button></AlertAction></Alert>}
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демонстрационные данные. Операции и сохранение недоступны.</p>}
{!canRead && <p role="status">Нет права mm:backups:read</p>}
{isLive && !canReadServers && <p role="status">Каталог устройств недоступен без mm:servers:read.</p>}
{isLive && canRead && <div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<Button type="button" variant="outline" size="sm" ref={refreshTrigger} onClick={() => void loadLive()} disabled={loading || scheduleSaveBusy || opBusy}>Обновить</Button>
<span role="status">{loading ? "Обновление данных…" : resources.backups.receivedAt ? "Получено: " + new Date(resources.backups.receivedAt).toLocaleString("ru-RU") : "Резервные копии пока неизвестны"}</span>
</div>}
{isLive && (Object.keys(resources) as Resource[]).filter(resource => resources[resource].error).map(resource => <Alert key={resource} variant="warning" aria-label={"Данные резервных копий: " + resource}>
<AlertTitle>{resources[resource].known ? "Последние данные сохранены" : "Данные пока неизвестны"}</AlertTitle>
<AlertDescription>{({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error}</AlertDescription>
</Alert>)}
{isLive && (restoreGuard.checkpoint || restoreGuard.error) && <Alert variant="warning" aria-label="Подтверждение восстановления">
<AlertTitle>{opBusy && restoreTarget ? "Импорт ожидает подтверждения" : "Проверьте результат восстановления"}</AlertTitle>
<AlertDescription className="flex flex-col gap-3">
<span>{restoreGuard.error || (restoreGuard.checkpoint ? restoreGuard.checkpoint.filename + " → " + restoreGuard.checkpoint.serverName + ". Ответ ещё не подтверждён. Закрытие страницы не отменяет импорт." : "")}</span>
<label className="flex items-start gap-2"><Checkbox checked={restoreReviewed} disabled={opBusy || !canWrite} onCheckedChange={value => setRestoreReviewed(value === true)} />Я проверил состояние роутера и понимаю последствия повторного импорта</label>
<Button type="button" variant="outline" disabled={!restoreReviewed || opBusy || !canWrite} onClick={() => { if (restoreGuard.clear()) { setRestoreReviewed(false); setOpError(null) } }}>Разрешить новое восстановление</Button>
</AlertDescription>
</Alert>}
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
{(job.error || job.storageError || persistedJobError) && <Alert variant="warning" aria-label="Задача резервного копирования"><AlertTitle>{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}</AlertTitle><AlertDescription>{job.error || job.storageError || persistedJobError}</AlertDescription></Alert>}
<div><h1 className="text-xl font-semibold">Резервные копии</h1><p className="text-sm text-muted-foreground">История файлов, расписание и подтверждения операций</p></div>
<KpiStatGrid <KpiStatGrid
aria-label="Сводка бэкапов" aria-label="Сводка бэкапов"
items={[ items={[
{ {
id: "all", id: "all",
label: "Всего бэкапов", label: "Всего бэкапов",
value: backupList.length, value: known("backups") ? backupList.length : "—",
icon: <HardDriveIcon className="size-4" />, icon: <HardDriveIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "auto", id: "auto",
label: "Авто", label: "Авто",
value: autoCount, value: known("backups") ? autoCount : "—",
icon: <ClockIcon className="size-4" />, icon: <ClockIcon className="size-4" />,
iconClassName: "text-info", iconClassName: "text-info",
}, },
{ {
id: "manual", id: "manual",
label: "Вручную", label: "Вручную",
value: manualCount, value: known("backups") ? manualCount : "—",
icon: <PlusIcon className="size-4" />, icon: <PlusIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "s3", id: "s3",
label: "В S3", label: "В S3",
value: s3Count, value: known("backups") ? s3Count : "—",
icon: <CloudIcon className="size-4" />, icon: <CloudIcon className="size-4" />,
iconClassName: "text-primary", iconClassName: "text-primary",
}, },
@@ -432,11 +483,11 @@ export default function BackupsPage() {
<Alert variant={alertVariant}> <Alert variant={alertVariant}>
<ClockIcon /> <ClockIcon />
<AlertTitle> <AlertTitle>
{backupJobId ? "Задача выполняется" : schedSummary} {backupJobId ? (checkpoint?.accepted ? "Задача наблюдается" : "Результат запроса пока неизвестен") : schedSummary}
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
{backupJobId {backupJobId
? "Снимок конфигурации снимается в фоне. Список обновится после завершения." ? "Закрытие формы не отменяет задачу. Проверка продолжает чтение того же ID."
: statusDetail} : statusDetail}
</AlertDescription> </AlertDescription>
<AlertAction> <AlertAction>
@@ -446,6 +497,7 @@ export default function BackupsPage() {
</AlertAction> </AlertAction>
</Alert> </Alert>
<Frame dense className="min-w-0"><FramePanel className="min-w-0 p-3 sm:p-5">
<Tabs <Tabs
value={tab} value={tab}
onValueChange={(v) => setTab(String(v) as PageTab)} onValueChange={(v) => setTab(String(v) as PageTab)}
@@ -456,7 +508,7 @@ export default function BackupsPage() {
<TabsTrigger value="settings">Настройки</TabsTrigger> <TabsTrigger value="settings">Настройки</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="history" className="flex flex-col gap-5"> <TabsContent value="history" className="flex flex-col gap-5">
<BackupsHistory {urlError ? null : !canRead ? <p role="status">История недоступна без права чтения</p> : !known("backups") ? <p role="status">{resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}</p> : <BackupsHistory
backups={backupList} backups={backupList}
kindFilter={kindFilter} kindFilter={kindFilter}
onKindFilterChange={setKindFilter} onKindFilterChange={setKindFilter}
@@ -465,12 +517,17 @@ export default function BackupsPage() {
filters={advancedFilters} filters={advancedFilters}
onFiltersChange={setAdvancedFilters} onFiltersChange={setAdvancedFilters}
onDownload={handleDownload} onDownload={handleDownload}
onRestore={setRestoreTarget} onRestore={backup => { captureDialogFocus(); setOpError(null); setRestoreTarget(backup) }}
onDelete={setDeleteTarget} onDelete={backup => { captureDialogFocus(); setOpError(null); setDeleteTarget(backup) }}
onCreate={() => openCreate(false)} onCreate={() => openCreate(false)}
/> canCreate={canCreate}
canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId}
canRestore={restoreGuard.canStart}
canDownload={isLive && canRead}
/>}
</TabsContent> </TabsContent>
<TabsContent value="settings"> <TabsContent value="settings">
<BackupsSettings <BackupsSettings
schedule={schedule} schedule={schedule}
onScheduleChange={setSched} onScheduleChange={setSched}
@@ -480,6 +537,7 @@ export default function BackupsPage() {
servers={liveServers} servers={liveServers}
selectedServers={selectedServers} selectedServers={selectedServers}
onToggleServer={(id) => { onToggleServer={(id) => {
scheduleDirty.current = true
setSelectedServers((prev) => { setSelectedServers((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(id)) next.delete(id) if (next.has(id)) next.delete(id)
@@ -487,17 +545,27 @@ export default function BackupsPage() {
return next return next
}) })
}} }}
onSelectAll={() => setSelectedServers(new Set(liveServers.map((s) => s.id)))} onSelectAll={() => { scheduleDirty.current = true; setAllEnabled(false); setSelectedServers(new Set(liveServers.map(s => s.id))) }}
onClearServers={() => setSelectedServers(new Set())} onClearServers={() => { scheduleDirty.current = true; setSelectedServers(new Set()) }}
onSave={handleSave} onSave={handleSave}
onSaveStorage={handleSaveStorage}
scheduleKnown={known("schedule")}
storageKnown={known("storage")}
storageDirty={storageIsDirty}
scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers") && !backupJobId}
storageWritable={canWrite && current("storage") && !backupJobId}
allEnabled={allEnabled}
onAllEnabledChange={value => { scheduleDirty.current = true; setAllEnabled(value) }}
onTest={handleTestStorage} onTest={handleTestStorage}
onSync={handleSyncStorage} onSync={handleSyncStorage}
saveBusy={scheduleSaveBusy} saveBusy={scheduleSaveBusy}
testBusy={testBusy} testBusy={testBusy}
syncBusy={syncBusy} syncBusy={syncBusy}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</FramePanel></Frame>
</div> </div>
</div> </div>
@@ -507,7 +575,7 @@ export default function BackupsPage() {
step={manualStep} step={manualStep}
onStepChange={setManualStep} onStepChange={setManualStep}
servers={liveServers} servers={liveServers}
selected={manualServers} selected={checkpoint ? new Set(checkpoint.request.serverIds.map(String)) : manualServers}
onToggle={(id) => { onToggle={(id) => {
setManualServers((prev) => { setManualServers((prev) => {
const next = new Set(prev) const next = new Set(prev)
@@ -518,22 +586,35 @@ export default function BackupsPage() {
}} }}
onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))} onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
onClear={() => setManualServers(new Set())} onClear={() => setManualServers(new Set())}
notes={manualNotes} notes={checkpoint?.request.notes ?? manualNotes}
onNotesChange={setManualNotes} onNotesChange={setManualNotes}
destinationLabel={destinationLabel} destinationLabel={destinationLabel}
busy={backupJobId !== null} busy={job.busy}
locked={!!checkpoint}
terminal={jobTerminal}
canObserve={canRead}
jobId={checkpoint?.request.requestId}
jobStatus={checkpoint?.job ? ({ queued: "В очереди", running: "Выполняется", done: checkpoint?.job && backupJobOutcome(checkpoint.job).fullSuccess ? "Завершена" : "Завершена с ограничениями", failed: "Прервана" })[checkpoint.job.status] + ": " + checkpoint.job.completed + "/" + checkpoint.job.total : checkpoint?.accepted ? "Запрос принят, результат ещё неизвестен" : "Результат запроса ещё неизвестен"}
error={job.error || job.storageError || persistedJobError}
result={checkpoint?.job && jobTerminal ? "Создано " + backupJobOutcome(checkpoint.job).created + " из " + checkpoint.job.total : undefined}
onReset={() => { if (job.reset()) { setManualServers(new Set()); setManualNotes(""); setManualStep(1) } }}
onSubmit={handleManualBackup} onSubmit={handleManualBackup}
/> />
<BackupDeleteDialog <BackupDeleteDialog
returnFocus={returnDialogFocus}
backup={deleteTarget} backup={deleteTarget}
busy={opBusy} busy={opBusy}
error={opError}
onConfirm={() => void handleDelete()} onConfirm={() => void handleDelete()}
onCancel={() => setDeleteTarget(null)} onCancel={() => setDeleteTarget(null)}
/> />
<BackupRestoreDialog <BackupRestoreDialog
returnFocus={returnDialogFocus}
backup={restoreTarget} backup={restoreTarget}
busy={opBusy} busy={opBusy}
blocked={!restoreGuard.canStart || Boolean(restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget))}
error={restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget) ? "Локальный файл отсутствует. Создайте новую резервную копию." : opError}
onConfirm={() => void handleRestore()} onConfirm={() => void handleRestore()}
onCancel={() => setRestoreTarget(null)} onCancel={() => setRestoreTarget(null)}
/> />
+231 -161
View File
@@ -1,37 +1,41 @@
"use client" "use client"
import { Fragment, useState, useMemo, useEffect } from "react" import { Suspense, useState, useMemo, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid" import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
import type { Filter } from "@/components/reui/filters" import { useBgpUrlState } from "@/hooks/use-bgp-url-state"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters" import { QueryFilters } from "@/components/data-grids/query-filters"
import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields" import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types" import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
import { bgpCatalogSchema, bgpServersSchema, type BackendBgpSession, type BgpFailure } from "@/lib/bgp/response"
import { fmtBgpNum, sumBgpCounters } from "@/lib/bgp/helpers"
import { BGP_AS_NAMES } from "@/lib/bgp/types" import { BGP_AS_NAMES } from "@/lib/bgp/types"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
RefreshCwIcon, DownloadIcon, SearchIcon, RefreshCwIcon, DownloadIcon,
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon, ActivityIcon, BarChart3Icon,
ArrowDownIcon, ClipboardCopyIcon, ServerIcon, ArrowDownIcon, ServerIcon,
XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
} from "lucide-react" } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { servers as mockServers, type Server } from "@/lib/data"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
type BgpSession = BgpSessionRow type BgpSession = BgpSessionRow
type BgpTab = "sessions" | "routers" | "analytics" type BgpTab = "sessions" | "routers" | "analytics"
type StateFilter = "all" | BgpState
type TypeFilter = "all" | BgpType
const AS_NAMES = BGP_AS_NAMES const AS_NAMES = BGP_AS_NAMES
// ─── mock data ──────────────────────────────────────────────────────────────── // ─── mock data ────────────────────────────────────────────────────────────────
@@ -181,6 +185,7 @@ const SESSIONS: BgpSession[] = [
// ─── visual helpers ─────────────────────────────────────────────────────────── // ─── visual helpers ───────────────────────────────────────────────────────────
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = { const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
Unknown: { bg: "bg-muted", text: "text-muted-foreground", dot: "bg-muted-foreground", label: "Неизвестно" },
Established: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", label: "Established" }, Established: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", label: "Established" },
Active: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", dot: "bg-amber-500", label: "Active" }, Active: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", dot: "bg-amber-500", label: "Active" },
Idle: { bg: "bg-slate-500/10", text: "text-slate-500 dark:text-slate-400", dot: "bg-slate-500", label: "Idle" }, Idle: { bg: "bg-slate-500/10", text: "text-slate-500 dark:text-slate-400", dot: "bg-slate-500", label: "Idle" },
@@ -189,48 +194,32 @@ const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; lab
OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" }, OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" },
} }
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = { const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
Unknown: { bg: "bg-muted", text: "text-muted-foreground" },
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" }, eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" }, iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
} }
function StateBadge({ state }: { state: BgpState }) {
const s = STATE_STYLE[state]
return (
<span className={cn("inline-flex items-center gap-1.5 rounded border px-2 py-0.5 text-[11px] font-semibold",
s.bg, s.text, "border-current/20")}>
<span className={cn("size-1.5 rounded-full shrink-0", s.dot)} />
{s.label}
</span>
)
}
function TypeBadge({ type }: { type: BgpType }) { function TypeBadge({ type }: { type: BgpType }) {
const s = TYPE_STYLE[type] const s = TYPE_STYLE[type]
return ( return (
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold", <span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
s.bg, s.text, "border-current/20")}> s.bg, s.text, "border-current/20")}>
{type} {type === "Unknown" ? "Тип неизвестен" : type}
</span> </span>
) )
} }
function fmtNum(n: number) { const fmtNum = fmtBgpNum
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`
return String(n)
}
// ─── backend mapping ────────────────────────────────────────────────────────── // ─── backend mapping ──────────────────────────────────────────────────────────
interface BackendBgpSession { type BackendServer = ReturnType<typeof bgpServersSchema.parse>[number]
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string type InventoryServer = Server & { monitoringUnknown?: boolean }
name: string; peerIp: string; remoteAs: number; localAs: number function mapBackendServer(s: BackendServer): InventoryServer {
localId: string; remoteId: string; state: string; type: "eBGP" | "iBGP" return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", site: s.site ?? "",
uptime: string | null; holdTime: number; keepalive: number country: s.country || "UN", asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled,
prefixesRx: number; prefixesTx: number status: s.status ?? "offline", monitoringUnknown: s.status == null, latency: s.latency ?? null, sessions: 0 }
inputMessages: number; outputMessages: number
capabilities: string[]; lastError: string | null
} }
function backendToFrontend(b: BackendBgpSession): BgpSession { function backendToFrontend(b: BackendBgpSession): BgpSession {
@@ -244,15 +233,15 @@ function backendToFrontend(b: BackendBgpSession): BgpSession {
localAs: b.localAs, localAs: b.localAs,
routerId: b.remoteId || b.localId, routerId: b.remoteId || b.localId,
description: b.name, description: b.name,
state: (b.state as BgpState) || "Idle", state: Object.hasOwn(STATE_STYLE, b.state) ? b.state as BgpState : "Unknown",
type: b.type, type: b.type,
afi: "IPv4 Unicast", afi: b.afi ?? "Неизвестно",
uptime: b.uptime, uptime: b.uptime,
holdTime: b.holdTime, holdTime: b.holdTime,
keepalive: b.keepalive, keepalive: b.keepalive,
prefixesRx: b.prefixesRx, prefixesRx: b.prefixesRx,
prefixesTx: b.prefixesTx, prefixesTx: b.prefixesTx,
prefixesActive: b.prefixesRx, prefixesActive: null,
inputMessages: b.inputMessages, inputMessages: b.inputMessages,
outputMessages: b.outputMessages, outputMessages: b.outputMessages,
capabilities: b.capabilities, capabilities: b.capabilities,
@@ -268,14 +257,12 @@ const BGP_FILTER_ACCESSORS = {
afi: (s: BgpSession) => s.afi, afi: (s: BgpSession) => s.afi,
} }
function SessionsTab({ sessions }: { sessions: BgpSession[] }) { function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession }: { sessions: BgpSession[]; emptyTitle?: string; selectedSessionId: string | null; onSelectSession: (id: string | null) => void }) {
const [search, setSearch] = useState("") const { search, setSearch, stateFilter, setStateFilter, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset } = useBgpUrlState()
const [stateFilter, setStateFilter] = useState<StateFilter>("all") const fields = useMemo(() => BGP_FILTER_FIELDS.map(field => field.id === "afi" ? { ...field, options: Array.from(new Set(sessions.map(s => s.afi))).sort().map(value => ({ value, label: value })) } : field), [sessions])
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
const q = search.toLowerCase() const q = search.toLowerCase()
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (urlError) return []
const base = sessions.filter((s) => { const base = sessions.filter((s) => {
if (stateFilter !== "all" && s.state !== stateFilter) return false if (stateFilter !== "all" && s.state !== stateFilter) return false
if (typeFilter !== "all" && s.type !== typeFilter) return false if (typeFilter !== "all" && s.type !== typeFilter) return false
@@ -283,46 +270,48 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
q && q &&
!s.peerIp.includes(q) && !s.peerIp.includes(q) &&
!s.description.toLowerCase().includes(q) && !s.description.toLowerCase().includes(q) &&
!s.serverLabel.includes(q) && !s.serverLabel.toLowerCase().includes(q) &&
!String(s.remoteAs).includes(q) && !String(s.remoteAs).includes(q) &&
!(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q) !(s.remoteAs === null ? "" : AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
) { ) {
return false return false
} }
return true return true
}) })
return applyReuiFilters(base, advancedFilters, BGP_FILTER_ACCESSORS) return applyFilterQuery(base, advancedFilters, BGP_FILTER_ACCESSORS)
}, [sessions, q, stateFilter, typeFilter, advancedFilters]) }, [sessions, q, stateFilter, typeFilter, advancedFilters, urlError])
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{urlError && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>{urlError}<Button variant="outline" size="sm" onClick={reset}>Сбросить фильтры BGP</Button></AlertDescription></Alert>}
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
className="[&>div:first-child]:max-w-full [&>div:first-child]:overflow-x-auto [&>div:first-child_button]:shrink-0 [&>div:first-child_button]:whitespace-nowrap"
segmented={{ segmented={{
value: stateFilter, value: stateFilter,
onChange: setStateFilter, onChange: setStateFilter,
options: [ options: [
{ value: "all", label: "Все", count: sessions.length }, { value: "all", label: "Все", count: sessions.length },
{ value: "Unknown", label: "Неизвестно", count: sessions.filter(s => s.state === "Unknown").length },
{ value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length }, { value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
{ value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length }, { value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
{ value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length }, { value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length }, { value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
], ],
}} }}
filters={advancedFilters} filterControl={<QueryFilters fields={fields} query={advancedFilters} onQueryChange={setAdvancedFilters} />}
onFiltersChange={setAdvancedFilters}
filterFields={BGP_FILTER_FIELDS}
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="IP, AS, описание…" searchPlaceholder="IP, AS, описание…"
countLabel={`${filtered.length} из ${sessions.length}`} countLabel={`${filtered.length} из ${sessions.length}`}
actions={ actions={
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40"> <div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{(["all", "eBGP", "iBGP"] as const).map((t) => ( {(["all", "eBGP", "iBGP", "Unknown"] as const).map((t) => (
<button <button
key={t} key={t}
type="button" type="button"
onClick={() => setTypeFilter(t)} onClick={() => setTypeFilter(t)}
aria-pressed={typeFilter === t}
className={cn( className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors", "px-2.5 py-1 text-[11px] rounded transition-colors",
typeFilter === t typeFilter === t
@@ -330,13 +319,15 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
)} )}
> >
{t === "all" ? "Все типы" : t} {t === "all" ? "Все типы" : t === "Unknown" ? "Неизвестно" : t}
</button> </button>
))} ))}
</div> </div>
} }
/> />
<BgpSessionsDataGrid sessions={filtered} /> {selectedSessionId && sessions.some(s => s.id === selectedSessionId) && !filtered.some(s => s.id === selectedSessionId) && <p role="status" className="px-5 py-2 text-xs text-muted-foreground">Сессия из ссылки скрыта текущими фильтрами.</p>}
{hasFilters && !urlError && <Button variant="ghost" size="sm" onClick={reset}>Сбросить фильтры BGP</Button>}
<BgpSessionsDataGrid sessions={filtered} emptyTitle={urlError ? "Некорректные фильтры BGP" : emptyTitle} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />
</DataPageCard> </DataPageCard>
</div> </div>
) )
@@ -358,8 +349,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{byRouter.map(router => { {byRouter.map(router => {
const estCnt = router.sessions.filter(s => s.state === "Established").length const estCnt = router.sessions.filter(s => s.state === "Established").length
const downCnt = router.sessions.length - estCnt const downCnt = router.sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0) const totalRx = sumBgpCounters(router.sessions.map(s => s.prefixesRx))
return ( return (
<Frame key={router.id} dense className="w-full overflow-hidden"> <Frame key={router.id} dense className="w-full overflow-hidden">
@@ -369,7 +360,7 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
<ServerIcon className="size-4 text-muted-foreground shrink-0" /> <ServerIcon className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-none">{router.label}</p> <p className="text-sm font-medium leading-none">{router.label}</p>
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · AS65001</p> <p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · {Array.from(new Set(router.sessions.map(s => s.localAs === null ? "AS неизвестна" : "AS" + s.localAs))).join(", ")}</p>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{estCnt > 0 && ( {estCnt > 0 && (
@@ -400,19 +391,19 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
<TypeBadge type={s.type} /> <TypeBadge type={s.type} />
</div> </div>
<div className="flex items-center gap-1.5 mt-0.5"> <div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-muted-foreground">AS{s.remoteAs}</span> <span className="text-[10px] text-muted-foreground">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
{AS_NAMES[s.remoteAs] && ( {s.remoteAs !== null && AS_NAMES[s.remoteAs] && (
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span> <span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
)} )}
</div> </div>
</div> </div>
<div className="shrink-0 text-right"> <div className="shrink-0 text-right">
{s.prefixesRx > 0 && ( {s.prefixesRx !== null && s.prefixesRx > 0 && (
<p className="text-[10px] font-mono text-emerald-500 tabular-nums"> <p className="text-[10px] font-mono text-emerald-500 tabular-nums">
↓ {fmtNum(s.prefixesRx)} ↓ {fmtNum(s.prefixesRx)}
</p> </p>
)} )}
<p className={cn("text-[10px] font-semibold", ss.text)}>{s.state}</p> <p className={cn("text-[10px] font-semibold", ss.text)}>{ss.label}</p>
</div> </div>
</div> </div>
) )
@@ -420,7 +411,7 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
</div> </div>
{/* footer summary */} {/* footer summary */}
{totalRx > 0 && ( {totalRx !== null && totalRx > 0 && (
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20"> <div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
<ArrowDownIcon className="size-3 text-emerald-500" /> <ArrowDownIcon className="size-3 text-emerald-500" />
<span className="text-[11px] font-mono text-muted-foreground"> <span className="text-[11px] font-mono text-muted-foreground">
@@ -441,9 +432,9 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) { function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
// top peers by prefixes received (eBGP only) // top peers by prefixes received (eBGP only)
const topPeers = useMemo(() => const topPeers = useMemo(() =>
[...sessions] (sessions.some(s => s.prefixesRx === null) ? [] : [...sessions])
.filter(s => s.prefixesRx > 0) .filter(s => s.prefixesRx !== null && s.prefixesRx > 0)
.sort((a, b) => b.prefixesRx - a.prefixesRx) .sort((a, b) => (b.prefixesRx ?? 0) - (a.prefixesRx ?? 0))
.slice(0, 8), .slice(0, 8),
[sessions] [sessions]
) )
@@ -457,8 +448,8 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
}, [sessions]) }, [sessions])
// total prefix stats // total prefix stats
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0) const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
const totalActive = sessions.reduce((a, s) => a + s.prefixesActive, 0) const totalActive = sumBgpCounters(sessions.map(s => s.prefixesActive))
const ebgpSessions = sessions.filter(s => s.type === "eBGP").length const ebgpSessions = sessions.filter(s => s.type === "eBGP").length
const ibgpSessions = sessions.filter(s => s.type === "iBGP").length const ibgpSessions = sessions.filter(s => s.type === "iBGP").length
@@ -469,29 +460,30 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
items={[ items={[
{ {
id: "rx", id: "rx",
label: "Всего префиксов", label: "Префиксы",
value: fmtNum(totalRx), value: fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />, icon: <DownloadIcon className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "active", id: "active",
label: "Активных маршрутов", label: "Маршруты",
hint: totalActive === null ? "нет данных API" : undefined,
value: fmtNum(totalActive), value: fmtNum(totalActive),
icon: <GitMergeIcon className="size-4" />, icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-info", iconClassName: "text-info",
}, },
{ {
id: "ebgp", id: "ebgp",
label: "eBGP сессий", label: "eBGP",
value: ebgpSessions, value: sessions.some(s => s.type === "Unknown") ? "—" : ebgpSessions,
icon: <ActivityIcon className="size-4" />, icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "ibgp", id: "ibgp",
label: "iBGP сессий", label: "iBGP",
value: ibgpSessions, value: sessions.some(s => s.type === "Unknown") ? "—" : ibgpSessions,
icon: <ServerIcon className="size-4" />, icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
@@ -503,7 +495,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5"> <OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{topPeers.map((s, i) => { {topPeers.map((s, i) => {
const pct = (s.prefixesRx / maxRx) * 100 const pct = ((s.prefixesRx ?? 0) / maxRx) * 100
const ss = STATE_STYLE[s.state] const ss = STATE_STYLE[s.state]
return ( return (
<div key={s.id} className="flex items-center gap-3"> <div key={s.id} className="flex items-center gap-3">
@@ -514,7 +506,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
<div className="flex items-center justify-between mb-1 gap-2"> <div className="flex items-center justify-between mb-1 gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-mono truncate">{s.peerIp}</span> <span className="text-xs font-mono truncate">{s.peerIp}</span>
<span className="text-[10px] text-muted-foreground shrink-0">AS{s.remoteAs}</span> <span className="text-[10px] text-muted-foreground shrink-0">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
<TypeBadge type={s.type} /> <TypeBadge type={s.type} />
</div> </div>
<span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}> <span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}>
@@ -533,7 +525,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
) )
})} })}
{topPeers.length === 0 && ( {topPeers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p> <p className="text-sm text-muted-foreground text-center py-4">Недостаточно данных о префиксах для сравнения</p>
)} )}
</div> </div>
</OpsPanel> </OpsPanel>
@@ -552,7 +544,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
return ( return (
<div key={state} className="flex items-center gap-2"> <div key={state} className="flex items-center gap-2">
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} /> <span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
<span className="text-xs w-24">{state}</span> <span className="text-xs w-24">{ss.label}</span>
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden"> <div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} /> <div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
</div> </div>
@@ -566,7 +558,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
{/* eBGP vs iBGP */} {/* eBGP vs iBGP */}
<OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5"> <OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
{sessions.length === 0 ? ( {sessions.length === 0 || sessions.some(s => s.type === "Unknown") ? (
<p className="text-xs text-muted-foreground">Нет данных</p> <p className="text-xs text-muted-foreground">Нет данных</p>
) : ( ) : (
<> <>
@@ -593,7 +585,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{Object.entries( {Object.entries(
sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => { sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => {
acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc if (s.remoteAs !== null) acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
}, {}) }, {})
).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => ( ).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => (
<div key={as} className="flex items-center justify-between text-[11px]"> <div key={as} className="flex items-center justify-between text-[11px]">
@@ -622,29 +614,78 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
] ]
export default function BgpPage() { export default function BgpPage() {
const [activeTab, setActiveTab] = useState<BgpTab>("sessions") return <Suspense fallback={<div role="status">Загрузка BGP…</div>}><BgpRoute /></Suspense>
}
function BgpRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const router = useRouter()
const selectedServerId = params.get("server") ?? ALL_SERVERS_ID
const selectedSessionId = params.get("session")
const tab = params.get("tab")
const activeTab: BgpTab = tab === "routers" || tab === "analytics" ? tab : "sessions"
function navigate(values: Record<string, string | null>) {
const next = new URLSearchParams(params.toString())
for (const [key, value] of Object.entries(values)) {
if (value === null) next.delete(key)
else next.set(key, value)
}
router.push("/bgp" + (next.size ? "?" + next.toString() : ""))
}
return <BgpPageContent key={JSON.stringify([mode, backendUrl])} selectedServerId={selectedServerId} selectedSessionId={selectedSessionId} activeTab={activeTab}
onSelectServer={id => navigate({ server: id === ALL_SERVERS_ID ? null : id, session: null })}
onSelectSession={id => navigate({ session: id })}
onSelectTab={tab => navigate({ tab: tab === "sessions" ? null : tab })} />
}
function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSelectServer, onSelectSession, onSelectTab }: {
selectedServerId: string; selectedSessionId: string | null; activeTab: BgpTab;
onSelectServer: (id: string) => void; onSelectSession: (id: string | null) => void; onSelectTab: (tab: BgpTab) => void;
}) {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([]) const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
const [loading, setLoading] = useState(false) const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
const [loading, setLoading] = useState(isLive)
const [fetchedAt, setFetchedAt] = useState<Date | null>(null) const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
const [liveError, setLiveError] = useState<string | null>(null) const [liveError, setLiveError] = useState<string | null>(null)
const [failures, setFailures] = useState<BgpFailure[]>([])
const [hasLoaded, setHasLoaded] = useState(false)
const [readReceipts, setReadReceipts] = useState<Record<string, string>>({})
const [fetchTick, setFetchTick] = useState(0) const [fetchTick, setFetchTick] = useState(0)
useEffect(() => { useEffect(() => {
if (!isLive) return if (!isLive) {
queueMicrotask(() => {
setLiveSessions([])
setLiveServers([])
setLiveError(null)
})
return
}
let cancelled = false let cancelled = false
queueMicrotask(() => { queueMicrotask(() => {
if (cancelled) return if (cancelled) return
setLoading(true) setLoading(true)
setLiveError(null) void Promise.all([
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions") requestJson<unknown>(backendUrl, "/api/bgp/catalog"),
.then(data => { requestJson<unknown>(backendUrl, "/api/servers"),
])
.then(([data, servers]) => {
if (cancelled) return if (cancelled) return
setLiveSessions(data.map(backendToFrontend)) const catalog = bgpCatalogSchema.parse(data)
setFetchedAt(new Date()) const inventory = bgpServersSchema.parse(servers)
const enabled = new Set(inventory.filter(s => s.enabled).map(s => s.id))
if (catalog.sessions.some(s => !enabled.has(s.serverId)) || catalog.failures.some(f => !enabled.has(f.serverId))) throw new Error("Чужой контекст BGP-каталога")
const failed = new Set(catalog.failures.map(f => String(f.serverId)))
setLiveSessions(previous => [...catalog.sessions.map(backendToFrontend), ...previous.filter(s => enabled.has(Number(s.serverId)) && failed.has(s.serverId))])
setLiveServers(inventory.filter(s => s.enabled).map(mapBackendServer))
setFailures(catalog.failures)
setLiveError(null)
setHasLoaded(true)
const receivedAt = new Date()
setFetchedAt(receivedAt)
setReadReceipts(previous => ({ ...Object.fromEntries(Object.entries(previous).filter(([id]) => enabled.has(Number(id)))), ...Object.fromEntries([...enabled].filter(id => !failed.has(String(id))).map(id => [String(id), receivedAt.toISOString()])) }))
setLoading(false) setLoading(false)
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
@@ -656,52 +697,89 @@ export default function BgpPage() {
return () => { cancelled = true } return () => { cancelled = true }
}, [isLive, backendUrl, fetchTick]) }, [isLive, backendUrl, fetchTick])
// Use live or mock data for all tabs and KPI const unknownIds = useMemo(() => new Set(isLive ? (liveError ? liveServers.map(s => s.id) : failures.map(f => String(f.serverId))) : []), [isLive, liveError, liveServers, failures])
const sessions = isLive ? liveSessions : SESSIONS const allSessions: BgpSession[] = isLive ? liveSessions.map(s => unknownIds.has(s.serverId) ? { ...s, state: "Unknown" } : s) : SESSIONS
const unavailableContext = isLive && hasLoaded && selectedServerId !== ALL_SERVERS_ID && !liveServers.some(s => s.id === selectedServerId)
const incomplete = isLive && (unavailableContext || !hasLoaded || Boolean(liveError) || (selectedServerId === ALL_SERVERS_ID ? failures.length > 0 : unknownIds.has(selectedServerId)))
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId = selectedServerId
const sessions = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return allSessions
return allSessions.filter((s) => s.serverId === effectiveServerId)
}, [allSessions, effectiveServerId])
const railItems = useMemo<ServerTileItem[]>(() => {
const counts = new Map<string, number>()
for (const s of allSessions) {
counts.set(s.serverId, (counts.get(s.serverId) ?? 0) + 1)
}
return displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: "monitoringUnknown" in s && s.monitoringUnknown ? undefined : s.status,
meta: "monitoringUnknown" in s && s.monitoringUnknown ? "Статус неизвестен" : undefined,
type: s.type,
count: isLive && (!hasLoaded || unknownIds.has(s.id)) ? undefined : counts.get(s.id) ?? 0,
enabled: s.enabled,
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
}))
}, [displayServers, allSessions, hasLoaded, isLive, unknownIds])
const established = sessions.filter(s => s.state === "Established").length const established = sessions.filter(s => s.state === "Established").length
const notEstab = sessions.length - established const notEstab = sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0) const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
const serverCount = useMemo(
() => new Set(liveSessions.map(s => s.serverId)).size,
[liveSessions],
)
return ( return (
<div className="flex flex-col h-full"> <ServerRailLayout
<PageHeader preserveSelection
crumbs={[{ label: "Управление" }, { label: "BGP" }]} items={railItems}
actions={ selectedId={effectiveServerId}
<> onSelect={onSelectServer}
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}> showAll
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} /> allCount={displayServers.length}
Обновить loading={isLive && loading && displayServers.length === 0}
</Button> header={
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button> <PageHeader
</> crumbs={[{ label: "Управление" }, { label: "BGP" }]}
} actions={
/> <>
<ServerRailMobileButton />
{/* tab bar */} <Button variant="outline" size="sm" disabled={!isLive || loading} onClick={() => setFetchTick(t => t + 1)}>
<div className="border-b bg-background shrink-0"> <RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
<div className="flex items-center px-6"> Обновить
{TABS.map(t => ( </Button>
<button key={t.id} onClick={() => setActiveTab(t.id)} </>
className={cn( }
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px", />
activeTab === t.id }
? "border-primary text-foreground" banner={
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border", <div className="border-b bg-background shrink-0">
)}> <div className="flex min-w-0 items-center overflow-x-auto px-4 sm:px-6">
{t.icon}{t.label} {TABS.map(t => (
</button> <button key={t.id} type="button" aria-pressed={activeTab === t.id} onClick={() => onSelectTab(t.id)}
))} className={cn(
"flex shrink-0 items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{t.icon}{t.label}
</button>
))}
</div>
</div> </div>
</div> }
>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{unavailableContext && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Устройство BGP недоступно в этом каталоге</AlertTitle><AlertDescription>Контекст {selectedServerId} сохранён. Выберите доступное устройство.<Button variant="outline" size="sm" onClick={() => onSelectServer(ALL_SERVERS_ID)}>Все устройства BGP</Button></AlertDescription></Alert>}
{selectedSessionId && hasLoaded && !sessions.some(s => s.id === selectedSessionId) && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>Сессия из ссылки отсутствует в выбранном снимке. Контекст ссылки сохранён.<Button variant="outline" size="sm" onClick={() => onSelectSession(null)}>Все сессии BGP</Button></AlertDescription></Alert>}
{/* data source banner */} {/* data source banner */}
{isLive && loading && ( {isLive && loading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -709,31 +787,23 @@ export default function BgpPage() {
Загрузка BGP-сессий… Загрузка BGP-сессий…
</div> </div>
)} )}
{isLive && !loading && fetchedAt && !liveError && ( {isLive && fetchedAt && (
<div className="flex items-center gap-2"> <div role="group" aria-label="Свежесть BGP" className="text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400"> Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>.
<span className="size-1.5 rounded-full bg-emerald-500" /> Это время получения браузером, не время измерения RouterOS.
Живые данные · {serverCount} серверов · обновлено {fetchedAt.toLocaleTimeString("ru")} {displayServers.filter(s => effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId).map(s => <p key={s.id}>{s.name}: {readReceipts[s.id] ? <time data-bgp-server={s.id} dateTime={readReceipts[s.id]}>{new Date(readReceipts[s.id]).toLocaleTimeString("ru")}</time> : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}</p>)}
</span>
<button
onClick={() => setFetchTick(t => t + 1)}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
<RefreshCwIcon className="size-3" />
Обновить
</button>
</div> </div>
)} )}
{isLive && liveError && ( {isLive && (liveError || failures.length > 0) && (
<Alert variant="warning" className="py-2"> <Alert variant="warning">
<AlertCircleIcon /> <AlertCircleIcon />
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription> <AlertTitle>{liveError ? "Не удалось загрузить BGP" : "Не удалось опросить часть роутеров BGP"}</AlertTitle>
<AlertDescription>
{liveError ?? failures.map(f => f.serverName + ": " + f.error).join("; ")}. {hasLoaded ? "Показаны последние полученные данные; статус отказавших устройств неизвестен." : "Данные пока неизвестны."}
<Button variant="outline" size="sm" disabled={loading} onClick={() => setFetchTick(t => t + 1)} aria-label="Повторить загрузку BGP">Повторить</Button>
</AlertDescription>
</Alert> </Alert>
)} )}
{isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
BGP не настроен ни на одном сервере
</div>
)}
{mode === "mock" && ( {mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground"> <span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные Моковые данные
@@ -745,30 +815,31 @@ export default function BgpPage() {
items={[ items={[
{ {
id: "sessions", id: "sessions",
label: "Сессий всего", label: "Сессий",
value: sessions.length, value: incomplete ? "—" : sessions.length,
hint: incomplete ? "неизвестно" : undefined,
icon: <GitMergeIcon className="size-4" />, icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "established", id: "established",
label: "Established", label: "Established",
value: established, value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : established,
icon: <CheckCircleIcon className="size-4" />, icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "not-estab", id: "not-estab",
label: "Не установлено", label: "Не поднято",
value: notEstab, value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : notEstab,
icon: <AlertCircleIcon className="size-4" />, icon: <AlertCircleIcon className="size-4" />,
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground", iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
variant: notEstab > 0 ? "warning" : "default", variant: notEstab > 0 ? "warning" : "default",
}, },
{ {
id: "prefixes", id: "prefixes",
label: "Получено префиксов", label: "Префиксы",
value: fmtNum(totalRx), value: incomplete ? "—" : fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />, icon: <DownloadIcon className="size-4" />,
iconClassName: "text-info", iconClassName: "text-info",
}, },
@@ -776,25 +847,24 @@ export default function BgpPage() {
/> />
{/* alert: not-established sessions */} {/* alert: not-established sessions */}
{notEstab > 0 && ( {!incomplete && notEstab > 0 && (
<Alert variant="warning"> <Alert variant="warning">
<AlertCircleIcon /> <AlertCircleIcon />
<AlertTitle> <AlertTitle>
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established <span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
Проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")} Проверьте {sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").map(s => s.peerIp).join(", ")}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{/* tab content */} {/* tab content */}
{activeTab === "sessions" && <SessionsTab sessions={sessions} />} {activeTab === "sessions" && <SessionsTab sessions={sessions} emptyTitle={incomplete && sessions.length === 0 ? "Данные BGP неизвестны" : undefined} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />}
{activeTab === "routers" && <RoutersTab sessions={sessions} />} {activeTab === "routers" && (incomplete ? <p role="status">Сводка по роутерам неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <RoutersTab sessions={sessions} />)}
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />} {activeTab === "analytics" && (incomplete ? <p role="status">Аналитика неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <AnalyticsTab sessions={sessions} />)}
</div> </div>
</div> </ServerRailLayout>
</div>
) )
} }
+157 -342
View File
@@ -1,16 +1,18 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { Suspense, useMemo, useState } from "react"
import { useSearchParams } from "next/navigation"
import { readCertificatesUrl, patchCertificatesUrl, type CertificateStatusFilter } from "@/lib/certificates-url-state"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { FileImportDialog } from "@/components/file-import-dialog"
import { routerCertificates, servers as mockServers } from "@/lib/data" import { routerCertificates, servers as mockServers } from "@/lib/data"
import type { CertStatus, Server } from "@/lib/data" import type { Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates" import type { CertificateDto } from "@mmapp/contracts/certificates"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid" import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
import { CertificateAcmeSettings } from "@/components/certificates/certificate-acme-settings"
import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings" import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
@@ -28,17 +30,11 @@ import {
} from "@/components/ui/sheet" } from "@/components/ui/sheet"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers" import { can } from "@/lib/auth"
import { useCertificateCatalog } from "@/lib/use-certificate-catalog"
import type { CertificateView } from "@/lib/certificate-view"
import { toFrontendServer } from "@/entities/server/model/mappers" import { toFrontendServer } from "@/entities/server/model/mappers"
import { import { useCertificateIssue } from "@/lib/use-certificate-issue"
createCertificateIssueJob,
getAcmeSettings,
getCertificateIssueJob,
listCertificates,
putAcmeSettings,
refreshCertificates,
testAcmeSettings,
} from "@/shared/api/certificates"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ShieldCheckIcon, ShieldCheckIcon,
@@ -59,7 +55,7 @@ import {
StepperSeparator, StepperSeparator,
StepperTitle, StepperTitle,
StepperTrigger, StepperTrigger,
} from "@/components/reui/stepper" } from "@/components/reui-kit/accessible-stepper"
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto { function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
return { return {
@@ -79,12 +75,12 @@ function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
} }
} }
function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) { function CertPartAlertExpired({ expired }: { expired: CertificateView[] }) {
return ( 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"> <div className="flex items-start gap-3 rounded-lg bg-destructive/5 border border-destructive/20 px-4 py-3 text-sm">
<AlertCircleIcon className="size-5 text-red-500 shrink-0 mt-0.5" /> <AlertCircleIcon className="size-5 text-destructive shrink-0 mt-0.5" />
<div> <div>
<p className="font-medium text-red-600 dark:text-red-400"> <p className="font-medium text-destructive">
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"} {expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
</p> </p>
<p className="text-muted-foreground text-xs mt-0.5"> <p className="text-muted-foreground text-xs mt-0.5">
@@ -95,12 +91,12 @@ function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
) )
} }
function CertPartAlertExpiring({ expiring }: { expiring: CertificateDto[] }) { function CertPartAlertExpiring({ expiring }: { expiring: CertificateView[] }) {
return ( return (
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm"> <div className="flex items-start gap-3 rounded-lg bg-warning/5 border border-warning/20 px-4 py-3 text-sm">
<AlertTriangleIcon className="size-5 text-amber-500 shrink-0 mt-0.5" /> <AlertTriangleIcon className="size-5 text-warning shrink-0 mt-0.5" />
<div> <div>
<p className="font-medium text-amber-600 dark:text-amber-400"> <p className="font-medium text-warning">
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней {expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
</p> </p>
<p className="text-muted-foreground text-xs mt-0.5"> <p className="text-muted-foreground text-xs mt-0.5">
@@ -115,10 +111,14 @@ function CertPartKpi({
displayCerts, displayCerts,
expiring, expiring,
expired, expired,
complete,
totalKnown,
}: { }: {
displayCerts: CertificateDto[] displayCerts: CertificateView[]
expiring: CertificateDto[] expiring: CertificateView[]
expired: CertificateDto[] expired: CertificateView[]
complete: boolean
totalKnown: boolean
}) { }) {
return ( return (
<KpiStatGrid <KpiStatGrid
@@ -127,21 +127,21 @@ function CertPartKpi({
{ {
id: "all", id: "all",
label: "Всего", label: "Всего",
value: displayCerts.length, value: totalKnown ? displayCerts.length : "—",
icon: <ShieldCheckIcon className="size-4" />, icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "valid", id: "valid",
label: "Действующих", label: "Действующих",
value: displayCerts.filter((c) => c.status === "valid").length, value: complete ? displayCerts.filter((c) => c.status === "valid").length : "—",
icon: <BadgeCheckIcon className="size-4" />, icon: <BadgeCheckIcon className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "expiring", id: "expiring",
label: "Истекают", label: "Истекают",
value: expiring.length, value: complete ? expiring.length : "—",
icon: <AlertTriangleIcon className="size-4" />, icon: <AlertTriangleIcon className="size-4" />,
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground", iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
variant: expiring.length > 0 ? "warning" : "default", variant: expiring.length > 0 ? "warning" : "default",
@@ -149,7 +149,7 @@ function CertPartKpi({
{ {
id: "expired", id: "expired",
label: "Истёкших", label: "Истёкших",
value: expired.length, value: complete ? expired.length : "—",
icon: <AlertCircleIcon className="size-4" />, icon: <AlertCircleIcon className="size-4" />,
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground", iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
variant: expired.length > 0 ? "destructive" : "default", variant: expired.length > 0 ? "destructive" : "default",
@@ -159,87 +159,6 @@ function CertPartKpi({
) )
} }
function CertPartAcmeSettings({
acmeDirectoryUrl,
setAcmeDirectoryUrl,
acmeZoneId,
setAcmeZoneId,
acmeTokenDraft,
setAcmeTokenDraft,
acmeTokenConfigured,
acmeSaveBusy,
onTest,
onSave,
}: {
acmeDirectoryUrl: string
setAcmeDirectoryUrl: (v: string) => void
acmeZoneId: string
setAcmeZoneId: (v: string) => void
acmeTokenDraft: string
setAcmeTokenDraft: (v: string) => void
acmeTokenConfigured: boolean
acmeSaveBusy: boolean
onTest: () => void
onSave: () => void
}) {
return (
<OpsPanel
title="ACME · Cloudflare DNS-01"
description="Публичные Let's Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+."
contentClassName="px-5 py-4 flex flex-col gap-3"
>
<form
className="grid gap-3 md:grid-cols-2"
autoComplete="off"
onSubmit={(event) => event.preventDefault()}
>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">ACME directory URL</label>
<Input
autoComplete="off"
name="acme-directory-url"
value={acmeDirectoryUrl}
onChange={(e) => setAcmeDirectoryUrl(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Cloudflare zone id (опционально)</label>
<Input
autoComplete="off"
name="cloudflare-zone-id"
value={acmeZoneId}
onChange={(e) => setAcmeZoneId(e.target.value)}
placeholder="Авто по домену"
/>
</div>
<div className="flex flex-col gap-1.5 md:col-span-2">
<label className="text-sm font-medium">Cloudflare API token</label>
<Input
type="password"
autoComplete="new-password"
name="cloudflare-api-token"
value={acmeTokenDraft}
onChange={(e) => setAcmeTokenDraft(e.target.value)}
placeholder={
acmeTokenConfigured
? "Токен сохранён — введите новый для замены"
: "API token с правом DNS"
}
/>
</div>
</form>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" disabled={acmeSaveBusy} onClick={onTest}>
Проверить Cloudflare
</Button>
<Button size="sm" disabled={acmeSaveBusy} onClick={onSave}>
Сохранить настройки
</Button>
</div>
</OpsPanel>
)
}
function CertPartReference() { function CertPartReference() {
return ( return (
<OpsPanel <OpsPanel
@@ -280,7 +199,7 @@ function CertPartReference() {
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide"> <p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
{b.title} {b.title}
</p> </p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre"> <pre tabIndex={0} role="region" aria-label={"Команды RouterOS: " + b.title} className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2">
{b.lines.join("\n")} {b.lines.join("\n")}
</pre> </pre>
</div> </div>
@@ -329,6 +248,7 @@ function CertPartIssueForm({
<SectionTitle>Основные</SectionTitle> <SectionTitle>Основные</SectionTitle>
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат"> <FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
<select <select
aria-label="Сервер выпуска"
value={issueServerId} value={issueServerId}
onChange={(e) => setIssueServerId(e.target.value)} onChange={(e) => setIssueServerId(e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
@@ -346,6 +266,7 @@ function CertPartIssueForm({
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве"> <FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
<Input <Input
className="font-mono" className="font-mono"
aria-label="Имя сертификата"
value={issueCertName} value={issueCertName}
onChange={(e) => setIssueCertName(e.target.value)} onChange={(e) => setIssueCertName(e.target.value)}
placeholder="router-le" placeholder="router-le"
@@ -360,6 +281,7 @@ function CertPartIssueForm({
<FormField label="Common Name" required hint="Основное имя в сертификате"> <FormField label="Common Name" required hint="Основное имя в сертификате">
<Input <Input
className="font-mono" className="font-mono"
aria-label="Основной домен"
value={issueCommonName} value={issueCommonName}
onChange={(e) => setIssueCommonName(e.target.value)} onChange={(e) => setIssueCommonName(e.target.value)}
placeholder="vpn.example.com" placeholder="vpn.example.com"
@@ -368,6 +290,7 @@ function CertPartIssueForm({
<FormField label="SAN" hint="По одному имени в строке"> <FormField label="SAN" hint="По одному имени в строке">
<textarea <textarea
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
aria-label="Дополнительные домены"
value={issueSans} value={issueSans}
onChange={(e) => setIssueSans(e.target.value)} onChange={(e) => setIssueSans(e.target.value)}
placeholder="www.example.com" placeholder="www.example.com"
@@ -388,15 +311,16 @@ function CertPartIssueForm({
<p className="text-sm font-medium">Trust store · www</p> <p className="text-sm font-medium">Trust store · www</p>
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p> <p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
</div> </div>
<FormToggle checked={issueTrustWww} onChange={setIssueTrustWww} /> <FormToggle ariaLabel="Trust store www" checked={issueTrustWww} onChange={setIssueTrustWww} />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium">Trust store · api</p> <p className="text-sm font-medium">Trust store · api</p>
<p className="text-xs text-muted-foreground">REST API и управление</p> <p className="text-xs text-muted-foreground">REST API и управление</p>
</div> </div>
<FormToggle checked={issueTrustApi} onChange={setIssueTrustApi} /> <FormToggle ariaLabel="Trust store api" checked={issueTrustApi} onChange={setIssueTrustApi} />
</div> </div>
<p className="text-xs text-muted-foreground">После импорта сертификат будет назначен сервисам www-ssl и api-ssl; оба сервиса будут включены. Переключатели выше задают trust store сертификата.</p>
</div> </div>
)} )}
@@ -408,6 +332,8 @@ function CertPartIssueForm({
<li>Имя: {issueCertName || "—"}</li> <li>Имя: {issueCertName || "—"}</li>
<li>CN: {issueCommonName || "—"}</li> <li>CN: {issueCommonName || "—"}</li>
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li> <li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
<li>Сервисы: www-ssl и api-ssl будут включены и получат этот сертификат.</li>
<li>DNS: приложение попробует синхронизировать A-записи доменов с IPv4 сервера; CNAME сохраняются.</li>
</ul> </ul>
</div> </div>
)} )}
@@ -416,21 +342,42 @@ function CertPartIssueForm({
} }
export default function CertificatesPage() { export default function CertificatesPage() {
return <Suspense fallback={<p role="status">Загрузка контекста сертификатов…</p>}><CertificatesSource /></Suspense>
}
function CertificatesSource() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
return <CertificatesContent key={mode + ":" + backendUrl} />
}
function CertificatesContent() {
const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource() const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource()
const isLive = prefsHydrated && mode === "live" const isLive = prefsHydrated && mode === "live"
const liveReady = isLive && backendStatus === true const liveReady = isLive && backendStatus === true
const [search, setSearch] = useState("") const params = useSearchParams()
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all") const context = readCertificatesUrl(params)
const [certificates, setCertificates] = useState<CertificateDto[]>([]) const search = context.search
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle") const statusFilter = context.status
const [loadError, setLoadError] = useState<string | null>(null) const selectedServerId = context.server
const [serverList, setServerList] = useState<Server[]>([]) const urlError = context.error
function writeContext(changes: Record<string, string | null>, replace = false) {
const href = patchCertificatesUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
}
const setSearch = (value: string) => writeContext({ q: value }, true)
const setStatusFilter = (value: CertificateStatusFilter) => writeContext({ status: value === "all" ? null : value })
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
const canRead = can("mm:certificates:read")
const canWrite = can("mm:certificates:write")
const canReadServers = can("mm:servers:read")
const catalog = useCertificateCatalog(backendUrl, isLive, canRead, canReadServers)
const { snapshots, loading, error: loadError, serverError } = catalog
const serverList = useMemo(() => catalog.servers.map(toFrontendServer), [catalog.servers])
const [issueOpen, setIssueOpen] = useState(false) const [issueOpen, setIssueOpen] = useState(false)
const [issueStep, setIssueStep] = useState(1) const [issueStep, setIssueStep] = useState(1)
const [importOpen, setImportOpen] = useState(false)
const [issueBusy, setIssueBusy] = useState(false)
const [issueServerId, setIssueServerId] = useState("") const [issueServerId, setIssueServerId] = useState("")
const [issueCertName, setIssueCertName] = useState("") const [issueCertName, setIssueCertName] = useState("")
const [issueCommonName, setIssueCommonName] = useState("") const [issueCommonName, setIssueCommonName] = useState("")
@@ -438,28 +385,20 @@ export default function CertificatesPage() {
const [issueTrustWww, setIssueTrustWww] = useState(true) const [issueTrustWww, setIssueTrustWww] = useState(true)
const [issueTrustApi, setIssueTrustApi] = useState(true) const [issueTrustApi, setIssueTrustApi] = useState(true)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
"https://acme-v02.api.letsencrypt.org/directory",
)
const [acmeZoneId, setAcmeZoneId] = useState("")
const [acmeTokenDraft, setAcmeTokenDraft] = useState("")
const [acmeTokenConfigured, setAcmeTokenConfigured] = useState(false)
const [acmeSaveBusy, setAcmeSaveBusy] = useState(false)
const displayCerts = useMemo(() => { const displayCerts = useMemo(() => {
if (!prefsHydrated) return [] if (!prefsHydrated) return []
if (isLive) return certificates if (isLive) return Object.values(snapshots).flatMap(snapshot => snapshot.confirmed?.certificates ?? [])
return routerCertificates.map(mockToDto) return routerCertificates.map(mockToDto)
}, [prefsHydrated, isLive, certificates]) }, [prefsHydrated, isLive, snapshots])
const displayServers = isLive ? serverList : mockServers const displayServers = isLive ? serverList : mockServers
const scopedCerts = useMemo(() => { const scopedCerts = useMemo(() => {
if (urlError) return []
if (selectedServerId === ALL_SERVERS_ID) return displayCerts if (selectedServerId === ALL_SERVERS_ID) return displayCerts
return displayCerts.filter((c) => c.serverId === selectedServerId) return displayCerts.filter((c) => c.serverId === selectedServerId)
}, [displayCerts, selectedServerId]) }, [displayCerts, selectedServerId, urlError])
const certRailItems = useMemo<ServerTileItem[]>(() => ( const certRailItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({ displayServers.map((s) => ({
@@ -468,12 +407,12 @@ export default function CertificatesPage() {
host: s.host, host: s.host,
site: s.site, site: s.site,
country: s.country, country: s.country,
status: s.status, status: isLive ? catalog.servers.find(server => String(server.id) === s.id)?.status ?? undefined : s.status,
type: s.type, type: s.type,
enabled: s.enabled, enabled: s.enabled,
meta: String(displayCerts.filter((c) => c.serverId === s.id).length), meta: isLive && !snapshots[s.id]?.confirmed ? "—" : String(displayCerts.filter((c) => c.serverId === s.id).length),
})) }))
), [displayServers, displayCerts]) ), [displayServers, displayCerts, isLive, catalog.servers, snapshots])
const serverById = useMemo(() => { const serverById = useMemo(() => {
const map = new Map<string, Server>() const map = new Map<string, Server>()
@@ -481,69 +420,21 @@ export default function CertificatesPage() {
return map return map
}, [isLive, serverList]) }, [isLive, serverList])
const loadLive = useCallback( const loadLive = catalog.reload
async (silent = false) => { const issue = useCertificateIssue(backendUrl, liveReady && canRead, liveReady && canWrite, loadLive, isLive)
if (!isLive) return const issueBusy = issue.busy
if (!silent) setLoadState("loading") const issueTerminal = issue.checkpoint?.job?.status === "done" || issue.checkpoint?.job?.status === "failed"
setLoadError(null)
try {
const [certRes, serversRes] = await Promise.all([
listCertificates(backendUrl),
listServers(backendUrl),
])
setCertificates(certRes.certificates)
setServerList(serversRes.map(toFrontendServer))
if (certRes.failures.length > 0 && certRes.certificates.length === 0) {
setLoadError(
certRes.failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; "),
)
} else if (certRes.failures.length > 0) {
toast.warning(`Часть серверов недоступна: ${certRes.failures.length}`)
}
setLoadState("idle")
} catch (e) {
setCertificates([])
setLoadError(e instanceof Error ? e.message : "Не удалось загрузить сертификаты")
setLoadState("error")
}
},
[backendUrl, isLive],
)
const loadAcmeSettings = useCallback(async () => {
if (!liveReady) return
try {
const s = await getAcmeSettings(backendUrl)
setAcmeDirectoryUrl(s.directoryUrl)
setAcmeZoneId(s.defaultZoneId ?? "")
setAcmeTokenConfigured(s.tokenConfigured)
} catch {
/* ignore */
}
}, [backendUrl, liveReady])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setCertificates([])
setServerList([])
setLoadState("idle")
setLoadError(null)
})
return
}
queueMicrotask(() => {
void loadLive()
void loadAcmeSettings()
})
}, [isLive, loadLive, loadAcmeSettings])
const expiring = useMemo( const expiring = useMemo(
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30), () => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30),
[scopedCerts], [scopedCerts],
) )
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts]) const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
const scopeSnapshots = Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId)
const scopeKnown = !urlError && (!isLive && (selectedServerId === ALL_SERVERS_ID || mockServers.some(server => server.id === selectedServerId)) || canRead && catalog.hasState && !loadError && (selectedServerId === ALL_SERVERS_ID || !!snapshots[selectedServerId]) && scopeSnapshots.every(snapshot => snapshot.latest.error === null))
const classificationsKnown = scopeKnown && scopedCerts.every(cert => cert.status !== "unknown" && cert.daysLeft !== null)
const filtered = useMemo(() => { const filtered = useMemo(() => {
return scopedCerts.filter((c) => { return scopedCerts.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false if (statusFilter !== "all" && c.status !== statusFilter) return false
@@ -559,93 +450,19 @@ export default function CertificatesPage() {
}, [scopedCerts, search, statusFilter]) }, [scopedCerts, search, statusFilter])
async function handleRefresh() { async function handleRefresh() {
if (!liveReady) return await loadLive()
try {
const res = await refreshCertificates(backendUrl)
setCertificates(res.certificates)
toast.success("Список сертификатов обновлён")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка обновления")
}
}
async function pollIssueJob(jobId: string) {
for (let i = 0; i < 120; i++) {
await new Promise((r) => setTimeout(r, 2000))
const job = await getCertificateIssueJob(backendUrl, jobId)
if (job.status === "done") {
toast.success("Сертификат выпущен и импортирован на роутер")
setIssueOpen(false)
await loadLive(true)
return
}
if (job.status === "failed") {
throw new Error(job.error ?? "Выпуск не удался")
}
}
throw new Error("Таймаут ожидания выпуска сертификата")
} }
async function handleIssue() { async function handleIssue() {
if (!liveReady) return if (issue.checkpoint) { await issue.run(); return }
const domains = [ if (!liveReady || !canWrite || !canRead || urlError || !catalog.hasServers || serverError || !catalog.servers.some(server => String(server.id) === issueServerId && server.enabled)) return
issueCommonName.trim(), const domains = [issueCommonName.trim(), ...issueSans.split(/[\n,;]+/).map(value => value.trim())].filter(Boolean)
...issueSans.split(/[\n,;]+/).map((s) => s.trim()), if (!issueServerId || !issueCertName.trim() || domains.length === 0) { toast.error("Укажите сервер, имя сертификата и домены"); return }
].filter(Boolean) await issue.run({ serverId: issueServerId, certName: issueCertName.trim(), domainNames: domains, trustStore: [...(issueTrustWww ? ["www" as const] : []), ...(issueTrustApi ? ["api" as const] : [])] })
if (!issueServerId || !issueCertName.trim() || domains.length === 0) {
toast.error("Укажите сервер, имя сертификата и домены")
return
}
setIssueBusy(true)
try {
const trustStore: Array<"www" | "api"> = []
if (issueTrustWww) trustStore.push("www")
if (issueTrustApi) trustStore.push("api")
const { jobId } = await createCertificateIssueJob(backendUrl, {
serverId: issueServerId,
certName: issueCertName.trim(),
domainNames: domains,
trustStore,
})
toast.message("Выпуск сертификата запущен…")
await pollIssueJob(jobId)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка выпуска")
} finally {
setIssueBusy(false)
}
} }
function startNewIssue() {
async function handleSaveAcmeSettings() { if (!issue.reset()) return
if (!liveReady) return setIssueCertName(""); setIssueCommonName(""); setIssueSans(""); setIssueStep(1)
setAcmeSaveBusy(true)
try {
const saved = await putAcmeSettings(backendUrl, {
directoryUrl: acmeDirectoryUrl.trim(),
defaultZoneId: acmeZoneId.trim() || null,
cloudflareApiToken: acmeTokenDraft.trim() ? acmeTokenDraft.trim() : undefined,
})
setAcmeTokenConfigured(saved.tokenConfigured)
setAcmeTokenDraft("")
toast.success("Настройки ACME сохранены")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сохранить настройки")
} finally {
setAcmeSaveBusy(false)
}
}
async function handleTestAcme() {
if (!liveReady) return
try {
const res = await testAcmeSettings(backendUrl, {
cloudflareApiToken: acmeTokenDraft.trim() || undefined,
})
if (res.ok) toast.success(res.message ?? "Cloudflare API доступен")
else toast.error(res.message ?? "Проверка не прошла")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Проверка Cloudflare не удалась")
}
} }
return ( return (
@@ -656,7 +473,8 @@ export default function CertificatesPage() {
onSelect={setSelectedServerId} onSelect={setSelectedServerId}
showAll showAll
allCount={displayServers.length} allCount={displayServers.length}
loading={isLive && loadState === "loading" && displayServers.length === 0} preserveSelection
loading={isLive && loading && displayServers.length === 0}
header={ header={
<PageHeader <PageHeader
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]} crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
@@ -666,47 +484,59 @@ export default function CertificatesPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!liveReady || loadState === "loading"} disabled={!isLive || loading || (!canRead && !canReadServers)}
onClick={() => { onClick={() => {
void handleRefresh() void handleRefresh()
}} }}
> >
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} /> <RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить Обновить
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}> <Button variant="outline" size="sm" disabled title="Импорт сертификатов пока недоступен">
<UploadIcon className="size-4" /> <UploadIcon className="size-4" />
Импорт Импорт
</Button> </Button>
<Button <Button
size="sm" size="sm"
disabled={!liveReady || issueBusy} disabled={!issue.hydrated || !liveReady || !canRead || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
onClick={() => { onClick={() => {
setIssueStep(1) setIssueStep(issue.checkpoint ? 4 : 1)
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId) if (!issue.checkpoint && selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
setIssueOpen(true) setIssueOpen(true)
}} }}
> >
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />
Выпустить сертификат {issue.checkpoint ? "Задача выпуска" : "Выпустить сертификат"}
</Button> </Button>
</> </>
} }
/> />
} }
> >
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5 min-w-0">
{!isLive && <p role="status" className="text-xs text-muted-foreground">Моковые данные</p>}
{urlError && <div role="alert" aria-label="Контекст сертификатов">{urlError} <Button variant="outline" size="sm" onClick={() => writeContext({ server: null, status: null, q: null }, true)}>Сбросить контекст</Button></div>}
{!urlError && selectedServerId !== ALL_SERVERS_ID && (isLive ? catalog.hasState && !snapshots[selectedServerId] : !mockServers.some(server => server.id === selectedServerId)) && <p role="alert">Выбранный сервер недоступен в каталоге сертификатов. Выберите другой сервер.</p>}
{isLive && backendStatus === false && ( {isLive && backendStatus === false && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300"> <div className="rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
Backend недоступен — live-операции отключены. Backend недоступен — live-операции отключены.
</div> </div>
)} )}
{loadError && isLive && ( {loadError && isLive && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"> <div role="alert" aria-label="Каталог сертификатов" className="[overflow-wrap:anywhere] rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{loadError} {catalog.hasState ? "Последние данные сохранены. " : "Сертификаты пока неизвестны. "}{loadError}
</div> </div>
)} )}
{isLive && serverError && <p role="alert" aria-label="Серверы сертификатов" className="[overflow-wrap:anywhere]">{catalog.hasServers ? "Последние серверы сохранены. " : "Каталог серверов пока неизвестен. "}{serverError}</p>}
{isLive && !canRead && <p role="status">Нет права mm:certificates:read.</p>}
{isLive && !canReadServers && <p role="status">Нет права mm:servers:read. Выбор устройства для выпуска недоступен.</p>}
{isLive && catalog.collectedAt && <p className="text-xs text-muted-foreground">Последнее чтение каталога: {new Date(catalog.collectedAt).toLocaleString("ru-RU")}</p>}
{isLive && Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId).map(snapshot => <div key={snapshot.latest.serverId}>
{snapshot.latest.error && <p role="alert" aria-label={"Сертификаты сервера " + snapshot.latest.serverId} className="[overflow-wrap:anywhere]">{snapshot.latest.serverName}: {snapshot.latest.error}. {snapshot.confirmed ? "Последние сертификаты сохранены." : "Сертификаты пока неизвестны."}</p>}
<p className="text-xs text-muted-foreground">{snapshot.latest.serverName}: {snapshot.confirmed ? "Подтверждённые данные от " + new Date(snapshot.confirmed.collectedAt).toLocaleString("ru-RU") : "Успешного чтения нет"}</p>
</div>)}
{(expiring.length > 0 || expired.length > 0) && ( {(expiring.length > 0 || expired.length > 0) && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{expired.length > 0 && <CertPartAlertExpired expired={expired} />} {expired.length > 0 && <CertPartAlertExpired expired={expired} />}
@@ -714,51 +544,38 @@ export default function CertificatesPage() {
</div> </div>
)} )}
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} /> {!classificationsKnown && <p role="status" className="text-xs text-muted-foreground">Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.</p>}
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} complete={classificationsKnown} totalKnown={scopeKnown} />
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} /> <CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
{liveReady && ( <CertificateAcmeSettings backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
<CertPartAcmeSettings
acmeDirectoryUrl={acmeDirectoryUrl}
setAcmeDirectoryUrl={setAcmeDirectoryUrl}
acmeZoneId={acmeZoneId}
setAcmeZoneId={setAcmeZoneId}
acmeTokenDraft={acmeTokenDraft}
setAcmeTokenDraft={setAcmeTokenDraft}
acmeTokenConfigured={acmeTokenConfigured}
acmeSaveBusy={acmeSaveBusy}
onTest={() => {
void handleTestAcme()
}}
onSave={() => {
void handleSaveAcmeSettings()
}}
/>
)}
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, CN, эмитенту…" searchPlaceholder="Поиск по имени, CN, эмитенту…"
segmented={{ filterControl={<div role="group" aria-label="Статус сертификатов" className="w-full sm:w-auto max-w-full overflow-x-auto"><SegmentedControl className="whitespace-nowrap"
value: statusFilter, value={statusFilter}
onChange: setStatusFilter, onChange={setStatusFilter}
options: [ options={[
{ value: "all", label: "Все" }, { value: "all", label: "Все" },
{ value: "valid", label: "Действующие" }, { value: "valid", label: "Действующие" },
{ value: "expired", label: "Истёкшие" }, { value: "expired", label: "Истёкшие" },
{ value: "revoked", label: "Отозванные" }, { value: "revoked", label: "Отозванные" },
], { value: "unknown", label: "Неизвестные" },
}} ]}
countLabel={`${filtered.length} сертификатов`} /></div>}
countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`}
/> />
<CertificatesDataGrid {urlError ? <p role="status" className="p-5">Исправьте контекст ссылки</p> : isLive && scopedCerts.length === 0 && !scopeKnown ? <p role="status" className="p-5">{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}</p> : <CertificatesDataGrid
certificates={filtered} certificates={filtered}
serverMap={serverById} serverMap={serverById}
isLoading={prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0} emptyTitle={search || statusFilter !== "all" ? "Нет совпадений" : "Нет сертификатов"}
/> emptyDescription={search || statusFilter !== "all" ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}
isLoading={isLive && loading && displayCerts.length === 0}
/>}
</DataPageCard> </DataPageCard>
<CertPartReference /> <CertPartReference />
@@ -766,7 +583,7 @@ export default function CertificatesPage() {
</ServerRailLayout> </ServerRailLayout>
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}> <Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0"> <SheetContent side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg min-w-0 flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0"> <SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Выпуск сертификата</SheetTitle> <SheetTitle>Выпуск сертификата</SheetTitle>
<SheetDescription> <SheetDescription>
@@ -774,6 +591,11 @@ export default function CertificatesPage() {
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="px-6 pt-3 text-sm min-w-0 [overflow-wrap:anywhere]">
{(issue.error || issue.storageError) && <p role="alert" aria-label="Выпуск сертификата">{issue.error || issue.storageError}</p>}
{issue.checkpoint && <p className="text-xs text-muted-foreground">Закрытие формы не отменяет серверную задачу. Её можно проверить повторно.</p>}
{issue.checkpoint && <p role="status">Задача {issue.checkpoint.request.requestId}: {issue.checkpoint.job ? ({ queued: "В очереди", running: "Выполняется", done: "Завершена", failed: "Ошибка" }[issue.checkpoint.job.status]) + " · " + issue.checkpoint.job.step : issue.checkpoint.accepted ? "Принята; результат пока неизвестен" : "Принятие запроса пока не подтверждено"}. Сервер {issue.checkpoint.request.serverId}, сертификат {issue.checkpoint.request.certName}.</p>}
</div>
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5"> <Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5"> <StepperNav className="mb-5">
{[ {[
@@ -794,30 +616,33 @@ export default function CertificatesPage() {
<StepperPanel className="flex-1 overflow-y-auto"> <StepperPanel className="flex-1 overflow-y-auto">
{[1, 2, 3, 4].map((s) => ( {[1, 2, 3, 4].map((s) => (
<StepperContent key={s} value={s}> <StepperContent key={s} value={s}>
<fieldset disabled={issueBusy || !!issue.checkpoint} className="min-w-0">
<CertPartIssueForm <CertPartIssueForm
step={s as 1 | 2 | 3 | 4} step={s as 1 | 2 | 3 | 4}
serverList={displayServers} serverList={displayServers}
issueServerId={issueServerId} issueServerId={String(issue.checkpoint?.request.serverId ?? issueServerId)}
setIssueServerId={setIssueServerId} setIssueServerId={setIssueServerId}
issueCertName={issueCertName} issueCertName={issue.checkpoint?.request.certName ?? issueCertName}
setIssueCertName={setIssueCertName} setIssueCertName={setIssueCertName}
issueCommonName={issueCommonName} issueCommonName={issue.checkpoint?.request.domainNames[0] ?? issueCommonName}
setIssueCommonName={setIssueCommonName} setIssueCommonName={setIssueCommonName}
issueSans={issueSans} issueSans={issue.checkpoint ? issue.checkpoint.request.domainNames.slice(1).join("\n") : issueSans}
setIssueSans={setIssueSans} setIssueSans={setIssueSans}
issueTrustWww={issueTrustWww} issueTrustWww={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("www") ?? true : issueTrustWww}
setIssueTrustWww={setIssueTrustWww} setIssueTrustWww={setIssueTrustWww}
issueTrustApi={issueTrustApi} issueTrustApi={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("api") ?? true : issueTrustApi}
setIssueTrustApi={setIssueTrustApi} setIssueTrustApi={setIssueTrustApi}
/> />
</fieldset>
</StepperContent> </StepperContent>
))} ))}
</StepperPanel> </StepperPanel>
</Stepper> </Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2"> <SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}> {issueTerminal && canWrite && <Button variant="outline" disabled={issueBusy} onClick={startNewIssue}>Новый выпуск</Button>}
Отмена <SheetClose render={<Button variant="outline" className="flex-1" />}>
{issue.checkpoint ? "Закрыть" : "Отмена"}
</SheetClose> </SheetClose>
{issueStep > 1 && ( {issueStep > 1 && (
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}> <Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
@@ -827,7 +652,7 @@ export default function CertificatesPage() {
{issueStep < 4 ? ( {issueStep < 4 ? (
<Button <Button
className="flex-1" className="flex-1"
disabled={issueStep === 1 && (!issueServerId || !issueCertName)} disabled={issueBusy || !issue.checkpoint && issueStep === 1 && (!issueServerId || !issueCertName)}
onClick={() => setIssueStep((s) => s + 1)} onClick={() => setIssueStep((s) => s + 1)}
> >
Далее Далее
@@ -835,26 +660,16 @@ export default function CertificatesPage() {
) : ( ) : (
<Button <Button
className="flex-1" className="flex-1"
disabled={!liveReady || issueBusy} disabled={!issue.hydrated || !liveReady || !canRead || issueBusy || issueTerminal || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
onClick={() => { void handleIssue() }} onClick={() => { void handleIssue() }}
> >
{issueBusy ? "Выпуск…" : "Выпустить"} {issueBusy ? "Проверяем…" : issueTerminal ? "Задача завершена" : issue.checkpoint ? "Продолжить проверку" : "Выпустить"}
</Button> </Button>
)} )}
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<FileImportDialog
open={importOpen}
onOpenChange={setImportOpen}
title="Импорт сертификата"
description="Загрузите PEM, CRT или PKCS#12 для импорта на RouterOS"
accept=".pem,.crt,.cer,.p12,.pfx"
onImport={async (files) => {
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
}}
/>
</> </>
) )
} }
+56 -45
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useMemo, useEffect } from "react" import { useEffect, useRef, useState, useMemo } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { import {
@@ -8,21 +8,20 @@ import {
type CommunityRow, type CommunityRow,
TYPE_LABELS, TYPE_LABELS,
ACTION_LABELS, ACTION_LABELS,
ACTION_COLOR,
} from "@/components/data-grids/communities-data-grid" } from "@/components/data-grids/communities-data-grid"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
PlusIcon, SearchIcon, TagIcon, FilterIcon, PlusIcon, SearchIcon, TagIcon, FilterIcon,
CopyIcon, CheckIcon, TrashIcon, PencilIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
LoaderCircleIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { filters } from "@/lib/data" import { filters } from "@/lib/data"
import { useDataSource } from "@/lib/data-source" import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
import { useEvoBGP } from "@/lib/evobgp-context" import { useEvoBGP } from "@/lib/evobgp-context"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
@@ -98,24 +97,31 @@ const COMMUNITIES: Community[] = [
// ─── page ───────────────────────────────────────────────────────────────────── // ─── page ─────────────────────────────────────────────────────────────────────
export default function CommunitiesPage() { export default function CommunitiesPage() {
const { mode } = useDataSource() const { sourceKey } = useEvoBGP()
const { enabled, snapshot, loading, error } = useEvoBGP() return <CommunitiesContent key={sourceKey} />
const useEvoCatalog = mode === "live" && enabled }
function CommunitiesContent() {
const { mode, snapshot, loading, known } = useEvoCatalogView()
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all") const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
const [copied, setCopied] = useState<string | null>(null) const [copied, setCopied] = useState<string | null>(null)
const [selected, setSelected] = useState<Community | null>(null) const [copying, setCopying] = useState(false)
const [copyError, setCopyError] = useState<string | null>(null)
const copyRequest = useRef({ active: true, pending: false, revision: 0, timer: null as ReturnType<typeof setTimeout> | null })
useEffect(() => {
const state = copyRequest.current; state.active = true
return () => { state.active = false; state.revision++; if (state.timer) clearTimeout(state.timer) }
}, [])
const [selectedId, setSelectedId] = useState<string | null>(null)
const listData = useMemo((): Community[] => { const listData = useMemo((): Community[] => {
if (!useEvoCatalog) return COMMUNITIES if (mode === "mock") return COMMUNITIES
if (loading && !snapshot) return [] if (loading && !snapshot) return []
return snapshot?.communities ?? [] return snapshot?.communities ?? []
}, [useEvoCatalog, loading, snapshot]) }, [mode, loading, snapshot])
useEffect(() => { const selected = known ? listData.find(row => row.id === selectedId) ?? null : null
setSelected(null)
}, [useEvoCatalog])
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.toLowerCase() const q = search.toLowerCase()
@@ -126,10 +132,24 @@ export default function CommunitiesPage() {
}) })
}, [search, typeFilter, listData]) }, [search, typeFilter, listData])
const handleCopy = (value: string) => { const handleCopy = async (value: string) => {
navigator.clipboard.writeText(value).catch(() => {}) const state = copyRequest.current
setCopied(value) if (!state.active || state.pending) return
setTimeout(() => setCopied(null), 1500) state.pending = true; const revision = ++state.revision
if (state.timer) clearTimeout(state.timer)
const current = () => state.active && state.revision === revision
setCopying(true); setCopied(null); setCopyError(null)
try {
await navigator.clipboard.writeText(value)
if (!current()) return
setCopied(value)
state.timer = setTimeout(() => { if (current()) setCopied(null) }, 1500)
} catch {
if (current()) setCopyError("Браузер не разрешил запись в буфер обмена. Повторите копирование или выделите значение вручную.")
} finally {
state.pending = false
if (current()) setCopying(false)
}
} }
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
@@ -138,8 +158,7 @@ export default function CommunitiesPage() {
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Данные" }, { label: "Communities" }]} crumbs={[{ label: "Данные" }, { label: "Communities" }]}
actions={ actions={mode === "live" ? null : <Button size="sm">
<Button size="sm">
<PlusIcon className="size-4" />Добавить <PlusIcon className="size-4" />Добавить
</Button> </Button>
} }
@@ -147,19 +166,10 @@ export default function CommunitiesPage() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{useEvoCatalog && ( <EvoBgpCatalogStatus />
<p className={cn( {copyError && <Alert variant="warning"><AlertTitle>Community не скопировано</AlertTitle><AlertDescription>{copyError}</AlertDescription></Alert>}
"text-xs flex items-center gap-1.5 -mt-1", {copying && <p role="status" className="text-xs text-muted-foreground">Копирование community…</p>}
error ? "text-destructive" : "text-muted-foreground", {copied && <p role="status" className="text-xs text-muted-foreground">Community {copied} скопировано</p>}
)}>
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
{error
? `EvoBGP: ${error}`
: snapshot?.fetchedAt
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
</p>
)}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка communities" aria-label="Сводка communities"
@@ -167,35 +177,35 @@ export default function CommunitiesPage() {
{ {
id: "all", id: "all",
label: "Всего communities", label: "Всего communities",
value: String(listData.length), value: known ? String(listData.length) : "—",
icon: <TagIcon className="size-4" />, icon: <TagIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "enabled", id: "enabled",
label: "Активных", label: "Активных",
value: String(listData.filter((c) => c.enabled).length), value: known ? String(listData.filter((c) => c.enabled).length) : "—",
icon: <CheckIcon className="size-4" />, icon: <CheckIcon className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "standard", id: "standard",
label: "Стандартных", label: "Стандартных",
value: String(listData.filter((c) => c.type === "standard").length), value: known ? String(listData.filter((c) => c.type === "standard").length) : "—",
icon: <TagIcon className="size-4" />, icon: <TagIcon className="size-4" />,
iconClassName: "text-info", iconClassName: "text-info",
}, },
{ {
id: "filters", id: "filters",
label: "Использует фильтры", label: "Использует фильтры",
value: String(new Set(listData.flatMap((c) => c.filterIds)).size), value: mode === "mock" ? String(new Set(listData.flatMap((c) => c.filterIds)).size) : "—",
icon: <FilterIcon className="size-4" />, icon: <FilterIcon className="size-4" />,
iconClassName: "text-primary", iconClassName: "text-primary",
}, },
]} ]}
/> />
<div className="grid grid-cols-[1fr_320px] gap-5"> <div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-5">
{/* ── main table ── */} {/* ── main table ── */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* toolbar */} {/* toolbar */}
@@ -204,7 +214,7 @@ export default function CommunitiesPage() {
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" /> <SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} /> <Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
</div> </div>
<div className="flex gap-1"> <div className="flex flex-wrap gap-1">
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => ( {(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"} <Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}> className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
@@ -216,13 +226,14 @@ export default function CommunitiesPage() {
{/* list */} {/* list */}
<DataPageCard> <DataPageCard>
<CommunitiesDataGrid {known ? <CommunitiesDataGrid
communities={filtered} communities={filtered}
selectedId={selected?.id} selectedId={selected?.id}
copiedValue={copied} copiedValue={copied}
onSelect={setSelected} copying={copying}
onSelect={row => setSelectedId(row.id)}
onCopy={handleCopy} onCopy={handleCopy}
/> /> : <EvoBgpCatalogUnavailable />}
</DataPageCard> </DataPageCard>
</div> </div>
@@ -234,8 +245,8 @@ export default function CommunitiesPage() {
description={selected.name} description={selected.name}
headerRight={ headerRight={
<div className="flex gap-1 shrink-0"> <div className="flex gap-1 shrink-0">
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button> <Button size="sm" variant="ghost" aria-label="Изменить community" disabled className="size-7 p-0"><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> <Button size="sm" variant="ghost" aria-label="Удалить community" disabled className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
</div> </div>
} }
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5" contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
@@ -256,7 +267,7 @@ export default function CommunitiesPage() {
{[ {[
["Тип", TYPE_LABELS[selected.type]], ["Тип", TYPE_LABELS[selected.type]],
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`], ["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
["Маршрутов", selected.prefixCount.toLocaleString("ru-RU")], ["Маршрутов", selected.prefixCount?.toLocaleString("ru-RU") ?? "—"],
["Серверов", selected.serverCount.toLocaleString("ru-RU")], ["Серверов", selected.serverCount.toLocaleString("ru-RU")],
].map(([k, v]) => ( ].map(([k, v]) => (
<div key={k} className="flex justify-between gap-2"> <div key={k} className="flex justify-between gap-2">
@@ -282,7 +293,7 @@ export default function CommunitiesPage() {
)} )}
<div className="pt-2 border-t flex flex-col gap-2"> <div className="pt-2 border-t flex flex-col gap-2">
<Button size="sm" variant="outline" className="w-full justify-start gap-2" onClick={() => handleCopy(selected.value)}> <Button size="sm" variant="outline" className="w-full justify-start gap-2" disabled={copying} onClick={() => { void handleCopy(selected.value) }}>
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />} {copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
Скопировать значение Скопировать значение
</Button> </Button>
+369 -182
View File
@@ -1,15 +1,20 @@
"use client" "use client"
import { useMemo, useState } from "react" import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useSearchParams } from "next/navigation"
import { readContainersUrl, patchContainersUrl, type ContainerStatusFilter } from "@/lib/containers-url-state"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { routerContainers, servers } from "@/lib/data" import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
import type { RouterContainer } from "@/lib/data" import type { RouterContainer, Server } from "@/lib/data"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuItem, DropdownMenuSeparator,
@@ -18,124 +23,110 @@ import {
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon, BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon, MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
CodeXmlIcon, ActivityIcon, ServerIcon, CodeXmlIcon, ActivityIcon, ServerIcon,
TerminalIcon, AlertCircleIcon, TerminalIcon, AlertCircleIcon, RefreshCwIcon,
} from "lucide-react" } from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { useDataSource } from "@/lib/data-source"
import { ApiClientError, requestJson } from "@/shared/api/http-client"
import { mutateContainer, type ContainerAction, readContainersState } from "@/shared/api/containers"
import { serverListSchema } from "@mmapp/contracts/servers"
import type { ContainerServerState, ContainersState } from "@mmapp/contracts/containers"
import { can } from "@/lib/auth"
import { userApiError } from "@/shared/api/users"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── helpers ────────────────────────────────────────────────────────────────── type ContainerView = Omit<RouterContainer, "status" | "startOnBoot"> & { status: RouterContainer["status"] | "unknown"; startOnBoot: boolean | null }
type ContainerServer = Omit<Server, "status"> & {status: Server["status"] | null}
function serverFor(id: string) { interface BackendServer {
return servers.find((s) => s.id === id) id: number
name: string
host: string
type?: Server["type"]
site?: string
country: string
asn?: string
enabled: boolean
status?: Server["status"] | null
latency?: number | null
} }
function statusConfig(status: RouterContainer["status"]) { function mapBackendServer(s: BackendServer): ContainerServer {
return { return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site ?? "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? null,
latency: s.latency ?? null,
sessions: 0,
}
}
function statusConfig(status: ContainerView["status"]) {
return {
unknown: { dot: "bg-muted-foreground", badge: "bg-muted text-muted-foreground border-border", label: "Неизвестно" },
running: { running: {
dot: "bg-emerald-500 animate-pulse", dot: "bg-success",
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", badge: "bg-success/10 text-success border-success/20",
label: "Running", label: "Работает",
}, },
stopped: { stopped: {
dot: "bg-muted-foreground", dot: "bg-muted-foreground",
badge: "bg-muted/50 text-muted-foreground border-border", badge: "bg-muted/50 text-muted-foreground border-border",
label: "Stopped", label: "Остановлен",
}, },
error: { error: {
dot: "bg-red-500 animate-pulse", dot: "bg-destructive",
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", badge: "bg-destructive/10 text-destructive border-destructive/20",
label: "Error", label: "Ошибка",
}, },
}[status] }[status]
} }
// ─── RSC generator ──────────────────────────────────────────────────────────── type ContainerExport = { container: ContainerView; collectedAt: string | null; failures: ContainerServerState["failures"]; source: "live" | "mock" }
function ExportSheet({ selection, onClose }: {selection: ContainerExport | null; onClose: () => void}) {
function generateContainerRsc(c: RouterContainer): string { const formats = useMemo(() => [{
const srv = serverFor(c.serverId) id: "json", label: "Снимок JSON", filename: "container-" + (selection?.container.id ?? "snapshot").replace(/[^a-zA-Z0-9_-]/g, "_") + ".json",
const lines: string[] = [] code: selection ? JSON.stringify(selection, null, 2) : "",
lines.push(`# RouterOS Container — ${c.name}`) }], [selection])
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`) return <CodeExportSheet open={!!selection} onClose={onClose} title="Снимок контейнера" description="Данные каталога; конфигурация устройства и резервная копия в снимок не входят."
lines.push(`# Образ: ${c.image}:${c.tag}`) className="data-[side=right]:w-full data-[side=right]:sm:max-w-xl min-w-0 [&_[data-slot=sheet-footer]]:flex-wrap"
lines.push(`# RouterOS 7.4+ · /container`) beforeCode={<p className="text-xs text-muted-foreground">{selection?.source === "mock" ? "Демонстрационные данные." : "Чтение RouterOS: " + (selection?.collectedAt ? new Date(selection.collectedAt).toLocaleString("ru-RU") : "время неизвестно")}{selection?.failures.length ? " Настройки получены частично; ошибки включены в JSON." : ""}</p>}
lines.push(``) returnFocus={() => Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === "Действия контейнера " + selection?.container.name) ?? null}
formats={formats} />
// interface
for (const iface of c.interfaces) {
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
}
lines.push(``)
// envs
if (c.envs.length > 0) {
lines.push(`/container/envs/add name=${c.name}-envs \\`)
for (const { key, value } of c.envs) {
lines.push(` ${key}="${value}" \\`)
}
lines.push(``)
}
// mounts
for (const m of c.mounts) {
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
if (m.src) lines.push(` src=${m.src} \\`)
lines.push(` dst=${m.dst}`)
lines.push(``)
}
// container
lines.push(`/container/add \\`)
lines.push(` remote-image=${c.image}:${c.tag} \\`)
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
if (c.envs.length > 0) lines.push(` envlist=${c.name}-envs \\`)
if (c.mounts.length > 0) lines.push(` mounts=${c.mounts.map((m) => `${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)}`).join(",")} \\`)
if (c.cmd) lines.push(` cmd="${c.cmd}" \\`)
if (c.startOnBoot) lines.push(` start-on-boot=yes \\`)
if (c.comment) lines.push(` comment="${c.comment}" \\`)
lines.push(` logging=yes`)
return lines.join("\n")
} }
// ─── Export Sheet ─────────────────────────────────────────────────────────────
function ExportSheet({ open, container, onClose }: {
open: boolean; container: RouterContainer | null; onClose: () => void
}) {
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
return (
<CodeExportSheet
open={open}
onClose={onClose}
title="Экспорт Container"
description="RouterOS 7.4+ · /container · /interface/veth"
formats={[
{
id: "rsc",
label: "MikroTik .rsc",
filename: `${container?.name ?? "container"}.rsc`,
code,
},
]}
/>
)
}
// ─── Container card ───────────────────────────────────────────────────────────
function ContainerCard({ function ContainerCard({
container, container,
server,
live,
busy,
onExport, onExport,
onStart,
onStop,
onRestart,
onRemove,
}: { }: {
container: RouterContainer container: ContainerView
server?: ContainerServer
live: boolean
busy: boolean
onExport: () => void onExport: () => void
onStart: () => void
onStop: () => void
onRestart: () => void
onRemove: () => void
}) { }) {
const srv = serverFor(container.serverId)
const cfg = statusConfig(container.status) const cfg = statusConfig(container.status)
const canMutate = live && Boolean(container.rosId) && !busy
return ( return (
<Frame dense className="w-full overflow-hidden"> <Frame dense className="w-full overflow-hidden">
@@ -150,51 +141,56 @@ function ContainerCard({
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger render={ <DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7 shrink-0"> <Button variant="ghost" size="icon" aria-label={"Действия контейнера " + container.name} className="size-11 sm:size-8 shrink-0" disabled={busy}>
<MoreHorizontalIcon className="size-4" /> <MoreHorizontalIcon aria-hidden="true" className="size-4" />
</Button> </Button>
} /> } />
<DropdownMenuContent side="bottom" align="end"> <DropdownMenuContent side="bottom" align="end">
{container.status === "running" ? ( {container.status === "running" ? (
<DropdownMenuItem><StopCircleIcon className="size-4 text-amber-500" />Остановить</DropdownMenuItem> <DropdownMenuItem disabled={!canMutate} onClick={onStop}>
<StopCircleIcon aria-hidden="true" className="size-4 text-warning" />Остановить
</DropdownMenuItem>
) : ( ) : (
<DropdownMenuItem><PlayIcon className="size-4 text-emerald-500" />Запустить</DropdownMenuItem> <DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onStart}>
<PlayIcon aria-hidden="true" className="size-4 text-success" />Запустить
</DropdownMenuItem>
)} )}
<DropdownMenuItem><TerminalIcon className="size-4" />Логи</DropdownMenuItem> <DropdownMenuItem disabled><TerminalIcon aria-hidden="true" className="size-4" />Логи</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem> <DropdownMenuItem disabled><PencilIcon aria-hidden="true" className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem> <DropdownMenuItem onClick={onExport}><CodeXmlIcon aria-hidden="true" className="size-4" />Снимок JSON</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem> <DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onRestart}>
<PowerIcon aria-hidden="true" className="size-4" />Перезапустить
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem> <DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
<Trash2Icon className="size-4" />Удалить
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="px-4 py-3 flex flex-col gap-3"> <div className="px-4 py-3 flex flex-col gap-3">
{/* image */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" /> <BoxIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
<span className="font-mono text-xs text-foreground/80"> <span className="font-mono text-xs text-foreground/80 min-w-0 [overflow-wrap:anywhere]">
{container.image}:<span className="text-muted-foreground">{container.tag}</span> {container.image || "Образ неизвестен"}{container.tag && <>:<span className="text-muted-foreground">{container.tag}</span></>}
</span> </span>
</div> </div>
{/* server */} {server && (
{srv && (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<ServerIcon className="size-3.5 shrink-0" /> <ServerIcon aria-hidden="true" className="size-3.5 shrink-0" />
<Flag code={srv.country} size={12} /> <Flag code={server.country} size={12} />
<span className="font-mono">{srv.name}</span> <span className="font-mono truncate" title={server.name}>{server.name}</span>
</div> </div>
)} )}
{/* uptime + stats */} {container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
{container.status === "running" && (
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5"> <div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
{container.uptime && ( {container.uptime && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ActivityIcon className="size-3 text-emerald-500" /> <ActivityIcon aria-hidden="true" className="size-3 text-success" />
<span>{container.uptime}</span> <span>{container.uptime}</span>
</div> </div>
)} )}
@@ -202,7 +198,7 @@ function ContainerCard({
<div> <div>
<span className="text-muted-foreground">CPU </span> <span className="text-muted-foreground">CPU </span>
<span className={cn("font-mono font-medium", <span className={cn("font-mono font-medium",
container.cpu > 50 ? "text-amber-500" : "text-foreground")}> container.cpu > 50 ? "text-warning" : "text-foreground")}>
{container.cpu}% {container.cpu}%
</span> </span>
</div> </div>
@@ -210,13 +206,12 @@ function ContainerCard({
{container.memMb !== undefined && ( {container.memMb !== undefined && (
<div> <div>
<span className="text-muted-foreground">RAM </span> <span className="text-muted-foreground">RAM </span>
<span className="font-mono font-medium">{container.memMb} МБ</span> <span className="font-mono font-medium">{container.memMb} МиБ</span>
</div> </div>
)} )}
</div> </div>
)} )}
{/* interfaces */}
{container.interfaces.length > 0 && ( {container.interfaces.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{container.interfaces.map((i) => ( {container.interfaces.map((i) => (
@@ -227,7 +222,6 @@ function ContainerCard({
</div> </div>
)} )}
{/* mounts */}
{container.mounts.length > 0 && ( {container.mounts.length > 0 && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{container.mounts.map((m, idx) => ( {container.mounts.map((m, idx) => (
@@ -241,7 +235,7 @@ function ContainerCard({
)} )}
{container.comment && ( {container.comment && (
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p> <p className="text-xs text-muted-foreground border-t pt-2 [overflow-wrap:anywhere]">{container.comment}</p>
)} )}
</div> </div>
</FramePanel> </FramePanel>
@@ -249,42 +243,225 @@ function ContainerCard({
) )
} }
// ════════════════════════════════════════════════════════════════════════════ export default function ContainersPage() { return <Suspense fallback={<p role="status">Загрузка контекста контейнеров…</p>}><ContainersSource /></Suspense> }
export default function ContainersPage() { function ContainersSource() {
const [search, setSearch] = useState("") const { mode, backendUrl, prefsHydrated } = useDataSource()
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all") if (!prefsHydrated) return <p role="status">Загрузка настроек контейнеров…</p>
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null) return <ContainersContent key={JSON.stringify([mode, backendUrl])} />
}
function ContainersContent() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const canRead = can("mm:dashboard:read")
const canReadServers = can("mm:servers:read")
const canWrite = can("mm:servers:write")
const params = useSearchParams()
const context = readContainersUrl(params)
const search = context.search
const statusFilter = context.status
const selectedServerId = context.server
const urlError = context.error
function writeContext(changes: Record<string, string | null>, replace = false) {
const href = patchContainersUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
}
const setSearch = (value: string) => writeContext({ q: value }, true)
const setStatusFilter = (value: ContainerStatusFilter) => writeContext({ status: value === "all" ? null : value })
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
const [exportContainer, setExportContainer] = useState<ContainerExport | null>(null)
const [state, setState] = useState<ContainersState | null>(null)
const [lastStates, setLastStates] = useState<Record<number, ContainerServerState>>({})
const [liveServers, setLiveServers] = useState<ContainerServer[]>([])
const [loading, setLoading] = useState(isLive && (canRead || canReadServers))
const [busyId, setBusyId] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ContainerView | null>(null)
const deleteReturnLabel = useRef<string | null>(null)
useEffect(() => {
if (deleteTarget || busyId || !deleteReturnLabel.current) return
const frame = requestAnimationFrame(() => {
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === deleteReturnLabel.current)
;(target ?? Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent?.trim() === "Обновить"))?.focus()
})
return () => cancelAnimationFrame(frame)
}, [deleteTarget, busyId])
const [operationError, setOperationError] = useState<string | null>(null)
const [operationNotice, setOperationNotice] = useState<string | null>(null)
const [commandNeedsRead, setCommandNeedsRead] = useState(false)
const mutationLock = useRef(false)
const mutationController = useRef<AbortController | null>(null)
const alive = useRef(true)
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
const [liveError, setLiveError] = useState<string | null>(null)
const [serversError, setServersError] = useState<string | null>(null)
const [readAt, setReadAt] = useState<number | null>(null)
const [serversReadAt, setServersReadAt] = useState<number | null>(null)
const loadController = useRef<AbortController | null>(null)
const loadRevision = useRef(0)
const loadLive = useCallback(async () => {
if (!isLive) return false
loadController.current?.abort()
const controller = new AbortController()
loadController.current = controller
const revision = ++loadRevision.current
const current = () => !controller.signal.aborted && loadRevision.current === revision
setLoading(true)
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
let stateComplete = !canRead
const tasks = await Promise.allSettled([
canRead ? readContainersState(backendUrl, { signal }).then(result => {
stateComplete = result.servers.every(node => node.availability !== "error")
if (current()) {
setState(result); setReadAt(Date.now()); setLiveError(null)
if (result.servers.every(node => node.availability !== "error")) setCommandNeedsRead(false)
setLastStates(previous => Object.fromEntries(result.servers.map(node => [node.serverId, node.availability === "error" ? previous[node.serverId] ?? node : node])))
}
}).catch(error => { if (current()) setLiveError(userApiError(error)); throw error }) : Promise.resolve(),
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
const result = serverListSchema.parse(payload)
if (new Set(result.map(server => server.id)).size !== result.length) throw new Error("Каталог серверов содержит дубли ID")
if (current()) { setLiveServers(result.filter(server => server.enabled).map(mapBackendServer)); setServersReadAt(Date.now()); setServersError(null) }
}).catch(error => { if (current()) setServersError(userApiError(error)); throw error }) : Promise.resolve(),
])
if (current()) setLoading(false)
return current() && stateComplete && tasks.every(task => task.status === "fulfilled")
}, [isLive, backendUrl, canRead, canReadServers])
useEffect(() => { let cancelled = false; queueMicrotask(() => { if (!cancelled) void loadLive() }); return () => { cancelled = true; loadController.current?.abort() } }, [loadLive])
const displayContainers: ContainerView[] = useMemo(() => isLive ? Object.values(lastStates).flatMap(node => node.containers) : mockContainers, [isLive, lastStates])
const displayServers = isLive ? liveServers : mockServers.filter(server => server.enabled)
const effectiveServerId = selectedServerId
const relevantStates = state?.servers.filter(node => selectedServerId === ALL_SERVERS_ID || String(node.serverId) === selectedServerId)
const known = !isLive || !!state && (selectedServerId === ALL_SERVERS_ID || !!relevantStates?.length) && relevantStates!.every(node => node.availability !== "error")
const stale = isLive && !!liveError
const writeReady = !isLive || canWrite && !urlError && !commandNeedsRead && !loading && !stale && !!serversReadAt && !serversError
const nodeFresh = (serverId: string) => !isLive || writeReady && state?.servers.some(node => String(node.serverId) === serverId && node.availability === "available" && displayServers.some(server => server.id === serverId))
const scoped = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
return displayContainers.filter((c) => c.serverId === effectiveServerId)
}, [displayContainers, effectiveServerId])
const serverById = useMemo(
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers],
)
const railItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status ?? "unknown",
type: s.type,
enabled: s.enabled,
meta: isLive && (!state || !state.servers.some(node => String(node.serverId) === s.id && node.availability !== "error")) ? "—" : String(displayContainers.filter((c) => c.serverId === s.id).length),
}))
), [displayServers, displayContainers, state, isLive])
const filtered = useMemo(() => { const filtered = useMemo(() => {
return routerContainers.filter((c) => { return scoped.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false if (statusFilter !== "all" && c.status !== statusFilter) return false
if (!search) return true if (!search) return true
const q = search.toLowerCase() const q = search.toLowerCase()
return ( return (
c.name.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) ||
c.image.toLowerCase().includes(q) || c.image.toLowerCase().includes(q) ||
(serverFor(c.serverId)?.name.toLowerCase().includes(q) ?? false) (serverById[c.serverId]?.name.toLowerCase().includes(q) ?? false)
) )
}) })
}, [search, statusFilter]) }, [search, statusFilter, scoped, serverById])
const running = routerContainers.filter((c) => c.status === "running").length const running = scoped.filter((c) => c.status === "running").length
const stopped = routerContainers.filter((c) => c.status === "stopped").length const stopped = scoped.filter((c) => c.status === "stopped").length
const errors = routerContainers.filter((c) => c.status === "error").length const errors = scoped.filter((c) => c.status === "error").length
const statusKnown = known && scoped.every(container => container.status !== "unknown")
const currentDeleteTarget = deleteTarget ? displayContainers.find(row => row.id === deleteTarget.id && row.rosId === deleteTarget.rosId && row.serverId === deleteTarget.serverId) : undefined
const deleteStopped = currentDeleteTarget?.status === "stopped"
async function mutate(c: ContainerView, action: ContainerAction) {
if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId) || mutationLock.current || !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId)) return
if (action === "remove" && !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId && row.status === "stopped")) return
mutationLock.current = true
const controller = new AbortController()
mutationController.current = controller
const current = () => alive.current && !controller.signal.aborted
setBusyId(c.id); setOperationError(null); setOperationNotice(null)
try {
await mutateContainer(backendUrl, c.serverId, c.rosId, action, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
if (!current()) return
const labels = { start: "запуск", stop: "остановка", restart: "перезапуск", remove: "удаление" }
const message = c.name + ": API подтвердил команду «" + labels[action] + "». Состояние проверяется повторным чтением."
setOperationNotice(message)
if (action === "remove") {
setLastStates(previous => Object.fromEntries(Object.entries(previous).map(([id, node]) => [id, { ...node, containers: node.containers.filter(row => row.id !== c.id) }])))
setDeleteTarget(null)
}
toast.success("Команда подтверждена API.")
const refreshed = await loadLive()
if (current() && !refreshed) setOperationNotice(message + " Обновление каталогов не удалось; повторите чтение.")
} catch (error) {
if (!current()) return
const uncertain = !(error instanceof ApiClientError && error.status >= 400 && error.status < 500)
setCommandNeedsRead(uncertain)
setOperationError(c.name + ": " + userApiError(error) + (uncertain ? " Результат команды не подтверждён; она могла выполниться частично. Обновите каталог перед новым действием." : " Команда отклонена API."))
} finally {
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
if (alive.current) setBusyId(null)
}
}
return ( return (
<div className="flex flex-col h-full"> <>
<PageHeader <ServerRailLayout
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]} items={railItems}
actions={ selectedId={effectiveServerId}
<Button size="sm"> onSelect={setSelectedServerId}
<BoxIcon className="size-4" />Новый контейнер showAll
</Button> preserveSelection
} allCount={displayServers.length}
/> loading={isLive && loading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || loading || !canRead && !canReadServers || !!busyId}
>
<RefreshCwIcon aria-hidden="true" className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button size="sm" disabled title="Создание контейнеров ещё не подключено">
<BoxIcon aria-hidden="true" className="size-4" />Новый контейнер
</Button>
</>
}
/>
}
>
<div className="flex flex-col min-w-0 gap-5 [overflow-wrap:anywhere]">
<div className="flex-1 overflow-y-auto p-6"> {urlError && <Alert role="alert" aria-label="Контекст контейнеров" variant="warning"><AlertDescription>{urlError}<Button type="button" variant="outline" onClick={() => writeContext({server:null,status:null,q:null})}>Сбросить контекст</Button></AlertDescription></Alert>}
<div className="flex flex-col gap-5"> {operationNotice && <p role="status" className="text-sm [overflow-wrap:anywhere]">{operationNotice}</p>}
{operationError && !deleteTarget && <Alert role="alert" aria-label="Команда контейнера" variant="warning"><AlertDescription>{operationError}</AlertDescription></Alert>}
{isLive && !canRead && <p role="status">Для каталога контейнеров требуется mm:dashboard:read.</p>}
{isLive && !canReadServers && <p role="status">Для каталога серверов требуется mm:servers:read. Команды недоступны.</p>}
{isLive && loading && !state && <p role="status">Загрузка контейнеров…</p>}
{isLive && readAt && <p role="status" className="text-xs text-muted-foreground">Каталог контейнеров получен: {new Date(readAt).toLocaleTimeString("ru-RU")}</p>}
{isLive && serversReadAt && <p role="status" className="text-xs text-muted-foreground">Серверы получены: {new Date(serversReadAt).toLocaleTimeString("ru-RU")}</p>}
{isLive && liveError && <Alert variant="warning" role="alert" aria-label="Каталог контейнеров"><AlertDescription>{state ? "Последние данные сохранены." : "Контейнеры пока неизвестны."} {liveError}</AlertDescription></Alert>}
{isLive && serversError && <Alert variant="warning" role="alert" aria-label="Серверы контейнеров"><AlertDescription>{serversReadAt ? "Последний каталог сохранён." : "Каталог серверов пока неизвестен."} {serversError}</AlertDescription></Alert>}
{isLive && relevantStates?.map(node => node.availability === "error" || node.failures.length ? <Alert key={node.serverId} variant="warning" role="alert" aria-label={"Контейнеры сервера " + node.serverId}><AlertDescription>Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: {node.failures.map(f => f.resource + ": " + f.error).join("; ")}. {node.availability === "available" ? "Контейнеры получены; настройки загружены частично." : lastStates[node.serverId]?.containers.length ? "Последние контейнеры сохранены." : "Список неизвестен."}</AlertDescription></Alert> : node.availability === "unsupported" ? <p key={node.serverId} role="status">Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: пакет container недоступен.</p> : null)}
{isLive && selectedServerId !== ALL_SERVERS_ID && !displayServers.some(server => server.id === selectedServerId) && <p role="alert">Выбранный сервер недоступен в каталоге. Выберите другое устройство; фильтр сохранён.</p>}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
</span>
)}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка контейнеров" aria-label="Сводка контейнеров"
@@ -292,97 +469,100 @@ export default function ContainersPage() {
{ {
id: "all", id: "all",
label: "Всего", label: "Всего",
value: routerContainers.length, value: known ? scoped.length : "—",
icon: <BoxIcon className="size-4" />, icon: <BoxIcon aria-hidden="true" className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "running", id: "running",
label: "Running", label: "Работает",
value: running, value: statusKnown ? running : "—",
icon: <PlayIcon className="size-4" />, icon: <PlayIcon aria-hidden="true" className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "stopped", id: "stopped",
label: "Stopped", label: "Остановлен",
value: stopped, value: statusKnown ? stopped : "—",
icon: <StopCircleIcon className="size-4" />, icon: <StopCircleIcon aria-hidden="true" className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "errors", id: "errors",
label: "Ошибок", label: "Ошибок",
value: errors, value: statusKnown ? errors : "—",
icon: <AlertCircleIcon className="size-4" />, icon: <AlertCircleIcon aria-hidden="true" className="size-4" />,
iconClassName: "text-destructive", iconClassName: "text-destructive",
variant: errors > 0 ? "destructive" : "default", variant: errors > 0 ? "destructive" : "default",
}, },
]} ]}
/> />
{/* Info banner */} <div className="flex items-start gap-3 rounded-lg bg-info/5 border border-info/20 px-4 py-3 text-sm">
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm"> <BoxIcon aria-hidden="true" className="size-5 text-info shrink-0 mt-0.5" />
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
<div> <div>
<p className="font-medium text-violet-600 dark:text-violet-400">Контейнеры доступны с RouterOS 7.4+</p> <p className="font-medium text-foreground">Контейнеры доступны с RouterOS 7.4+</p>
<p className="text-muted-foreground text-xs mt-0.5"> <p className="text-muted-foreground text-xs mt-0.5">
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>. Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
Интерфейсы veth создаются автоматически. Сеть veth настраивается на устройстве. Создание контейнеров в интерфейсе пока недоступно.
</p> </p>
</div> </div>
</div> </div>
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]"> <div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-0 w-full sm:w-auto">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" /> <SearchIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
<input <input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm" className="flex-1 min-w-0 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, образу, серверу…" aria-label="Поиск контейнеров" placeholder="Поиск по имени, образу, серверу…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div> </div>
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5"> <div role="group" aria-label="Статус контейнеров" className="flex flex-wrap items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{(["all", "running", "stopped", "error"] as const).map((s) => ( {(["all", "running", "stopped", "error", "unknown"] as const).map((s) => (
<button key={s} <button key={s}
onClick={() => setStatusFilter(s)} type="button" aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}
className={cn( className={cn(
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors", "px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
statusFilter === s statusFilter === s
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
)}> )}>
{s === "all" ? "Все" : s === "running" ? "Running" : s === "stopped" ? "Stopped" : "Error"} {s === "all" ? "Все" : s === "running" ? "Работает" : s === "stopped" ? "Остановлен" : s === "unknown" ? "Неизвестно" : "Ошибка"}
</button> </button>
))} ))}
</div> </div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span> <span className="text-sm text-muted-foreground ml-auto">{known ? filtered.length : "—"} контейнеров</span>
</div> </div>
{/* Grid */} {filtered.length === 0 ? (!known ? <p role="status">Список контейнеров пока неизвестен.</p> : (
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground"> <div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<BoxIcon className="size-10 mb-3 opacity-20" /> <BoxIcon aria-hidden="true" className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">Контейнеры не найдены</p> <p className="text-sm font-medium">Контейнеры не найдены</p>
<p className="text-xs mt-1">Добавьте первый контейнер или измените фильтр</p> <p className="text-xs mt-1">{scoped.length ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}</p>
</div> </div>
) : ( )) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((c) => ( {filtered.map((c) => (
<ContainerCard <ContainerCard
key={c.id} key={c.id}
container={c} container={c}
onExport={() => setExportContainer(c)} server={serverById[c.serverId]}
live={isLive && !!nodeFresh(c.serverId)}
busy={!!busyId}
onExport={() => { const snapshot = lastStates[Number(c.serverId)]; setExportContainer({ container: c, collectedAt: snapshot?.collectedAt ?? null, failures: snapshot?.failures ?? [], source: isLive ? "live" : "mock" }) }}
onStart={() => { void mutate(c, "start") }}
onStop={() => { void mutate(c, "stop") }}
onRestart={() => { void mutate(c, "restart") }}
onRemove={() => { deleteReturnLabel.current = "Действия контейнера " + c.name; setOperationError(null); setDeleteTarget(c) }}
/> />
))} ))}
</div> </div>
)} )}
{/* RouterOS reference */}
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4"> <OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[ {[
@@ -433,7 +613,7 @@ export default function ContainersPage() {
].map((b) => ( ].map((b) => (
<div key={b.title}> <div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p> <p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre"> <pre className="bg-muted rounded-md p-2.5 text-foreground text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
{b.lines.join("\n")} {b.lines.join("\n")}
</pre> </pre>
</div> </div>
@@ -442,13 +622,20 @@ export default function ContainersPage() {
</OpsPanel> </OpsPanel>
</div> </div>
</div> </ServerRailLayout>
<ExportSheet {deleteTarget && <AlertDialog open onOpenChange={open => { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}>
open={!!exportContainer} <AlertDialogContent>
container={exportContainer} <AlertDialogHeader><AlertDialogTitle>Удалить контейнер?</AlertDialogTitle><AlertDialogDescription>{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. {deleteStopped ? "Будет удалён остановленный контейнер. Дополнительная очистка файлов не выполняется." : "Сначала закройте это окно, остановите контейнер через меню действий и обновите каталог. Удаление доступно только после подтверждённой остановки."}</AlertDialogDescription></AlertDialogHeader>
onClose={() => setExportContainer(null)} {operationError && <p role="alert" aria-label="Удаление контейнера" className="text-sm text-destructive [overflow-wrap:anywhere]">{operationError}</p>}
/> <AlertDialogFooter>
</div> {(commandNeedsRead || !deleteStopped) && <Button type="button" variant="outline" disabled={loading || !!busyId} onClick={() => { void loadLive() }}>Обновить каталог</Button>}
<AlertDialogCancel disabled={!!busyId}>Отмена</AlertDialogCancel>
<AlertDialogAction type="button" variant="destructive" disabled={!!busyId || !deleteStopped || !nodeFresh(deleteTarget.serverId)} aria-busy={!!busyId} onClick={() => { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>}
<ExportSheet selection={exportContainer} onClose={() => setExportContainer(null)} />
</>
) )
} }
+206 -198
View File
@@ -1,5 +1,7 @@
"use client" "use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import Link from "next/link" import Link from "next/link"
import { import {
ActivityIcon, ActivityIcon,
@@ -24,9 +26,9 @@ import { DashboardEventsTimeline } from "@/components/dashboard/dashboard-events
import { InternetPathPanel } from "@/components/dashboard/internet-path-panel" import { InternetPathPanel } from "@/components/dashboard/internet-path-panel"
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid" import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
import { EmptyState } from "@/components/empty-state" import { EmptyState } from "@/components/empty-state"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert" import { DashboardDataStatus } from "@/components/dashboard/dashboard-data-status"
import { Badge } from "@/components/reui/badge" import { Badge } from "@/components/reui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { fmtRate } from "@/lib/fmt-rate" import { fmtRate } from "@/lib/fmt-rate"
import { useDashboardLive, type AttentionRow } from "@/hooks/use-dashboard-live" import { useDashboardLive, type AttentionRow } from "@/hooks/use-dashboard-live"
@@ -36,31 +38,44 @@ function fmtIntRu(n: number): string {
} }
function AttentionRows({ rows }: { rows: AttentionRow[] }) { function AttentionRows({ rows }: { rows: AttentionRow[] }) {
const [expanded,setExpanded]=useState(false)
const [visible,setVisible]=useState(55)
function renderRow(row: AttentionRow) {
return (
<Link
key={row.id}
href={row.href}
className="hover:bg-muted/40 focus-visible:ring-ring flex min-w-0 items-start justify-between gap-2 rounded-md px-1 py-1.5 focus-visible:ring-2"
>
<span className="min-w-0 wrap-anywhere text-sm font-medium">{row.title}</span>
<Badge
size="sm"
variant={row.tone === "warning" ? "warning-light" : "destructive-light"}
className="shrink-0"
>
{row.hint}
</Badge>
</Link>
)
}
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{rows.map((row) => ( {rows.slice(0, 5).map(renderRow)}
<Link {rows.length > 5 && (
key={row.id} <details onToggle={event=>{setExpanded(event.currentTarget.open);if(!event.currentTarget.open)setVisible(55)}}>
href={row.href} <summary className="cursor-pointer rounded-md px-1 py-2 text-xs text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring">Ещё {rows.length - 5}</summary>
className="hover:bg-muted/40 flex min-w-0 items-center justify-between gap-2 rounded-md px-1 py-1.5" {expanded && rows.slice(5,visible).map(renderRow)}
> {expanded && rows.length>visible && <Button type="button" variant="ghost" size="sm" onClick={()=>setVisible(count=>Math.min(count+50,rows.length))}>Показать ещё {Math.min(50,rows.length-visible)}</Button>}
<span className="truncate text-sm font-medium">{row.title}</span> </details>
<Badge )}
size="sm"
variant={row.tone === "warning" ? "warning-light" : "destructive-light"}
className="shrink-0"
>
{row.hint}
</Badge>
</Link>
))}
</div> </div>
) )
} }
export default function DashboardPage() { export default function DashboardPage() {
const dash = useDashboardLive() const dash = useDashboardLive()
const offline = Math.max(0, dash.totalServers - dash.onlineCount) const offline = dash.offlineCount
const health = dash.probeDown + dash.probeWarn const health = dash.probeDown + dash.probeWarn
return ( return (
@@ -69,17 +84,7 @@ export default function DashboardPage() {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5"> <div className="flex flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{dash.error && !dash.loading ? ( {dash.isLive && <DashboardDataStatus sources={dash.sources} refreshing={dash.refreshing} onRefresh={dash.retry} />}
<Alert variant="destructive">
<AlertTitle>Не удалось обновить дашборд</AlertTitle>
<AlertDescription className="flex flex-wrap items-center gap-2">
<span>{dash.error}</span>
<Button variant="outline" size="sm" onClick={dash.retry}>
Повторить
</Button>
</AlertDescription>
</Alert>
) : null}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка дашборда" aria-label="Сводка дашборда"
@@ -87,25 +92,25 @@ export default function DashboardPage() {
items={[ items={[
{ {
id: "fleet", id: "fleet",
label: "Флот", label: "Серверы онлайн",
value: `${dash.onlineCount} / ${dash.totalServers}`, value: dash.availability.servers ? `${dash.onlineCount} / ${dash.totalServers}` : "—",
hint: !dash.isLive hint: !dash.availability.servers ? "нет данных" : !dash.isLive
? "демо" ? "демо"
: offline > 0 : offline > 0 || dash.unknownServerCount > 0 || dash.degradedServerCount > 0
? `${offline} offline` ? [offline > 0 ? `${offline} не в сети` : null,dash.unknownServerCount > 0 ? `Статус неизвестен: ${dash.unknownServerCount}` : null,dash.degradedServerCount > 0 ? `Ухудшение: ${dash.degradedServerCount}` : null].filter(Boolean).join(" · ")
: dash.totalServers > 0 : dash.totalServers > 0
? "все online" ? "все в сети"
: "нет узлов", : "нет серверов",
href: "/servers", href: "/servers",
icon: <ServerIcon />, icon: <ServerIcon />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
variant: offline > 0 ? "warning" : "default", variant: offline > 0 || dash.unknownServerCount > 0 || dash.degradedServerCount > 0 ? "warning" : "default",
}, },
{ {
id: "overlay", id: "overlay",
label: "Оверлей", label: "Туннели работают",
value: String(dash.overlay.up), value: dash.availability.overlay ? String(dash.overlay.up) : "—",
hint: dash.overlay.total > 0 ? `${dash.overlay.down} down` : "нет туннелей", hint: !dash.availability.overlay ? "не все источники доступны" : !dash.reliable.overlay ? "Последний успешный снимок; не все источники обновлены" : dash.overlay.total > 0 ? `Не работают: ${dash.overlay.down}` : "нет туннелей",
href: "/gre", href: "/gre",
icon: <CableIcon />, icon: <CableIcon />,
iconClassName: "text-info", iconClassName: "text-info",
@@ -113,18 +118,18 @@ export default function DashboardPage() {
}, },
{ {
id: "bgp", id: "bgp",
label: "BGP Rx", label: "Префиксы BGP",
value: dash.bgp ? fmtIntRu(dash.bgp.prefixSum) : "—", value: dash.bgp?.prefixSum != null ? fmtIntRu(dash.bgp.prefixSum) : "—",
hint: dash.bgp ? `${dash.bgp.establishedCount} Established` : "нет данных", hint: dash.bgp ? `Сессий установлено: ${dash.bgp.establishedCount ?? "неизвестно"}${dash.bgp.prefixSum === null ? " · префиксы неизвестны" : ""}` : "нет данных",
href: "/bgp", href: "/bgp",
icon: <GitMergeIcon />, icon: <GitMergeIcon />,
iconClassName: "text-primary", iconClassName: "text-primary",
}, },
{ {
id: "health", id: "health",
label: "Здоровье", label: "Проверки с проблемами",
value: String(health), value: dash.availability.probes && dash.unknownProbeCount===0 ? String(health) : "—",
hint: `${dash.probeDown} down · ${dash.probeWarn} warn`, hint: dash.availability.probes ? `${dash.probeDown} ошибок · ${dash.probeWarn} предупреждений${dash.unknownProbeCount>0?` · неизвестны: ${dash.unknownProbeCount}`:""}` : "нет данных",
href: "/uptime", href: "/uptime",
icon: <BellIcon />, icon: <BellIcon />,
iconClassName: health > 0 ? "text-destructive" : "text-muted-foreground", iconClassName: health > 0 ? "text-destructive" : "text-muted-foreground",
@@ -133,13 +138,165 @@ export default function DashboardPage() {
]} ]}
/> />
<section aria-labelledby="dashboard-attention-title" className="flex min-w-0 flex-col gap-2">
<h2 id="dashboard-attention-title" className="text-sm font-semibold">Требует внимания</h2>
<AttentionQueue
className="lg:grid-cols-3"
columns={[
{
id: "nodes",
title: "Серверы",
icon: ServerIcon,
count: dash.attentionServers.length,
countVariant: "warning-light",
emptyTitle: dash.reliable.servers ? (dash.totalServers > 0 ? "Серверы без проблем" : "Нет серверов") : "Состояние не подтверждено",
emptyDescription: dash.reliable.servers ? "Нет включённых серверов с ошибками доступности." : "Проверьте состояние источников выше.",
children: <AttentionRows rows={dash.attentionServers} />,
},
{
id: "overlay",
title: "Туннели",
icon: CableIcon,
iconClassName: "text-info",
count: dash.attentionOverlay.length,
countVariant: "destructive-light",
emptyTitle: dash.reliable.overlay ? (dash.overlay.total > 0 ? "Туннели работают" : "Нет туннелей") : "Состояние не подтверждено",
emptyDescription: dash.reliable.overlay ? "В GRE, WireGuard и VXLAN проблем не обнаружено." : "Проверьте состояние источников выше.",
children: <AttentionRows rows={dash.attentionOverlay} />,
},
{
id: "probes",
title: "Проверки",
icon: HeartPulseIcon,
iconClassName: "text-destructive",
count: dash.attentionProbes.length,
countVariant: "destructive-light",
emptyTitle: dash.reliable.probes ? (dash.probeCount > 0 ? "Проверки без ошибок" : "Нет активных проверок") : "Состояние не подтверждено",
emptyDescription: dash.reliable.probes ? "Нет ошибок и предупреждений активных проверок." : "Проверьте состояние источников выше.",
children: <AttentionRows rows={dash.attentionProbes} />,
},
]}
/>
</section>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_1fr]">
<OpsPanel
title="Задержка до серверов"
description={
dash.latency.kind === "mock" || dash.latency.kind === "live"
? dash.latency.subtitle
: dash.latency.kind === "loading"
? "Загрузка…"
: "Нет серии RTT"
}
contentClassName="px-3 pb-3"
>
{dash.latency.kind === "loading" && (
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
)}
{dash.latency.kind === "empty" && (
<p className="text-muted-foreground px-4 py-10 text-center text-sm">{dash.latency.message}</p>
)}
{(dash.latency.kind === "mock" || dash.latency.kind === "live") && (
<LatencyChart series={dash.latency.series} labels={dash.latency.labels} />
)}
</OpsPanel>
<OpsPanel
title="Последние события"
description="Система и BGP"
headerRight={
<Link href="/alerts" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-7 text-xs")}>
Все →
</Link>
}
>
<DashboardEventsTimeline
events={dash.recentEvents}
loading={dash.eventsLoading}
error={dash.eventsError}
/>
</OpsPanel>
</div>
<div>
<OpsPanel
title="Пропускная способность"
description={dash.traffic?.demo ? "Суммарный RX / TX · демо" : "Суммарная история RX / TX · 1 ч; значения — последний измеренный снимок"}
headerRight={<Link href="/traffic" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>Трафик →</Link>}
contentClassName="px-3 pb-3"
>
{dash.loading && !dash.traffic ? (
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
) : !dash.traffic ? (
<p className="text-muted-foreground px-4 py-10 text-center text-sm">
Нет данных трафика за последний час.{" "}
<Link href="/traffic" className="underline underline-offset-2">
Открыть трафик
</Link>
</p>
) : (
<>
<div className="flex flex-wrap items-center gap-3 px-2 py-3 text-xs">
{dash.traffic.demo ? <Badge variant="outline" size="sm">демо</Badge> : !dash.traffic.observationsKnown ? <Badge variant="outline" size="sm">Измерения не подтверждены API</Badge> : null}
<span className="tabular-nums">RX {dash.traffic.rxNow===null?"—":fmtRate(dash.traffic.rxNow)}</span>
<span className="tabular-nums">TX {dash.traffic.txNow===null?"—":fmtRate(dash.traffic.txNow)}</span>
</div>
<TrafficRxTxChart rx={dash.traffic.rx} tx={dash.traffic.tx} range="1h" embedded />
</>
)}
</OpsPanel>
</div>
<InternetPathPanel
model={dash.internetPath}
loading={dash.internetPathLoading}
error={dash.isLive ? dash.internetPathError : null}
/>
<OpsPanel
title="Активные пробы"
description={
<span className={dash.error && dash.isLive ? "text-destructive" : undefined}>{dash.probesSubtitle}</span>
}
headerRight={
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
Мониторинг →
</Link>
}
contentClassName="px-0 pb-0"
>
{dash.activeProbes.length === 0 && !dash.probesLoading ? (
<EmptyState
className="py-10"
title={dash.availability.probes ? "Нет проб на дашборде" : "Данные проб недоступны"}
description={dash.availability.probes ? "Включите пробу и отметьте ★ на странице мониторинга." : "Повторите обновление данных."}
action={
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
Открыть мониторинг
</Link>
}
/>
) : (
<DashboardActiveProbesDataGrid
probes={dash.activeProbes}
catalog={dash.servers}
isLoading={dash.probesLoading}
emptyDescription="Нет проб с звездой на дашборде."
/>
)}
</OpsPanel>
<QuickActionGrid <QuickActionGrid
description="Разделы операционного контура" title="Быстрые переходы"
description="Другие разделы управления сетью"
actions={[ actions={[
{ {
id: "traffic", id: "traffic",
title: "Трафик", title: "Трафик",
description: "RX/TX флота и IPFIX", description: "Приём, передача и потоки IPFIX",
href: "/traffic", href: "/traffic",
icon: <ActivityIcon />, icon: <ActivityIcon />,
iconClassName: "text-info", iconClassName: "text-info",
@@ -187,155 +344,6 @@ export default function DashboardPage() {
]} ]}
/> />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_1fr]">
<OpsPanel
title="Задержка до серверов"
description={
dash.latency.kind === "mock" || dash.latency.kind === "live"
? dash.latency.subtitle
: dash.latency.kind === "loading"
? "Загрузка…"
: "Нет серии RTT"
}
contentClassName="px-3 pb-3"
>
{dash.latency.kind === "loading" && (
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
)}
{dash.latency.kind === "empty" && (
<p className="text-muted-foreground px-4 py-10 text-center text-sm">{dash.latency.message}</p>
)}
{(dash.latency.kind === "mock" || dash.latency.kind === "live") && (
<LatencyChart series={dash.latency.series} labels={dash.latency.labels} />
)}
</OpsPanel>
<OpsPanel
title="Последние события"
description="Система и BGP"
headerRight={
<Link href="/alerts" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-7 text-xs")}>
Все →
</Link>
}
>
<DashboardEventsTimeline
events={dash.recentEvents}
loading={dash.eventsLoading}
error={dash.eventsError}
/>
</OpsPanel>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[3fr_2fr]">
<OpsPanel
title="Пропускная способность"
description={dash.traffic?.demo ? "Суммарный RX / TX · демо" : "Суммарный RX / TX флота · 1 ч"}
headerRight={
dash.traffic ? (
<div className="flex items-center gap-3 text-xs">
{dash.traffic.demo ? <Badge variant="outline" size="sm">демо</Badge> : null}
<span className="tabular-nums">RX {fmtRate(dash.traffic.rxNow)}</span>
<span className="tabular-nums">TX {fmtRate(dash.traffic.txNow)}</span>
<Link href="/traffic" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
Трафик →
</Link>
</div>
) : null
}
contentClassName="px-3 pb-3"
>
{dash.loading && !dash.traffic ? (
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
) : !dash.traffic ? (
<p className="text-muted-foreground px-4 py-10 text-center text-sm">
Нет данных трафика за последний час.{" "}
<Link href="/traffic" className="underline underline-offset-2">
Открыть трафик
</Link>
</p>
) : (
<TrafficRxTxChart rx={dash.traffic.rx} tx={dash.traffic.tx} range="1h" embedded />
)}
</OpsPanel>
<AttentionQueue
className="@3xl:grid-cols-1"
columns={[
{
id: "nodes",
title: "Узлы",
icon: ServerIcon,
count: dash.attentionServers.length,
countVariant: "warning-light",
emptyTitle: "Все online",
emptyDescription: "Нет офлайн или degraded узлов.",
children: <AttentionRows rows={dash.attentionServers} />,
},
{
id: "overlay",
title: "Оверлей",
icon: CableIcon,
iconClassName: "text-info",
count: dash.attentionOverlay.length,
countVariant: "destructive-light",
emptyTitle: "Туннели up",
emptyDescription: "GRE и WireGuard в норме.",
children: <AttentionRows rows={dash.attentionOverlay} />,
},
{
id: "probes",
title: "Пробы",
icon: HeartPulseIcon,
iconClassName: "text-destructive",
count: dash.attentionProbes.length,
countVariant: "destructive-light",
emptyTitle: "Пробы up",
emptyDescription: "Нет down или warn.",
children: <AttentionRows rows={dash.attentionProbes} />,
},
]}
/>
</div>
<InternetPathPanel
model={dash.internetPath}
loading={dash.internetPathLoading}
error={dash.isLive ? dash.internetPathError : null}
/>
<OpsPanel
title="Активные пробы"
description={
<span className={dash.error && dash.isLive ? "text-destructive" : undefined}>{dash.probesSubtitle}</span>
}
headerRight={
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
Мониторинг →
</Link>
}
contentClassName="px-0 pb-0"
>
{dash.activeProbes.length === 0 && !dash.probesLoading ? (
<EmptyState
className="py-10"
title="Нет проб на дашборде"
description="Включите пробу и отметьте ★ на странице мониторинга."
action={
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
Открыть мониторинг
</Link>
}
/>
) : (
<DashboardActiveProbesDataGrid
probes={dash.activeProbes}
catalog={dash.servers}
isLoading={dash.probesLoading}
emptyDescription="Нет проб с звездой на дашборде."
/>
)}
</OpsPanel>
</div> </div>
</div> </div>
</div> </div>
+307 -434
View File
@@ -1,9 +1,8 @@
"use client" "use client"
import Link from "next/link" import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/reui/badge" import { Badge } from "@/components/reui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
@@ -21,12 +20,15 @@ import {
SCHEDULER_JOB_DESCRIPTIONS, SCHEDULER_JOB_DESCRIPTIONS,
SCHEDULER_JOB_KEYS, SCHEDULER_JOB_KEYS,
SCHEDULER_JOB_LABELS, SCHEDULER_JOB_LABELS,
type CollectorSettingsDto,
type SchedulerJobStatusDto, type SchedulerJobStatusDto,
type SchedulerStatusDto,
type SchedulerRunRowDto, type SchedulerRunRowDto,
type UptimeSettingsDto,
} from "@/lib/scheduler-settings" } from "@/lib/scheduler-settings"
import { requestJson, ApiClientError } from "@/shared/api/http-client" import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings, putTrafficCollectorSettings, putServersApiPingSettings, putUptimeCollectorSettings, putInternetPathSettings } from "@/shared/api/collector-settings"
import { can } from "@/lib/auth"
import { getCertificateRenewSettings, putCertificateRenewSettings } from "@/shared/api/certificates"
import { getSchedulerStatus, listSchedulerRuns, runSchedulerJobNow, refreshScheduler } from "@/shared/api/scheduler"
import { ApiClientError } from "@/shared/api/http-client"
import { import {
parseSchedulerRunSnapshot, parseSchedulerRunSnapshot,
type AlertEngineRunSnapshot, type AlertEngineRunSnapshot,
@@ -56,6 +58,7 @@ import {
} from "@/components/data-grids/data-collection-scheduler-data-grid" } from "@/components/data-grids/data-collection-scheduler-data-grid"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel" import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
import { LogsCollectorSettings } from "@/components/logs/logs-collector-settings"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
AlertCircleIcon, AlertCircleIcon,
@@ -68,33 +71,13 @@ import {
XCircleIcon, XCircleIcon,
} from "lucide-react" } from "lucide-react"
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(backendUrl, path, init)
}
}
function extractApiError(reason: unknown): string { function extractApiError(reason: unknown): string {
if (reason instanceof ApiClientError) return reason.message if (reason instanceof ApiClientError) return reason.message
if (reason instanceof Error && reason.name === "ZodError") return "Настройки не подтверждены: получен некорректный ответ сервера"
if (reason instanceof Error) return reason.message if (reason instanceof Error) return reason.message
return String(reason) return String(reason)
} }
function readSettled<T>(result: PromiseSettledResult<T>): T | null {
return result.status === "fulfilled" ? result.value : null
}
function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels: string[]): string[] {
const errors: string[] = []
for (let i = 0; i < results.length; i += 1) {
const result = results[i]
if (result.status === "rejected") {
errors.push(`${labels[i]}: ${extractApiError(result.reason)}`)
}
}
return errors
}
function fmtMs(ms: number): string { function fmtMs(ms: number): string {
if (ms < 1000) return `${ms} мс` if (ms < 1000) return `${ms} мс`
const s = ms / 1000 const s = ms / 1000
@@ -102,6 +85,7 @@ function fmtMs(ms: number): string {
} }
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) { function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "router_logs") return <div className="flex flex-col gap-2 text-sm">{snap.skipped && <p>Сбор логов выключен или приостановлен.</p>}{snap.servers.map(server => <p key={server.serverId}>{server.name}: +{server.added} событий{server.gap ? " · Возможен разрыв истории" : ""}{server.error ? " · " + server.error : ""}</p>)}</div>
if (snap.job === "traffic") { if (snap.job === "traffic") {
const t = snap as TrafficRunSnapshot const t = snap as TrafficRunSnapshot
return ( return (
@@ -432,7 +416,11 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
function RunRowDetail({ r }: { r: SchedulerRunRowDto }) { function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
const jobTitle = SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey const jobTitle = SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—" const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson]) const snapshot = useMemo(() => {
const parsed = parseSchedulerRunSnapshot(r.resultJson)
return parsed?.job === r.jobKey ? parsed : null
}, [r.resultJson, r.jobKey])
const hasSnapshot = r.resultJson != null && (typeof r.resultJson !== "string" || r.resultJson.trim() !== "")
return ( return (
<div className="flex flex-col gap-4 text-sm"> <div className="flex flex-col gap-4 text-sm">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3"> <dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
@@ -501,6 +489,12 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
{snapshot ? ( {snapshot ? (
<SnapshotTables snap={snapshot} /> <SnapshotTables snap={snapshot} />
) : hasSnapshot ? (
<Alert variant="warning">
<AlertCircleIcon />
<AlertTitle>Снимок недоступен</AlertTitle>
<AlertDescription>Сохранённые результаты имеют неподдерживаемый формат или относятся к другой задаче.</AlertDescription>
</Alert>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Снимок отсутствует (запись до обновления бекенда или задача без детализации). Снимок отсутствует (запись до обновления бекенда или задача без детализации).
@@ -511,47 +505,34 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
) )
} }
type SchedulerToggleOverrides = { const SETTINGS_LABELS = { traffic: "Трафик", rest: "Серверы REST API", uptime: "Uptime", internet: "Internet path", cert: "Сертификаты" } as const
trafficEnabled?: boolean const SETTINGS_PERMISSIONS = { traffic: "mm:traffic:write", rest: "mm:servers:write", uptime: "mm:uptime:write", internet: "mm:network:write", cert: "mm:certificates:write" } as const
serversApiEnabled?: boolean type SettingsKey = keyof typeof SETTINGS_LABELS
resourcesEnabled?: boolean type SettingsRead = { known: boolean; error: string | null; receivedAt: string | null }
pingEnabled?: boolean const INITIAL_SETTINGS: Record<SettingsKey, SettingsRead> = Object.fromEntries(Object.keys(SETTINGS_LABELS).map(key => [key, { known: false, error: null, receivedAt: null }])) as Record<SettingsKey, SettingsRead>
speedEnabled?: boolean
internetPathEnabled?: boolean
certRenewEnabled?: boolean
}
function applySchedulerJobsToDrafts(
jobs: SchedulerJobStatusDto[],
setters: {
setDraftTrafficEnabled: (value: boolean) => void
setDraftServersApiEnabled: (value: boolean) => void
setDraftResourcesEnabled: (value: boolean) => void
setDraftPingEnabled: (value: boolean) => void
setDraftSpeedEnabled: (value: boolean) => void
setDraftInternetPathEnabled: (value: boolean) => void
setDraftCertRenewEnabled: (value: boolean) => void
},
) {
const byKey = Object.fromEntries(jobs.map((job) => [job.jobKey, job])) as Record<string, SchedulerJobStatusDto>
if (byKey.traffic) setters.setDraftTrafficEnabled(!!byKey.traffic.enabled)
if (byKey.servers_rest_ping) setters.setDraftServersApiEnabled(!!byKey.servers_rest_ping.enabled)
if (byKey.uptime_resources) setters.setDraftResourcesEnabled(!!byKey.uptime_resources.enabled)
if (byKey.uptime_ping) setters.setDraftPingEnabled(!!byKey.uptime_ping.enabled)
if (byKey.uptime_speed) setters.setDraftSpeedEnabled(!!byKey.uptime_speed.enabled)
if (byKey.internet_path) setters.setDraftInternetPathEnabled(!!byKey.internet_path.enabled)
if (byKey.certificates_renew) setters.setDraftCertRenewEnabled(!!byKey.certificates_renew.enabled)
}
export default function DataCollectionPage() { export default function DataCollectionPage() {
const { mode, backendUrl, prefsHydrated } = useDataSource() const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = prefsHydrated && mode === "live" if (!prefsHydrated) return <p role="status" className="p-6 text-sm text-muted-foreground">Загрузка настроек источника…</p>
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) return <DataCollectionContent key={JSON.stringify([mode, backendUrl])} />
}
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null) function DataCollectionContent() {
const [serversApiCollector, setServersApiCollector] = useState<CollectorSettingsDto | null>(null) const { mode, backendUrl, prefsHydrated } = useDataSource()
const [uptimeCollector, setUptimeCollector] = useState<UptimeSettingsDto | null>(null) const isLive = prefsHydrated && mode === "live"
const [internetPathCollector, setInternetPathCollector] = useState<CollectorSettingsDto | null>(null)
const [trafficCollector, setTrafficCollector] = useState<Awaited<ReturnType<typeof getTrafficCollectorSettings>> | null>(null)
const [serversApiCollector, setServersApiCollector] = useState<Awaited<ReturnType<typeof getServersApiPingSettings>> | null>(null)
const [uptimeCollector, setUptimeCollector] = useState<Awaited<ReturnType<typeof getUptimeCollectorSettings>> | null>(null)
const [internetPathCollector, setInternetPathCollector] = useState<Awaited<ReturnType<typeof getInternetPathSettings>> | null>(null)
const [settingsReads, setSettingsReads] = useState(INITIAL_SETTINGS)
const dirtySettings = useRef(new Set<SettingsKey>())
const dirtyFields = useRef<Record<SettingsKey, Set<string>>>({ traffic: new Set(), rest: new Set(), uptime: new Set(), internet: new Set(), cert: new Set() })
const [dirtyCount, setDirtyCount] = useState(0)
const [saveMessage, setSaveMessage] = useState<string | null>(null)
const mutationLock = useRef(false)
const writable = (key: SettingsKey) => isLive && can(SETTINGS_PERMISSIONS[key]) && settingsReads[key].known && !settingsReads[key].error
const markDirty = (key: SettingsKey, field: string) => { dirtySettings.current.add(key); dirtyFields.current[key].add(field); setDirtyCount(Object.values(dirtyFields.current).reduce((count, fields) => count + fields.size, 0)) }
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30") const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14") const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300") const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300")
@@ -570,6 +551,12 @@ export default function DataCollectionPage() {
const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600") const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600")
const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30") const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30")
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([]) const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
const [schedulerStatus, setSchedulerStatus] = useState<SchedulerStatusDto | null>(null)
const [schedulerStatusError, setSchedulerStatusError] = useState<string | null>(null)
const [schedulerStatusReceivedAt, setSchedulerStatusReceivedAt] = useState<string | null>(null)
const [runsKnown, setRunsKnown] = useState(false)
const [runsError, setRunsError] = useState<string | null>(null)
const [runsReceivedAt, setRunsReceivedAt] = useState<string | null>(null)
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("") const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null) const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
const [schedulerSaveBusy, setSchedulerSaveBusy] = useState(false) const [schedulerSaveBusy, setSchedulerSaveBusy] = useState(false)
@@ -577,6 +564,13 @@ export default function DataCollectionPage() {
const [collectorError, setCollectorError] = useState<string | null>(null) const [collectorError, setCollectorError] = useState<string | null>(null)
const [openRunIds, setOpenRunIds] = useState<Set<string>>(() => new Set()) const [openRunIds, setOpenRunIds] = useState<Set<string>>(() => new Set())
const reader = useRef<{ active: boolean; revision: number; controller?: AbortController }>({ active: true, revision: 0 })
useEffect(() => {
reader.current.active = true
const state = reader.current
return () => { state.active = false; state.revision++; state.controller?.abort() }
}, [])
const toggleRunOpen = useCallback((id: string, open: boolean) => { const toggleRunOpen = useCallback((id: string, open: boolean) => {
setOpenRunIds((prev) => { setOpenRunIds((prev) => {
const next = new Set(prev) const next = new Set(prev)
@@ -584,265 +578,190 @@ export default function DataCollectionPage() {
else next.delete(id) else next.delete(id)
return next return next
}) })
}, []) }, [setOpenRunIds])
const schedulerJobsByKey = useMemo(() => { const schedulerJobsByKey = useMemo(() => {
const jobs = uptimeCollector?.scheduler?.jobs ?? [] const jobs = schedulerStatus?.jobs ?? []
return Object.fromEntries(jobs.map((j) => [j.jobKey, j])) as Record<string, SchedulerJobStatusDto> return Object.fromEntries(jobs.map((j) => [j.jobKey, j])) as Record<string, SchedulerJobStatusDto>
}, [uptimeCollector?.scheduler?.jobs]) }, [schedulerStatus?.jobs])
const loadCollectors = useCallback(async () => { const loadCollectors = useCallback(async () => {
if (!isLive) return if (!isLive || !reader.current.active || mutationLock.current) return
setCollectorError(null) const revision = ++reader.current.revision
reader.current.controller?.abort()
const controller = new AbortController(); reader.current.controller = controller
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
const current = () => reader.current.active && revision === reader.current.revision && !controller.signal.aborted
setPageBusy(true) setPageBusy(true)
try { try {
const runsQuery = const runsFilter = runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number]) ? runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number] : undefined
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number]) const runsRequest = listSchedulerRuns(backendUrl, { limit: 80, jobKey: runsFilter }, { signal }).then(data => {
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}` if (current()) { setSchedulerRuns(data.runs); setRunsKnown(true); setRunsError(null); setRunsReceivedAt(new Date().toISOString()) }
: "?limit=80" return data
const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes] = await Promise.allSettled([ }, error => { if (current()) setRunsError(extractApiError(error)); throw error })
apiFetch<CollectorSettingsDto>("/api/traffic/settings"), const statusRequest = getSchedulerStatus(backendUrl, { signal }).then(data => {
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"), if (current()) { setSchedulerStatus(data); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) }
apiFetch<UptimeSettingsDto>("/api/uptime/settings"), return data
apiFetch<CollectorSettingsDto>("/api/internet-path/settings"), }, error => { if (current()) setSchedulerStatusError(extractApiError(error)); throw error })
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"), function publish<T>(key: SettingsKey, request: Promise<T>, accept: (data: T, updateDraft: boolean) => void) {
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`), return request.then(data => {
if (current()) {
accept(data, !dirtySettings.current.has(key))
setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
}
}, error => {
if (current()) setSettingsReads(prev => ({ ...prev, [key]: { ...prev[key], error: extractApiError(error) } }))
})
}
await Promise.allSettled([
publish("traffic", getTrafficCollectorSettings(backendUrl, { signal }), (data, draft) => {
setTrafficCollector(data)
if (draft) { setTrafficIntervalDraft(String(data.intervalSec)); setTrafficRetentionDraft(String(data.retentionDays)); setDraftTrafficEnabled(data.enabled) }
}),
publish("rest", getServersApiPingSettings(backendUrl, { signal }), (data, draft) => {
setServersApiCollector(data)
if (draft) { setServersApiIntervalDraft(String(data.intervalSec)); setDraftServersApiEnabled(data.enabled) }
}),
publish("uptime", getUptimeCollectorSettings(backendUrl, { signal }), (data, draft) => {
setUptimeCollector(data)
if (draft) {
setUptimeResourceIntervalDraft(String(data.intervalSec)); setUptimeIntervalDraft(String(data.probeIntervalSec)); setUptimeSpeedIntervalDraft(String(data.speedIntervalSec)); setUptimeRetentionDraft(String(data.retentionDays))
setDraftResourcesEnabled(data.resourcesEnabled); setDraftPingEnabled(data.pingEnabled); setDraftSpeedEnabled(data.speedEnabled)
}
}),
publish("internet", getInternetPathSettings(backendUrl, { signal }), (data, draft) => {
setInternetPathCollector(data)
if (draft) { setInternetPathIntervalDraft(String(data.intervalSec)); setDraftInternetPathEnabled(data.enabled) }
}),
publish("cert", getCertificateRenewSettings(backendUrl, { signal }), (data, draft) => {
if (draft) { setCertRenewIntervalDraft(String(data.intervalSec)); setRenewBeforeDaysDraft(String(data.renewBeforeDays)); setDraftCertRenewEnabled(data.enabled) }
}),
runsRequest, statusRequest,
]) ])
const loadErrors = collectSettledErrors( } catch (e) {
[trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes], if (current()) setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"], } finally {
) if (current()) setPageBusy(false)
if (loadErrors.length > 0) { }
setCollectorError(loadErrors.join("; ")) }, [backendUrl, isLive, runFilterJobKey])
}
const traffic = readSettled(trafficRes) const writeSettings = useCallback(async (key: SettingsKey, patch: Record<string, number | boolean>) => {
if (traffic) { if (!isLive || !reader.current.active || !can(SETTINGS_PERMISSIONS[key]) || !settingsReads[key].known || settingsReads[key].error) throw new Error("Настройки не получены или недостаточно прав для записи")
setTrafficCollector(traffic) const data = key === "traffic" ? await putTrafficCollectorSettings(backendUrl, patch)
setTrafficIntervalDraft(String(traffic.intervalSec)) : key === "rest" ? await putServersApiPingSettings(backendUrl, patch)
setTrafficRetentionDraft(String(traffic.retentionDays)) : key === "uptime" ? await putUptimeCollectorSettings(backendUrl, patch)
setDraftTrafficEnabled(!!traffic.enabled) : key === "internet" ? await putInternetPathSettings(backendUrl, patch)
} : await putCertificateRenewSettings(backendUrl, patch)
if (Object.entries(patch).some(([field, value]) => (data as Record<string, unknown>)[field] !== value)) throw new Error("Сервер подтвердил другие настройки. Обновите данные перед повторной попыткой")
if (!reader.current.active) return
if (key === "traffic") setTrafficCollector(data as Awaited<ReturnType<typeof getTrafficCollectorSettings>>)
if (key === "rest") setServersApiCollector(data as Awaited<ReturnType<typeof getServersApiPingSettings>>)
if (key === "uptime") setUptimeCollector(data as Awaited<ReturnType<typeof getUptimeCollectorSettings>>)
if (key === "internet") setInternetPathCollector(data as Awaited<ReturnType<typeof getInternetPathSettings>>)
setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
}, [backendUrl, isLive, settingsReads])
const serversApi = readSettled(serversApiRes) const persistSchedulerDrafts = async () => {
if (serversApi) { if (mutationLock.current || !reader.current.active) return
setServersApiCollector(serversApi) const drafts: Record<SettingsKey, Record<string, string>> = {
setDraftServersApiEnabled(!!serversApi.enabled) traffic: { intervalSec: trafficIntervalDraft, retentionDays: trafficRetentionDraft }, rest: { intervalSec: serversApiIntervalDraft },
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120)) uptime: { intervalSec: uptimeResourceIntervalDraft, probeIntervalSec: uptimeIntervalDraft, speedIntervalSec: uptimeSpeedIntervalDraft, retentionDays: uptimeRetentionDraft },
} internet: { intervalSec: internetPathIntervalDraft }, cert: { intervalSec: certRenewIntervalDraft, renewBeforeDays: renewBeforeDaysDraft },
}
const uptime = readSettled(uptimeRes) const plan: { key: SettingsKey; patch: Record<string, number> }[] = []
if (uptime) { try {
setUptimeCollector(uptime) for (const key of Object.keys(drafts) as SettingsKey[]) {
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300)) if (!dirtyFields.current[key].size) continue
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15)) if (!writable(key)) throw new Error("Настройки не получены или недостаточно прав: " + SETTINGS_LABELS[key])
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60)) const patch: Record<string, number> = {}
setUptimeRetentionDraft(String(uptime.retentionDays)) for (const field of dirtyFields.current[key]) {
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled)) const raw = drafts[key][field]; const value = Number(raw)
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled)) const min = field === "retentionDays" || field === "renewBeforeDays" ? 1 : key === "cert" ? 300 : key === "internet" ? 30 : key === "rest" || field === "speedIntervalSec" ? 10 : 5
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled)) if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < min || (field === "renewBeforeDays" && value > 90)) throw new Error(SETTINGS_LABELS[key] + ": укажите целое значение от " + min + (field === "renewBeforeDays" ? " до 90" : ""))
if (uptime.scheduler?.jobs?.length) { patch[field] = value
applySchedulerJobsToDrafts(uptime.scheduler.jobs, {
setDraftTrafficEnabled,
setDraftServersApiEnabled,
setDraftResourcesEnabled,
setDraftPingEnabled,
setDraftSpeedEnabled,
setDraftInternetPathEnabled,
setDraftCertRenewEnabled,
})
} }
plan.push({ key, patch })
} }
if (!plan.length) return
const internetPath = readSettled(internetPathRes) mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
if (internetPath) { const successes: string[] = [], errors: string[] = []
setInternetPathCollector(internetPath) for (const { key, patch } of plan) {
setDraftInternetPathEnabled(!!internetPath.enabled) if (!reader.current.active) break
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300)) try {
await writeSettings(key, patch)
if (!reader.current.active) return
for (const field of Object.keys(patch)) dirtyFields.current[key].delete(field)
if (!dirtyFields.current[key].size) dirtySettings.current.delete(key)
successes.push(SETTINGS_LABELS[key])
} catch (error) { if (reader.current.active) errors.push(SETTINGS_LABELS[key] + ": " + extractApiError(error)) }
} }
if (!reader.current.active) return
const certRenew = readSettled(certRenewRes) setDirtyCount(Object.values(dirtyFields.current).reduce((count, fields) => count + fields.size, 0))
if (certRenew) { if (successes.length) setSaveMessage("Сохранение подтверждено: " + successes.join(", "))
setDraftCertRenewEnabled(!!certRenew.enabled) if (errors.length) setCollectorError(errors.join("; "))
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600)) } catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30)) finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
}
const runsPayload = readSettled(runsRes)
if (runsPayload) {
setSchedulerRuns(runsPayload.runs ?? [])
}
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
} finally {
setPageBusy(false)
}
}, [apiFetch, isLive, runFilterJobKey])
const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => {
const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled
const serversApiEnabled = overrides.serversApiEnabled ?? draftServersApiEnabled
const resourcesEnabled = overrides.resourcesEnabled ?? draftResourcesEnabled
const pingEnabled = overrides.pingEnabled ?? draftPingEnabled
const speedEnabled = overrides.speedEnabled ?? draftSpeedEnabled
const internetPathEnabled = overrides.internetPathEnabled ?? draftInternetPathEnabled
const certRenewEnabled = overrides.certRenewEnabled ?? draftCertRenewEnabled
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14)
const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300)
const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15)
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300)
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
const saveResults = await Promise.allSettled([
apiFetch("/api/traffic/settings", {
method: "PUT",
body: JSON.stringify({
enabled: trafficEnabled,
intervalSec: tInt,
retentionDays: tRet,
}),
}),
apiFetch("/api/servers-api-ping/settings", {
method: "PUT",
body: JSON.stringify({
enabled: serversApiEnabled,
intervalSec: sApiInt,
}),
}),
apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
resourcesEnabled,
pingEnabled,
speedEnabled,
intervalSec: uRes,
probeIntervalSec: uPing,
speedIntervalSec: uSpd,
retentionDays: uRet,
}),
}),
apiFetch("/api/internet-path/settings", {
method: "PUT",
body: JSON.stringify({
enabled: internetPathEnabled,
intervalSec: ipInt,
}),
}),
apiFetch("/api/certificates/renew-settings", {
method: "PUT",
body: JSON.stringify({
enabled: certRenewEnabled,
intervalSec: certRenewInt,
renewBeforeDays,
}),
}),
])
const saveErrors = collectSettledErrors(
saveResults,
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"],
)
if (saveErrors.length > 0) {
throw new Error(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
}
}, [
apiFetch,
certRenewIntervalDraft,
draftCertRenewEnabled,
draftInternetPathEnabled,
draftPingEnabled,
draftResourcesEnabled,
draftServersApiEnabled,
draftSpeedEnabled,
draftTrafficEnabled,
internetPathIntervalDraft,
renewBeforeDaysDraft,
serversApiIntervalDraft,
trafficIntervalDraft,
trafficRetentionDraft,
uptimeIntervalDraft,
uptimeResourceIntervalDraft,
uptimeRetentionDraft,
uptimeSpeedIntervalDraft,
])
const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
const overrides: SchedulerToggleOverrides = {}
if (jobKey === "traffic") {
setDraftTrafficEnabled(nextEnabled)
overrides.trafficEnabled = nextEnabled
} else if (jobKey === "servers_rest_ping") {
setDraftServersApiEnabled(nextEnabled)
overrides.serversApiEnabled = nextEnabled
} else if (jobKey === "uptime_resources") {
setDraftResourcesEnabled(nextEnabled)
overrides.resourcesEnabled = nextEnabled
} else if (jobKey === "uptime_ping") {
setDraftPingEnabled(nextEnabled)
overrides.pingEnabled = nextEnabled
} else if (jobKey === "uptime_speed") {
setDraftSpeedEnabled(nextEnabled)
overrides.speedEnabled = nextEnabled
} else if (jobKey === "certificates_renew") {
setDraftCertRenewEnabled(nextEnabled)
overrides.certRenewEnabled = nextEnabled
} else if (jobKey === "internet_path") {
setDraftInternetPathEnabled(nextEnabled)
overrides.internetPathEnabled = nextEnabled
} else {
return
}
setCollectorError(null)
setSchedulerSaveBusy(true)
try {
await persistSchedulerDrafts(overrides)
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setSchedulerSaveBusy(false)
}
}, [loadCollectors, persistSchedulerDrafts])
useEffect(() => {
if (!isLive) {
setTrafficCollector(null)
setServersApiCollector(null)
setUptimeCollector(null)
setInternetPathCollector(null)
setSchedulerRuns([])
return
}
void loadCollectors()
}, [isLive, loadCollectors])
useEffect(() => {
setOpenRunIds(new Set())
}, [runFilterJobKey])
const handleRefreshScheduler = async () => {
if (!isLive) return
setCollectorError(null)
try {
await apiFetch("/api/scheduler/refresh", { method: "POST" })
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось обновить планировщик")
}
} }
const schedulerGridRows = useMemo<SchedulerJobGridRow[]>(() => { const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
const targets = {
traffic: ["traffic", "enabled", setDraftTrafficEnabled], servers_rest_ping: ["rest", "enabled", setDraftServersApiEnabled],
uptime_resources: ["uptime", "resourcesEnabled", setDraftResourcesEnabled], uptime_ping: ["uptime", "pingEnabled", setDraftPingEnabled], uptime_speed: ["uptime", "speedEnabled", setDraftSpeedEnabled],
internet_path: ["internet", "enabled", setDraftInternetPathEnabled], certificates_renew: ["cert", "enabled", setDraftCertRenewEnabled],
} as const
if (!(jobKey in targets) || mutationLock.current || !reader.current.active) return
const [key, field, accept] = targets[jobKey as keyof typeof targets]
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
try {
await writeSettings(key, { [field]: nextEnabled })
if (!reader.current.active) return
accept(nextEnabled); setSaveMessage("Сохранение подтверждено: " + SETTINGS_LABELS[key])
} catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
}, [writeSettings])
useEffect(() => {
if (!isLive) return
queueMicrotask(() => { void loadCollectors() })
}, [isLive, loadCollectors])
const handleRefreshScheduler = async () => {
if (!isLive || !reader.current.active || mutationLock.current || !can("mm:settings:admin")) return
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
try {
const data = await refreshScheduler(backendUrl)
if (!reader.current.active) return
setSchedulerStatus(data.status); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString())
setSaveMessage("Перечитывание таймеров подтверждено сервером")
} catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
}
const handleRunNow = async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number]) => {
const job = schedulerJobsByKey[jobKey]
if (!isLive || !reader.current.active || mutationLock.current || !can("mm:settings:admin") || !job || job.running) return
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setRunNowJobKey(jobKey); setCollectorError(null); setSaveMessage(null)
try {
const data = await runSchedulerJobNow(backendUrl, jobKey)
if (!reader.current.active) return
if (data.status) { setSchedulerStatus(data.status); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) }
else setSchedulerStatusError(data.statusError ?? "Статус после запуска недоступен")
const skipped = "skipped" in data.result.result && data.result.result.skipped
const notDue = data.result.result.job === "backups" && !data.result.result.due
setSaveMessage((!data.ok ? "Запуск завершился с ошибками: " : skipped || notDue ? "Прогон пропущен: " : "Запуск подтверждён: ") + SCHEDULER_JOB_LABELS[jobKey])
const warnings = [data.result.error, data.result.eventError ? "Журнал событий: " + data.result.eventError : null, data.statusError ? "Статус: " + data.statusError : null].filter(Boolean)
if (warnings.length) setCollectorError(warnings.join("; "))
} catch (error) {
if (reader.current.active) setCollectorError(extractApiError(error) + ". Результат операции неизвестен; перед повторным запуском обновите журнал.")
} finally { mutationLock.current = false; if (reader.current.active) { setSchedulerSaveBusy(false); setRunNowJobKey(null) } }
}
const schedulerGridRows: SchedulerJobGridRow[] = (() => {
return SCHEDULER_JOB_KEYS.map((jobKey) => { return SCHEDULER_JOB_KEYS.map((jobKey) => {
const j = schedulerJobsByKey[jobKey] const j = schedulerJobsByKey[jobKey]
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" const settingsKey: SettingsKey = jobKey === "traffic" ? "traffic" : jobKey === "servers_rest_ping" ? "rest" : jobKey.startsWith("uptime_") ? "uptime" : jobKey === "certificates_renew" ? "cert" : "internet"
const fixedSchedule = jobKey === "router_logs" || jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
const enabled = fixedSchedule const enabled = fixedSchedule
? Boolean(j?.enabled ?? true) ? Boolean(j?.enabled ?? true)
: jobKey === "traffic" : jobKey === "traffic"
@@ -904,16 +823,18 @@ export default function DataCollectionPage() {
? 21600 ? 21600
: 300 : 300
const settingsKnown = fixedSchedule ? Boolean(j) : settingsReads[settingsKey].known
return { return {
id: jobKey, id: jobKey,
settingsKnown,
jobKey, jobKey,
label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey, label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey,
description: SCHEDULER_JOB_DESCRIPTIONS[jobKey], description: SCHEDULER_JOB_DESCRIPTIONS[jobKey],
fixedSchedule, fixedSchedule,
enabled, enabled,
intervalValue, intervalValue: settingsKnown ? intervalValue : "",
intervalReadOnly: fixedSchedule, intervalReadOnly: fixedSchedule,
intervalDisabled: !enabled && !fixedSchedule, intervalDisabled: !settingsKnown || schedulerSaveBusy || (!fixedSchedule && !writable(settingsKey)),
defaultInterval, defaultInterval,
job: j, job: j,
onEnabledChange: fixedSchedule onEnabledChange: fixedSchedule
@@ -921,94 +842,32 @@ export default function DataCollectionPage() {
: (nextEnabled) => { : (nextEnabled) => {
void handleJobEnabledChange(jobKey, nextEnabled) void handleJobEnabledChange(jobKey, nextEnabled)
}, },
onIntervalChange, onIntervalChange: (value: string) => { markDirty(settingsKey, jobKey === "uptime_ping" ? "probeIntervalSec" : jobKey === "uptime_speed" ? "speedIntervalSec" : "intervalSec"); onIntervalChange(value) },
onRunNow: async () => { onRunNow: async () => { await handleRunNow(jobKey) },
setRunNowJobKey(jobKey) runNowDisabled: schedulerSaveBusy || !can("mm:settings:admin") || !j || j.running,
setCollectorError(null)
try {
await apiFetch(`/api/scheduler/jobs/${encodeURIComponent(jobKey)}/run-now`, {
method: "POST",
})
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Ошибка запуска")
} finally {
setRunNowJobKey(null)
}
},
runNowLoading: runNowJobKey === jobKey, runNowLoading: runNowJobKey === jobKey,
saveBusy: schedulerSaveBusy, saveBusy: schedulerSaveBusy || !writable(settingsKey),
} }
}) })
}, [ })()
apiFetch,
certRenewIntervalDraft,
draftCertRenewEnabled,
draftInternetPathEnabled,
draftPingEnabled,
draftResourcesEnabled,
draftServersApiEnabled,
draftSpeedEnabled,
draftTrafficEnabled,
handleJobEnabledChange,
internetPathIntervalDraft,
loadCollectors,
runNowJobKey,
schedulerJobsByKey,
schedulerSaveBusy,
serversApiIntervalDraft,
trafficIntervalDraft,
uptimeIntervalDraft,
uptimeResourceIntervalDraft,
uptimeSpeedIntervalDraft,
])
const enabledJobsCount = useMemo(() => { const enabledJobsCount = schedulerStatus?.jobs.filter(job => job.enabled).length ?? 0
const jobs = uptimeCollector?.scheduler?.jobs const schedulerJobCount = schedulerStatus?.jobs.length ?? 0
if (jobs?.length) return jobs.filter((job) => job.enabled).length const runningJobsCount = schedulerStatus?.jobs.filter(job => job.running).length ?? 0
let n = draftTrafficEnabled ? 1 : 0
if (draftServersApiEnabled) n += 1
if (draftResourcesEnabled) n += 1
if (draftPingEnabled) n += 1
if (draftSpeedEnabled) n += 1
if (draftInternetPathEnabled) n += 1
if (draftCertRenewEnabled) n += 1
return n
}, [
draftCertRenewEnabled,
draftInternetPathEnabled,
draftPingEnabled,
draftResourcesEnabled,
draftServersApiEnabled,
draftSpeedEnabled,
draftTrafficEnabled,
uptimeCollector?.scheduler?.jobs,
])
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
const runningJobsCount = useMemo(
() => (uptimeCollector?.scheduler?.jobs ?? []).filter((j) => j.running).length,
[uptimeCollector?.scheduler?.jobs],
)
const errorRunsInView = useMemo(() => schedulerRuns.filter((r) => r.status === "error").length, [schedulerRuns]) const errorRunsInView = useMemo(() => schedulerRuns.filter((r) => r.status === "error").length, [schedulerRuns])
const stats = useMemo( const stats = [
() => [
{ {
label: "Включено задач", label: "Включено задач",
value: `${enabledJobsCount} / ${schedulerJobCount}`, value: schedulerStatus ? `${enabledJobsCount} / ${schedulerJobCount}` : "—",
sub: uptimeCollector?.scheduler?.jobs?.length sub: schedulerStatusError ? (schedulerStatus ? "Последний подтверждённый статус" : "Статус не получен") : schedulerStatus ? "По сохранённым задачам планировщика" : "Ожидание статуса",
? "По сохранённым задачам планировщика"
: "По переключателям на этой странице",
icon: <CalendarClockIcon className="size-4" />, icon: <CalendarClockIcon className="size-4" />,
}, },
{ {
label: "Сейчас выполняется", label: "Сейчас выполняется",
value: String(runningJobsCount), value: schedulerStatus ? String(runningJobsCount) : "—",
sub: "Фоновые прогоны планировщика", sub: schedulerStatusError ? "Статус требует обновления" : "Фоновые прогоны планировщика",
icon: <LoaderCircleIcon className="size-4" />, icon: <LoaderCircleIcon className="size-4" />,
}, },
{ {
@@ -1016,31 +875,20 @@ export default function DataCollectionPage() {
value: trafficCollector?.lastCollectedAt value: trafficCollector?.lastCollectedAt
? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit" }) ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit" })
: "—", : "—",
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных", sub: settingsReads.traffic.error ? "Последний сбор требует обновления" : trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
icon: trafficCollector?.lastError ? ( icon: trafficCollector?.lastError ? (
<XCircleIcon className="size-4" /> <XCircleIcon className="size-4" />
) : ( ) : trafficCollector?.lastCollectedAt ? (
<CheckCircleIcon className="size-4" /> <CheckCircleIcon className="size-4" />
), ) : (<CalendarClockIcon className="size-4" />),
}, },
{ {
label: "Журнал (в списке)", label: "Журнал (в списке)",
value: String(schedulerRuns.length), value: runsKnown ? String(schedulerRuns.length) : "—",
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет", sub: runsError ? (runsKnown ? "Показана последняя подтверждённая история" : "История не получена") : runsKnown ? (errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет") : "Ожидание истории",
icon: <DatabaseIcon className="size-4" />, icon: <DatabaseIcon className="size-4" />,
}, },
], ]
[
enabledJobsCount,
schedulerJobCount,
runningJobsCount,
trafficCollector?.lastCollectedAt,
trafficCollector?.lastDurationMs,
trafficCollector?.lastError,
schedulerRuns.length,
errorRunsInView,
],
)
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -1054,13 +902,13 @@ export default function DataCollectionPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!isLive || pageBusy} disabled={!isLive || pageBusy || schedulerSaveBusy}
onClick={() => { void loadCollectors() }} onClick={() => { void loadCollectors() }}
> >
<RefreshCwIcon className={cn("size-4", pageBusy && "animate-spin")} /> <RefreshCwIcon className={cn("size-4", pageBusy && "animate-spin")} />
Обновить Обновить
</Button> </Button>
<Button variant="outline" size="sm" disabled={!isLive || pageBusy} onClick={() => { void handleRefreshScheduler() }}> <Button variant="outline" size="sm" disabled={!isLive || pageBusy || schedulerSaveBusy || !can("mm:settings:admin")} onClick={() => { void handleRefreshScheduler() }}>
Перечитать таймеры Перечитать таймеры
</Button> </Button>
</div> </div>
@@ -1090,23 +938,39 @@ export default function DataCollectionPage() {
</OpsPanel> </OpsPanel>
)} )}
{isLive && schedulerStatusError ? (
<Alert variant="warning">
<AlertCircleIcon aria-hidden="true" />
<AlertTitle>{schedulerStatus ? "Статус планировщика не обновлён" : "Статус планировщика не получен"}</AlertTitle>
<AlertDescription>{schedulerStatusError}</AlertDescription>
</Alert>
) : null}
{isLive && schedulerStatusReceivedAt ? <p className="text-xs text-muted-foreground">Статус получен: {new Date(schedulerStatusReceivedAt).toLocaleString("ru-RU")}</p> : null}
{isLive && Object.entries(settingsReads).map(([key, value]) => (
<div key={key} className="text-xs text-muted-foreground" data-testid={"settings-" + key}>
<p role="status">{SETTINGS_LABELS[key as SettingsKey]} — {value.known ? "настройки получены" : "настройки не получены"}{value.receivedAt ? " · Получено: " + new Date(value.receivedAt).toLocaleString("ru-RU") : ""}</p>
{value.error ? <Alert variant="warning" className="mt-1"><AlertCircleIcon aria-hidden="true" /><AlertDescription>{value.error}{value.known ? ". Сохранены последние подтверждённые данные." : ""}</AlertDescription></Alert> : null}
</div>
))}
{isLive && saveMessage ? <p role="status" className="text-sm text-muted-foreground">{saveMessage}</p> : null}
{isLive && collectorError && ( {isLive && collectorError && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircleIcon /> <AlertCircleIcon />
<AlertTitle>Ошибка загрузки статуса коллекторов</AlertTitle> <AlertTitle>Операция не завершена</AlertTitle>
<AlertDescription>{collectorError}</AlertDescription> <AlertDescription>{collectorError}</AlertDescription>
</Alert> </Alert>
)} )}
{isLive && can("mm:settings:admin") && <LogsCollectorSettings key={backendUrl} base={backendUrl} onSaved={settings => setSchedulerStatus(previous => previous ? { ...previous, jobs: previous.jobs.map(job => job.jobKey === "router_logs" ? { ...job, enabled: settings.enabled, intervalSec: settings.intervalSec } : job) } : previous)} />}
{isLive && ( {isLive && (
<> <>
<KpiStatGrid <KpiStatGrid
aria-label="Сводка сбора данных" aria-label="Сводка сбора данных"
items={stats.map((s, i) => ({ items={stats.map((s, i) => ({
id: `dc-${i}`, id: `dc-${i}`,
label: s.label, label: <span className="whitespace-normal">{s.label}</span>,
value: s.value, value: s.value,
hint: s.sub, hint: <span className="text-xs text-muted-foreground whitespace-normal">{s.sub}</span>,
icon: s.icon, icon: s.icon,
iconClassName: iconClassName:
s.label === "Трафик — последний сбор" && trafficCollector?.lastError s.label === "Трафик — последний сбор" && trafficCollector?.lastError
@@ -1115,7 +979,7 @@ export default function DataCollectionPage() {
? "text-warning" ? "text-warning"
: s.label === "Журнал (в списке)" && errorRunsInView : s.label === "Журнал (в списке)" && errorRunsInView
? "text-destructive" ? "text-destructive"
: s.label === "Трафик — последний сбор" : s.label === "Трафик — последний сбор" && trafficCollector?.lastCollectedAt
? "text-success" ? "text-success"
: "text-muted-foreground", : "text-muted-foreground",
variant: variant:
@@ -1131,7 +995,7 @@ export default function DataCollectionPage() {
<div className="border-b border-border px-5 py-4"> <div className="border-b border-border px-5 py-4">
<p className="text-base font-medium">Планировщик сбора данных</p> <p className="text-base font-medium">Планировщик сбора данных</p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения. Интервалы и вкл/выкл по задачам. Переключатель сохраняет только свой флаг; кнопка ниже — изменённые интервалы и срок хранения. Ручной запуск использует сохранённые настройки, без отправки черновика.
</p> </p>
</div> </div>
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} /> <DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
@@ -1141,8 +1005,10 @@ export default function DataCollectionPage() {
<div> <div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p> <p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
<Input <Input
value={trafficRetentionDraft} value={settingsReads.traffic.known ? trafficRetentionDraft : ""}
onChange={(e) => setTrafficRetentionDraft(e.target.value)} aria-label="Хранение трафика"
disabled={!writable("traffic") || schedulerSaveBusy}
onChange={(e) => { markDirty("traffic", "retentionDays"); setTrafficRetentionDraft(e.target.value) }}
className="h-8 text-sm" className="h-8 text-sm"
inputMode="numeric" inputMode="numeric"
/> />
@@ -1150,8 +1016,10 @@ export default function DataCollectionPage() {
<div> <div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов uptime (дней)</p> <p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов uptime (дней)</p>
<Input <Input
value={uptimeRetentionDraft} value={settingsReads.uptime.known ? uptimeRetentionDraft : ""}
onChange={(e) => setUptimeRetentionDraft(e.target.value)} aria-label="Хранение uptime"
disabled={!writable("uptime") || schedulerSaveBusy}
onChange={(e) => { markDirty("uptime", "retentionDays"); setUptimeRetentionDraft(e.target.value) }}
className="h-8 text-sm" className="h-8 text-sm"
inputMode="numeric" inputMode="numeric"
/> />
@@ -1159,11 +1027,13 @@ export default function DataCollectionPage() {
<div> <div>
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p> <p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
<Input <Input
value={renewBeforeDaysDraft} value={settingsReads.cert.known ? renewBeforeDaysDraft : ""}
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)} aria-label="Обновлять сертификаты за дней"
onChange={(e) => { markDirty("cert", "renewBeforeDays"); setRenewBeforeDaysDraft(e.target.value) }}
className="h-8 text-sm" className="h-8 text-sm"
inputMode="numeric" inputMode="numeric"
disabled={!draftCertRenewEnabled} disabled={!writable("cert") || schedulerSaveBusy}
/> />
<p className="text-[11px] text-muted-foreground mt-1"> <p className="text-[11px] text-muted-foreground mt-1">
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS. Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
@@ -1175,22 +1045,12 @@ export default function DataCollectionPage() {
</p> </p>
<Button <Button
size="sm" size="sm"
disabled={schedulerSaveBusy} aria-label="Сохранить настройки планировщика"
onClick={async () => { disabled={schedulerSaveBusy || dirtyCount === 0}
setSchedulerSaveBusy(true) onClick={() => { void persistSchedulerDrafts() }}
setCollectorError(null)
try {
await persistSchedulerDrafts()
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setSchedulerSaveBusy(false)
}
}}
> >
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null} {schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
Сохранить настройки планировщика Сохранить изменения
</Button> </Button>
<div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60"> <div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
<p> <p>
@@ -1206,7 +1066,7 @@ export default function DataCollectionPage() {
) : null} ) : null}
</p> </p>
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}> <p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>
{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : "Трафик: ошибок нет"} {trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : (settingsReads.traffic.known ? "Трафик: ошибки не записаны" : "Трафик: состояние неизвестно")}
</p> </p>
<p> <p>
REST API (серверы):{" "} REST API (серверы):{" "}
@@ -1223,7 +1083,7 @@ export default function DataCollectionPage() {
<p className={cn(serversApiCollector?.lastError ? "text-destructive" : "")}> <p className={cn(serversApiCollector?.lastError ? "text-destructive" : "")}>
{serversApiCollector?.lastError {serversApiCollector?.lastError
? `REST API (серверы): ${serversApiCollector.lastError}` ? `REST API (серверы): ${serversApiCollector.lastError}`
: "REST API (серверы): ошибок нет"} : (settingsReads.rest.known ? "REST API (серверы): ошибки не записаны" : "REST API (серверы): состояние неизвестно")}
</p> </p>
<p> <p>
Uptime (агрегат в настройках):{" "} Uptime (агрегат в настройках):{" "}
@@ -1233,7 +1093,7 @@ export default function DataCollectionPage() {
· {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"} · {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}
</p> </p>
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}> <p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : "Uptime: ошибок нет"} {uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : (settingsReads.uptime.known ? "Uptime: ошибки не записаны" : "Uptime: состояние неизвестно")}
</p> </p>
<p> <p>
Internet Path snapshot:{" "} Internet Path snapshot:{" "}
@@ -1245,7 +1105,7 @@ export default function DataCollectionPage() {
<p className={cn(internetPathCollector?.lastError ? "text-destructive" : "")}> <p className={cn(internetPathCollector?.lastError ? "text-destructive" : "")}>
{internetPathCollector?.lastError {internetPathCollector?.lastError
? `Internet Path: ${internetPathCollector.lastError}` ? `Internet Path: ${internetPathCollector.lastError}`
: "Internet Path: ошибок нет"} : (settingsReads.internet.known ? "Internet Path: ошибки не записаны" : "Internet Path: состояние неизвестно")}
</p> </p>
</div> </div>
</div> </div>
@@ -1255,7 +1115,7 @@ export default function DataCollectionPage() {
<OpsPanel <OpsPanel
title="Журнал прогонов" title="Журнал прогонов"
description="Таблица `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки." description="Последние 80 запусков. Раскройте запись для результатов и объяснения ошибок."
headerRight={ headerRight={
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter"> <label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
@@ -1265,7 +1125,8 @@ export default function DataCollectionPage() {
id="run-filter" id="run-filter"
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[180px]" className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[180px]"
value={runFilterJobKey} value={runFilterJobKey}
onChange={(e) => setRunFilterJobKey(e.target.value)} disabled={schedulerSaveBusy}
onChange={(e) => { setRunFilterJobKey(e.target.value); setOpenRunIds(new Set()); setSchedulerRuns([]); setRunsKnown(false); setRunsError(null); setRunsReceivedAt(null) }}
> >
<option value="">Все</option> <option value="">Все</option>
{SCHEDULER_JOB_KEYS.map((k) => ( {SCHEDULER_JOB_KEYS.map((k) => (
@@ -1278,7 +1139,19 @@ export default function DataCollectionPage() {
} }
contentClassName="px-0 pb-0" contentClassName="px-0 pb-0"
> >
{schedulerRuns.length === 0 ? ( {runsError ? (
<div className="mx-4 mb-3">
<Alert variant="warning">
<AlertCircleIcon aria-hidden="true" />
<AlertTitle>{runsKnown ? "История не обновлена" : "История не получена"}</AlertTitle>
<AlertDescription>{runsError}</AlertDescription>
</Alert>
</div>
) : null}
{runsReceivedAt ? <p className="px-4 pb-3 text-xs text-muted-foreground">История получена: {new Date(runsReceivedAt).toLocaleString("ru-RU")}</p> : null}
{!runsKnown ? (
!runsError ? <p role="status" className="px-4 py-10 text-center text-sm text-muted-foreground">Загрузка журнала…</p> : null
) : schedulerRuns.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10 px-4">Пока нет прогонов</p> <p className="text-sm text-muted-foreground text-center py-10 px-4">Пока нет прогонов</p>
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
+13 -28
View File
@@ -8,25 +8,22 @@ import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog" import { FileImportDialog } from "@/components/file-import-dialog"
import { domains as mockDomains } from "@/lib/data" import { domains as mockDomains } from "@/lib/data"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react" import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
export default function DomainsPage() { export default function DomainsPage() {
const { mode } = useDataSource() const { mode, snapshot, loading, known } = useEvoCatalogView()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const useEvoCatalog = mode === "live" && enabled const useEvoCatalog = mode === "live"
const rows = useMemo(() => { const rows = useMemo(() => {
if (!useEvoCatalog) return mockDomains if (mode === "mock") return mockDomains
if (loading && !snapshot) return [] if (loading && !snapshot) return []
return snapshot?.domains ?? [] return snapshot?.domains ?? []
}, [useEvoCatalog, loading, snapshot]) }, [mode, loading, snapshot])
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search) return rows if (!search) return rows
@@ -43,8 +40,7 @@ export default function DomainsPage() {
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Данные" }, { label: "Домены" }]} crumbs={[{ label: "Данные" }, { label: "Домены" }]}
actions={ actions={mode === "live" ? null : <>
<>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}> <Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт <UploadIcon className="size-4" />Импорт
</Button> </Button>
@@ -58,34 +54,23 @@ export default function DomainsPage() {
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1> <h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik Домены из каталога для BGP-фильтрации
</p> </p>
{useEvoCatalog && ( <div className="mt-3"><EvoBgpCatalogStatus /></div>
<p className={cn(
"text-xs mt-2 flex items-center gap-1.5",
error ? "text-destructive" : "text-muted-foreground",
)}>
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
{error
? `EvoBGP: ${error}`
: snapshot?.fetchedAt
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
</p>
)}
</div> </div>
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по домену…" searchPlaceholder="Поиск по домену…"
countLabel={`${filtered.length} доменов`} countLabel={known ? `${filtered.length} доменов` : "Количество: —"}
/> />
<DomainsDataGrid {known || loading ? <DomainsDataGrid
domains={filtered} domains={filtered}
isLoading={useEvoCatalog && loading && !snapshot} isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog} pagination={useEvoCatalog}
/> emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
/> : <EvoBgpCatalogUnavailable />}
</DataPageCard> </DataPageCard>
</div> </div>
</div> </div>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+541 -397
View File
File diff suppressed because it is too large. Load diff
+13 -28
View File
@@ -8,25 +8,22 @@ import { IpRangesDataGrid } from "@/components/data-grids/ip-ranges-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog" import { FileImportDialog } from "@/components/file-import-dialog"
import { ipRanges as mockIpRanges } from "@/lib/data" import { ipRanges as mockIpRanges } from "@/lib/data"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react" import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
export default function IpRangesPage() { export default function IpRangesPage() {
const { mode } = useDataSource() const { mode, snapshot, loading, known } = useEvoCatalogView()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const useEvoCatalog = mode === "live" && enabled const useEvoCatalog = mode === "live"
const rows = useMemo(() => { const rows = useMemo(() => {
if (!useEvoCatalog) return mockIpRanges if (mode === "mock") return mockIpRanges
if (loading && !snapshot) return [] if (loading && !snapshot) return []
return snapshot?.ipRanges ?? [] return snapshot?.ipRanges ?? []
}, [useEvoCatalog, loading, snapshot]) }, [mode, loading, snapshot])
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search) return rows if (!search) return rows
@@ -44,8 +41,7 @@ export default function IpRangesPage() {
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]} crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
actions={ actions={mode === "live" ? null : <>
<>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}> <Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт <UploadIcon className="size-4" />Импорт
</Button> </Button>
@@ -59,34 +55,23 @@ export default function IpRangesPage() {
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1> <h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам CIDR-блоки из каталога для сетевых фильтров
</p> </p>
{useEvoCatalog && ( <div className="mt-3"><EvoBgpCatalogStatus /></div>
<p className={cn(
"text-xs mt-2 flex items-center gap-1.5",
error ? "text-destructive" : "text-muted-foreground",
)}>
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
{error
? `EvoBGP: ${error}`
: snapshot?.fetchedAt
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
</p>
)}
</div> </div>
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по CIDR, ASN…" searchPlaceholder="Поиск по CIDR, ASN…"
countLabel={`${filtered.length} диапазонов`} countLabel={known ? `${filtered.length} диапазонов` : "Количество: —"}
/> />
<IpRangesDataGrid {known || loading ? <IpRangesDataGrid
ipRanges={filtered} ipRanges={filtered}
isLoading={useEvoCatalog && loading && !snapshot} isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog} pagination={useEvoCatalog}
/> emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
/> : <EvoBgpCatalogUnavailable />}
</DataPageCard> </DataPageCard>
</div> </div>
</div> </div>
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -22,7 +22,7 @@ export default function MainLayout({ children }: { children: ReactNode }) {
К содержимому К содержимому
</a> </a>
<AppSidebar /> <AppSidebar />
<SidebarInset id="main-content" className="h-svh overflow-hidden"> <SidebarInset tabIndex={-1} id="main-content" className="h-svh overflow-hidden">
{children} {children}
</SidebarInset> </SidebarInset>
<CommandPalette /> <CommandPalette />
+2
View File
@@ -0,0 +1,2 @@
import { LogsWorkspace } from "@/components/logs/logs-workspace"
export default function LogsPage() { return <LogsWorkspace /> }
+6
View File
@@ -0,0 +1,6 @@
import { Suspense } from "react"
import { McpWorkspace } from "@/components/mcp/mcp-workspace"
export default function McpPage() {
return <Suspense fallback={<p role="status" className="p-6">Загрузка MCP…</p>}><McpWorkspace /></Suspense>
}
File diff suppressed because it is too large. Load diff
+415 -383
View File
File diff suppressed because it is too large. Load diff
+269 -327
View File
@@ -1,6 +1,10 @@
"use client" "use client"
import { useEffect, useRef, useState, useMemo, useCallback } from "react" import { toast } from "sonner"
import { Suspense, useEffect, useRef, useState, useMemo, useCallback } from "react"
import { useSearchParams } from "next/navigation"
import { readProbeSource } from "@/lib/probe-source"
import { readProbeUrl, patchProbeUrl } from "@/lib/probe-url"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit" import { FormToggle } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
@@ -11,21 +15,21 @@ import {
type SchedRule, type SchedRule,
type SchedType, type SchedType,
} from "@/components/data-grids/probes-schedule-data-grid" } from "@/components/data-grids/probes-schedule-data-grid"
import { import {ProbesSpeedSchedule} from "@/components/probes-speed-schedule"
ProbesSpeedProbesDataGrid,
type SpeedProbeApiRow,
} from "@/components/data-grids/probes-speed-probes-data-grid"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail" import { type ServerTileItem } from "@/components/server-tile-rail"
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data" import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
import { filtersGreResponseSchema } from "@/lib/filters-response"
import { can } from "@/lib/auth"
import { readProbeOutput, probeServersSchema } from "@/lib/probe-response"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { import {
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon, PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon, ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
ZapIcon, ClockIcon, CheckIcon, TerminalIcon, ZapIcon, CheckIcon, TerminalIcon,
LoaderCircleIcon, AlertCircleIcon, LoaderCircleIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
@@ -33,7 +37,7 @@ import { requestJson } from "@/shared/api/http-client"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu" type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu"
type RunStatus = "running" | "done" | "error" type RunStatus = "running" | "done" | "error" | "cancelled"
type TraceProto = "icmp" | "udp" | "tcp" type TraceProto = "icmp" | "udp" | "tcp"
type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR" type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR"
@@ -66,12 +70,12 @@ const TOOL_META: Record<DiagTool, {
color: string color: string
description: string description: string
}> = { }> = {
ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-sky-500", description: "Проверка связи и RTT" }, ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-info", description: "Проверка связи и RTT" },
traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-violet-500", description: "Трассировка маршрута" }, traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-info", description: "Трассировка маршрута" },
bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-emerald-500", description: "Пропускная способность" }, bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-success", description: "Пропускная способность" },
dns: { label: "DNS", ros: "/resolve", Icon: SearchIcon, color: "text-amber-500", description: "Разрешение DNS-имён" }, dns: { label: "DNS", ros: "DNS приложения", Icon: SearchIcon, color: "text-warning", description: "DNS на хосте приложения" },
route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-blue-500", description: "Поиск активного маршрута" }, route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-info", description: "Поиск активного маршрута" },
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-orange-500", description: "Определение MTU пути" }, mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-warning", description: "Определение MTU пути" },
} }
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
@@ -80,31 +84,25 @@ function makeApiFetch(backendUrl: string) {
} }
} }
async function copyProbeText(text:string){
try{await navigator.clipboard.writeText(text);toast.success("Скопировано.")}
catch{toast.error("Не удалось скопировать. Разрешите доступ к буферу обмена или выделите текст вручную.")}
}
function inferProbeLineKind(line: string): OutputLine["kind"] { function inferProbeLineKind(line: string): OutputLine["kind"] {
const l = line.toLowerCase() const l = line.toLowerCase()
if (l.includes("error:") || (l.includes("timeout") && l.includes("seq="))) return "err" if (l.includes("error:") || (l.includes("timeout") && l.includes("seq="))) return "err"
if (l.includes("sent=") || l.includes("received=") || l.includes("packet-loss") || l.includes("✓")) return "ok" if (l.includes("✓")) return "ok"
if (l.startsWith(";;") || l.trim() === "") return "dim" if (l.startsWith(";;") || l.trim() === "") return "dim"
if (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header" if (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header"
return "normal" return "normal"
} }
function parseProbeOutput(text: string): OutputLine[] { function parseProbeOutput(text: string): OutputLine[] {
return text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) })) const lines = text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) }))
return lines.length > 1000 ? [{ text: "Показаны последние 1000 строк; начало вывода сокращено.", kind: "dim" }, ...lines.slice(-1000)] : lines
} }
interface BackendServerRow {
id: number
name: string
host: string
site: string
country: string
asn: string
type: Server["type"]
enabled: boolean
status?: Server["status"]
latency?: number | null
}
// SpeedProbeApiRow imported from probes-speed-probes-data-grid // SpeedProbeApiRow imported from probes-speed-probes-data-grid
@@ -321,7 +319,7 @@ function buildCommand(
case "bandwidth": case "bandwidth":
return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both` return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both`
case "dns": case "dns":
return `/resolve ${target} type=${opts.dnsType ?? "A"} server=1.1.1.1` return "DNS приложения: " + target + " · " + (opts.dnsType ?? "A") + " · резолвер хоста backend"
case "route": case "route":
return `/ip route lookup ip=${target}` return `/ip route lookup ip=${target}`
case "mtu": case "mtu":
@@ -331,11 +329,11 @@ function buildCommand(
// ─── small UI components ────────────────────────────────────────────────────── // ─── small UI components ──────────────────────────────────────────────────────
function NativeSelect({ value, onChange, children, className }: { function NativeSelect({ value, onChange, children, className, ariaLabel }: {
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string; ariaLabel?: string
}) { }) {
return ( return (
<select value={value} onChange={e => onChange(e.target.value)} <select aria-label={ariaLabel} value={value} onChange={e => onChange(e.target.value)}
className={cn( className={cn(
"h-8 min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-sm text-foreground", "h-8 min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-sm text-foreground",
"transition-colors outline-none", "transition-colors outline-none",
@@ -347,7 +345,8 @@ function NativeSelect({ value, onChange, children, className }: {
) )
} }
function OptionLabel({ children }: { children: React.ReactNode }) { function OptionLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
if (htmlFor) return <label htmlFor={htmlFor} className="block text-xs font-medium text-muted-foreground mb-1">{children}</label>
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p> return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
} }
@@ -355,9 +354,9 @@ function SegBtn<T extends string | number>({ value, current, onClick, children }
value: T; current: T; onClick: (v: T) => void; children: React.ReactNode value: T; current: T; onClick: (v: T) => void; children: React.ReactNode
}) { }) {
return ( return (
<button onClick={() => onClick(value)} <button type="button" aria-pressed={value === current} onClick={() => onClick(value)}
className={cn( className={cn(
"px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors", "px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50", value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50",
)}> )}>
{children} {children}
@@ -368,107 +367,18 @@ function SegBtn<T extends string | number>({ value, current, onClick, children }
// ─── terminal output ────────────────────────────────────────────────────────── // ─── terminal output ──────────────────────────────────────────────────────────
function TerminalOutput({ test }: { test: DiagTest }) { function TerminalOutput({ test }: { test: DiagTest }) {
const ref = useRef<HTMLDivElement>(null) const ref=useRef<HTMLDivElement>(null),follow=useRef(true)
const visible = test.lines.slice(0, test.totalLines) const visible=test.lines.slice(0,test.totalLines)
useEffect(()=>{if(ref.current&&follow.current)ref.current.scrollTop=ref.current.scrollHeight},[visible.length])
useEffect(() => { return <div ref={ref} role="region" aria-label={"Вывод "+TOOL_META[test.tool].label+" · "+test.srcServerName} tabIndex={0}
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight onScroll={()=>{const el=ref.current;if(el)follow.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}}
}, [visible.length]) className="h-full min-h-0 overflow-auto rounded-lg bg-muted/30 border px-3 py-3 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<div className="mb-2 text-muted-foreground [overflow-wrap:anywhere]">{test.source==="demo"?"Демо":test.tool==="dns"?"DNS приложения":test.srcServerName} · {test.command}</div>
return ( {visible.map((line,i)=><div key={i} className={cn("whitespace-pre-wrap [overflow-wrap:anywhere] leading-5",line.kind==="ok"&&"text-success",line.kind==="err"&&"text-destructive",line.kind==="dim"&&"text-muted-foreground",line.kind==="header"&&"text-foreground font-semibold",line.kind==="cmd"&&"text-warning",line.kind==="normal"&&"text-foreground")}>{line.text||" "}</div>)}
<div ref={ref} </div>
className="h-72 overflow-y-auto rounded-lg bg-zinc-950 dark:bg-zinc-900 border border-zinc-800 px-4 py-3 font-mono text-xs leading-relaxed">
{/* command line */}
<div className="mb-2 flex items-center gap-2">
<span className="text-zinc-500">[{test.srcServerName}]</span>
<span className="text-emerald-400">$</span>
<span className="text-zinc-300">{test.command}</span>
{test.status === "running" && (
<span className="inline-block size-1.5 rounded-full bg-emerald-400 animate-pulse ml-1" />
)}
</div>
{/* output */}
{visible.map((line, i) => (
<div key={i} className={cn(
"whitespace-pre leading-5",
line.kind === "ok" && "text-emerald-400",
line.kind === "err" && "text-red-400",
line.kind === "dim" && "text-zinc-500",
line.kind === "header" && "text-zinc-400 font-semibold",
line.kind === "cmd" && "text-amber-400",
line.kind === "normal" && "text-zinc-300",
)}>
{line.text || " "}
</div>
))}
{test.status === "running" && visible.length < test.lines.length && (
<div className="text-zinc-600 animate-pulse">…</div>
)}
</div>
)
} }
// ─── uptime speed probes (live) ─────────────────────────────────────────────── // ─── uptime speed probes (live) ───────────────────────────────────────────────
function ScheduleSpeedProbesLive({
apiFetch,
serversForName,
}: {
apiFetch: ReturnType<typeof makeApiFetch>
serversForName: Server[]
}) {
const [rows, setRows] = useState<SpeedProbeApiRow[]>([])
const [loading, setLoading] = useState(true)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setErr(null)
void apiFetch<{ probes: SpeedProbeApiRow[] }>("/api/uptime/speed-probes")
.then(d => {
if (!cancelled) setRows(d.probes ?? [])
})
.catch(e => {
if (!cancelled) setErr(e instanceof Error ? e.message : String(e))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [apiFetch])
const name = (id: string) => serversForName.find(s => s.id === id)?.name ?? id
if (loading) {
return (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-5 animate-spin" />
Загрузка расписания из БД…
</div>
)
}
if (err) {
return (
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertCircleIcon className="size-4 shrink-0" />
{err}
</div>
)
}
return (
<div className="flex flex-col gap-3">
<DataPageCard>
<ProbesSpeedProbesDataGrid rows={rows} serverName={name} />
</DataPageCard>
<p className="text-[11px] text-muted-foreground">
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API.
</p>
</div>
)
}
// ─── schedule tab ───────────────────────────────────────────────────────────── // ─── schedule tab ─────────────────────────────────────────────────────────────
const INIT_RULES: SchedRule[] = [ const INIT_RULES: SchedRule[] = [
@@ -492,21 +402,12 @@ function ScheduleTab({
}) { }) {
const [showAdd, setShowAdd] = useState(false) const [showAdd, setShowAdd] = useState(false)
const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1") const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
const [addTun, setAddTun] = useState("") const [selectedTunnel, setAddTun] = useState("")
const [addType, setAddType] = useState<SchedType>("ping") const [addType, setAddType] = useState<SchedType>("ping")
const [addMin, setAddMin] = useState(10) const [addMin, setAddMin] = useState(10)
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer]) const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
useEffect(() => { const addTun = addTunnels.some(t=>t.id===selectedTunnel) ? selectedTunnel : addTunnels[0]?.id ?? ""
if (defaultSrc) setAddSrc(defaultSrc)
}, [defaultSrc])
useEffect(() => {
const list = tunnelsForServer(addSrc)
if (list.length && !list.some(t => t.id === addTun)) {
setAddTun(list[0]?.id ?? "")
}
}, [addSrc, addTun, tunnelsForServer])
const tunnelName = (srcId: string, tunnelId: string) => const tunnelName = (srcId: string, tunnelId: string) =>
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
@@ -528,21 +429,21 @@ function ScheduleTab({
<Frame dense className="w-full overflow-hidden"> <Frame dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden"> <FramePanel className="p-0 overflow-hidden">
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium"> <div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
<PlusIcon className="size-4 text-muted-foreground" />Новое правило <PlusIcon aria-hidden="true" className="size-4 text-muted-foreground" />Новое правило
</div> </div>
<div className="px-4 py-4 flex flex-wrap items-end gap-3"> <div className="px-4 py-4 flex flex-wrap items-end gap-3">
<div><OptionLabel>Сервер</OptionLabel> <div><OptionLabel>Сервер</OptionLabel>
<NativeSelect value={addSrc} onChange={setAddSrc} className="min-w-[160px]"> <NativeSelect ariaLabel="Устройство расписания" value={addSrc} onChange={value=>{setAddSrc(value);setAddTun("")}} className="min-w-[160px]">
{serverOptions.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)} {serverOptions.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</NativeSelect> </NativeSelect>
</div> </div>
<div><OptionLabel>GRE-туннель</OptionLabel> <div><OptionLabel>GRE-туннель</OptionLabel>
<NativeSelect value={addTun} onChange={setAddTun} className="min-w-[150px]"> <NativeSelect ariaLabel="Туннель расписания" value={addTun} onChange={setAddTun} className="min-w-[150px]">
{addTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)} {addTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</NativeSelect> </NativeSelect>
</div> </div>
<div><OptionLabel>Тип</OptionLabel> <div><OptionLabel>Тип</OptionLabel>
<NativeSelect value={addType} onChange={v => setAddType(v as SchedType)}> <NativeSelect ariaLabel="Тип теста расписания" value={addType} onChange={v => setAddType(v as SchedType)}>
<option value="ping">Ping</option> <option value="ping">Ping</option>
<option value="bandwidth">BW-тест</option> <option value="bandwidth">BW-тест</option>
<option value="both">Ping + BW</option> <option value="both">Ping + BW</option>
@@ -566,7 +467,7 @@ function ScheduleTab({
</Frame> </Frame>
) : ( ) : (
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}> <Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
<PlusIcon className="size-4" />Добавить правило <PlusIcon aria-hidden="true" className="size-4" />Добавить правило
</Button> </Button>
)} )}
</div> </div>
@@ -576,16 +477,40 @@ function ScheduleTab({
// ─── page ───────────────────────────────────────────────────────────────────── // ─── page ─────────────────────────────────────────────────────────────────────
export default function ProbesPage() { export default function ProbesPage() {
return <Suspense fallback={<p role="status">Загрузка диагностики…</p>}><ProbesRoute /></Suspense>
}
function ProbesRoute() {
const params = useSearchParams()
const url = readProbeUrl(new URLSearchParams(params.toString()))
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (url.error) return <div role="alert" aria-label="Ссылка диагностики" className="p-4 space-y-2"><p>{url.error}</p><Button type="button" variant="outline" onClick={() => window.history.replaceState(null, "", patchProbeUrl(new URLSearchParams(params.toString()), null) + window.location.hash)}>Сбросить контекст диагностики</Button></div>
if (!prefsHydrated) return <p role="status">Загрузка настроек диагностики…</p>
return <ProbesContent key={JSON.stringify([mode, backendUrl])} />
}
function ProbesContent() {
const params = useSearchParams()
const { server } = readProbeUrl(new URLSearchParams(params.toString()))
const srcId = server ?? ""
const selectServer = (id: string) => window.history.pushState(null, "", patchProbeUrl(new URLSearchParams(params.toString()), id || null) + window.location.hash)
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const canRead = can("mm:servers:read")
const canRun = can("mm:servers:write")
const canReadGre = can("mm:filters:read")
const isLive = mode === "live" const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [liveServers, setLiveServers] = useState<Server[]>([]) const [liveServers, setLiveServers] = useState<Server[]>([])
const [liveLoad, setLiveLoad] = useState<"idle" | "loading" | "error">(() => (isLive ? "loading" : "idle")) const [liveLoad, setLiveLoad] = useState<"idle" | "loading" | "error">(() => (isLive ? "loading" : "idle"))
const [catalogError, setCatalogError] = useState<string | null>(null)
const [catalogRevision, setCatalogRevision] = useState(0)
const [catalogReadAt, setCatalogReadAt] = useState<number | null>(null)
const [greState, setGreState] = useState<{ id: string; state: "loading" | "ready" | "error"; error?: string } | null>(null)
const [greRevision, setGreRevision] = useState(0)
const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({}) const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({})
const [tool, setTool] = useState<DiagTool>("ping") const [tool, setTool] = useState<DiagTool>("ping")
const [srcId, setSrcId] = useState(servers[0]?.id ?? "srv1")
const [target, setTarget] = useState("8.8.8.8") const [target, setTarget] = useState("8.8.8.8")
const [pingCount, setPingCount] = useState(5) const [pingCount, setPingCount] = useState(5)
const [pingSize, setPingSize] = useState(64) const [pingSize, setPingSize] = useState(64)
@@ -594,44 +519,40 @@ export default function ProbesPage() {
const [traceUseDns, setTraceUseDns] = useState(false) const [traceUseDns, setTraceUseDns] = useState(false)
const [traceHops] = useState(30) const [traceHops] = useState(30)
const [dnsType, setDnsType] = useState<DnsType>("A") const [dnsType, setDnsType] = useState<DnsType>("A")
const [bwTunId, setBwTunId] = useState("") const [bwChoice, setBwChoice] = useState<{ serverId: string; id: string } | null>(null)
const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp") const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp")
const [bwDuration, setBwDuration] = useState(10) const [bwDuration, setBwDuration] = useState(10)
const [runBusy, setRunBusy] = useState(false) const [runBusy, setRunBusy] = useState(false)
/** В live: IPv4 для src-address (DNS A-запись к host API); undefined = грузим, null = не удалось */ /** В live: IPv4 для src-address (DNS A-запись к host API); undefined = грузим, null = не удалось */
const [rosSrcV4, setRosSrcV4] = useState<string | null | undefined>(undefined) const [source, setSource] = useState<{ key: string; ipv4?: string | null; error?: string; loading: boolean } | null>(null)
const [sourceRevision, setSourceRevision] = useState(0)
useEffect(() => { useEffect(() => {
if (!isLive) { if (!isLive || !canRead) return
queueMicrotask(() => setLiveLoad("idle")) let cancelled = false
return const ctrl = new AbortController()
} queueMicrotask(() => { if (!cancelled) { setLiveLoad("loading"); setCatalogError(null) } })
setLiveLoad("loading") void apiFetch<unknown>("/api/servers", { signal: ctrl.signal })
void apiFetch<BackendServerRow[]>("/api/servers") .then(raw => {
.then(rows => { const result = probeServersSchema.safeParse(raw)
const mapped: Server[] = rows.map(s => ({ if (!result.success) throw new Error("Некорректный ответ API: каталог устройств не подтверждён.")
id: String(s.id), if (cancelled) return
name: s.name || s.host, setLiveServers(result.data.map(s => ({
host: s.host, id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—",
model: "—", site: s.site ?? "", country: s.country ?? "UN", asn: s.asn ?? "", type: s.type,
os: "—", enabled: s.enabled, status: s.status ?? "unknown",
site: s.site, latency: s.latency != null ? Math.round(s.latency) : null, sessions: 0,
country: s.country || "UN", })))
asn: s.asn, setCatalogReadAt(Date.now())
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}))
setLiveServers(mapped)
setLiveLoad("idle") setLiveLoad("idle")
}) })
.catch(() => { .catch(error => {
setLiveServers([]) if (cancelled) return
setCatalogError(error instanceof Error ? error.message : "Не удалось прочитать устройства.")
setLiveLoad("error") setLiveLoad("error")
}) })
}, [isLive, apiFetch]) return () => { cancelled = true; ctrl.abort() }
}, [isLive, canRead, apiFetch, catalogRevision])
const allServers = useMemo(() => { const allServers = useMemo(() => {
if (!isLive) return servers if (!isLive) return servers
@@ -652,72 +573,71 @@ export default function ProbesPage() {
})) }))
), [allServers]) ), [allServers])
useEffect(() => { useEffect(() => {
if (!isLive) { if (!isLive || !canReadGre || liveLoad !== "idle" || !allServers.some(s => s.id === srcId && s.enabled)) return
setRosSrcV4(undefined)
return
}
const n = Number.parseInt(srcId, 10)
if (!Number.isFinite(n)) {
setRosSrcV4(undefined)
return
}
let cancelled = false let cancelled = false
setRosSrcV4(undefined) const ctrl = new AbortController()
void apiFetch<{ ipv4: string | null }>(`/api/servers/${n}/ros-src-address`) queueMicrotask(() => { if (!cancelled) setGreState({ id: srcId, state: "loading" }) })
.then((r) => { void apiFetch<unknown>("/api/filters/gre-tunnels?serverId=" + encodeURIComponent(srcId), { signal: ctrl.signal })
if (!cancelled) setRosSrcV4(r.ipv4) .then(raw => {
const result = filtersGreResponseSchema.safeParse(raw)
if (!result.success || result.data.tunnels.some(tunnel => tunnel.serverId !== srcId)) throw new Error("Некорректный ответ API: GRE выбранного устройства не подтверждены.")
if (cancelled) return
setGreByServer(prev => ({ ...prev, [srcId]: result.data.tunnels }))
setGreState({ id: srcId, state: "ready" })
}) })
.catch(() => { .catch(error => {
if (!cancelled) setRosSrcV4(null) if (cancelled) return
setGreState({ id: srcId, state: "error", error: error instanceof Error ? error.message : "Не удалось прочитать GRE." })
}) })
return () => { cancelled = true } return () => { cancelled = true; ctrl.abort() }
}, [isLive, srcId, apiFetch]) }, [isLive, canReadGre, liveLoad, allServers, srcId, apiFetch, greRevision])
const ensureGre = useCallback(async (serverId: string) => {
if (!isLive) return
try {
const d = await apiFetch<{ tunnels: GreTunnel[] }>(
`/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`,
)
setGreByServer(prev => ({ ...prev, [serverId]: d.tunnels }))
} catch {
setGreByServer(prev => ({ ...prev, [serverId]: [] }))
}
}, [isLive, apiFetch])
useEffect(() => {
if (!isLive || !srcId) return
void ensureGre(srcId)
}, [isLive, srcId, ensureGre])
const tunnelsForSrc = useMemo(() => { const tunnelsForSrc = useMemo(() => {
if (isLive) return greByServer[srcId] ?? [] if (isLive) return greByServer[srcId] ?? []
return greTunnels.filter(t => t.serverId === srcId) return greTunnels.filter(t => t.serverId === srcId)
}, [isLive, greByServer, srcId]) }, [isLive, greByServer, srcId])
useEffect(() => {
const first = allServers[0]?.id
if (!first) return
if (!allServers.some(s => s.id === srcId)) setSrcId(first)
}, [allServers, srcId])
useEffect(() => {
const list = isLive ? (greByServer[srcId] ?? []) : greTunnels.filter(t => t.serverId === srcId)
if (list.length && !list.some(t => t.id === bwTunId)) setBwTunId(list[0]!.id)
}, [isLive, greByServer, srcId, bwTunId])
// ── run state ── // ── run state ──
const [tests, setTests] = useState<DiagTest[]>([]) const [tests, setTests] = useState<DiagTest[]>([])
const [tab, setTab] = useState<"history" | "schedule">("history") const [tab, setTab] = useState<"history" | "schedule">("history")
const [rules, setRules] = useState<SchedRule[]>(INIT_RULES) const [rules, setRules] = useState<SchedRule[]>(INIT_RULES)
const nextId = useRef(1) const nextId = useRef(1)
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null) const targetRef = useRef<HTMLInputElement>(null)
const runButtonRef = useRef<HTMLButtonElement>(null)
/** Живой POST /probes/run — отмена через fetch AbortSignal */ /** Живой POST /probes/run — отмена через fetch AbortSignal */
const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null) const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null)
const runLock = useRef(false)
const alive = useRef(true)
useEffect(() => {
alive.current = true
return () => { alive.current = false; liveProbeRunRef.current?.ctrl.abort() }
}, [])
const bwTunnels = tunnelsForSrc const bwTunnels = tunnelsForSrc
const bwTunId = bwChoice?.serverId === srcId ? bwChoice.id : (bwTunnels[0]?.id ?? "")
const setBwTunId = (id: string) => setBwChoice({ serverId: srcId, id })
const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId]) const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId])
const sourceKey = JSON.stringify([srcId, srcServer?.host])
const sourceRequired = tool === "ping" || tool === "traceroute" || tool === "mtu"
const sourceCurrent = source?.key === sourceKey ? source : null
const rosSrcV4 = sourceCurrent?.ipv4
useEffect(() => {
if (!isLive || !canRead || liveLoad !== "idle" || !srcServer?.enabled || !sourceRequired) return
const host = srcServer.host, id = srcServer.id
if (!/^[1-9][0-9]*$/.test(id) || !Number.isSafeInteger(Number(id))) return
let cancelled = false
const ctrl = new AbortController()
queueMicrotask(() => { if (!cancelled) setSource({ key: sourceKey, loading: true }) })
void apiFetch<unknown>("/api/servers/" + id + "/ros-src-address", { signal: ctrl.signal })
.then(raw => { const ipv4 = readProbeSource(raw, host); if (!cancelled) setSource({ key: sourceKey, loading: false, ipv4 }) })
.catch(error => { if (!cancelled) setSource({ key: sourceKey, loading: false, error: error instanceof Error ? error.message : "Источник preview неизвестен." }) })
return () => { cancelled = true; ctrl.abort() }
}, [isLive, canRead, liveLoad, srcServer, sourceRequired, sourceKey, apiFetch, sourceRevision])
const greReady = !isLive || (greState?.id === srcId && greState.state === "ready")
const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId]) const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId])
// current command preview // current command preview
@@ -726,37 +646,21 @@ export default function ProbesPage() {
bwTarget: bwTun?.remoteAddress, bwProto, bwDuration, bwTarget: bwTun?.remoteAddress, bwProto, bwDuration,
}, isLive ? rosSrcV4 : undefined), [allServers, tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, traceUseDns, dnsType, bwTun, bwProto, bwDuration, isLive, rosSrcV4]) }, isLive ? rosSrcV4 : undefined), [allServers, tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, traceUseDns, dnsType, bwTun, bwProto, bwDuration, isLive, rosSrcV4])
// ── progressive reveal tick ──
useEffect(() => {
tickRef.current = setInterval(() => {
setTests(prev => {
const hasRunning = prev.some(t => t.status === "running")
if (!hasRunning) return prev
return prev.map(t => {
if (t.source === "live") return t
if (t.status !== "running") return t
const speed = t.tool === "dns" || t.tool === "route" ? t.lines.length : 1
const nextTotal = Math.min(t.totalLines + speed, t.lines.length)
const done = nextTotal >= t.lines.length
return { ...t, totalLines: nextTotal, status: done ? "done" : "running" }
})
})
}, 400)
return () => { if (tickRef.current) clearInterval(tickRef.current) }
}, [])
// ── run test ── // ── run test ──
const runTest = useCallback(async () => { const runTest = useCallback(async () => {
const srv = allServers.find(s => s.id === srcId) const srv = allServers.find(s => s.id === srcId)
if (!srv) return if (!canRun || (tool === "bandwidth" && (!bwTun?.enabled || !bwTun.remoteAddress.trim() || !greReady || (isLive && !canReadGre))) || !srv || !srv.enabled || runLock.current || (isLive && liveLoad !== "idle")) return
const id = String(nextId.current++) const id = String(nextId.current++)
const numericServerId = Number.parseInt(srcId, 10) const numericServerId = Number.parseInt(srcId, 10)
if (isLive) { if (isLive) {
if (!Number.isFinite(numericServerId)) return if (!Number.isFinite(numericServerId)) return
runLock.current = true
setRunBusy(true) setRunBusy(true)
const abortCtrl = new AbortController() const abortCtrl = new AbortController()
liveProbeRunRef.current = { testId: id, ctrl: abortCtrl } liveProbeRunRef.current = { testId: id, ctrl: abortCtrl }
let timedOut = false
const timeout = setTimeout(() => { timedOut = true; abortCtrl.abort() }, 60_000)
setTests(p => [{ setTests(p => [{
id, id,
tool, tool,
@@ -766,7 +670,7 @@ export default function ProbesPage() {
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target, target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
command: cmdPreview, command: cmdPreview,
startedAt: Date.now(), startedAt: Date.now(),
lines: [{ text: "Выполняется запрос к MikroTik через API…", kind: "dim" }], lines: [{ text: tool === "dns" ? "Выполняется DNS-запрос приложения…" : "Выполняется запрос к MikroTik через API…", kind: "dim" }],
totalLines: 1, totalLines: 1,
source: "live", source: "live",
}, ...p.slice(0, 9)]) }, ...p.slice(0, 9)])
@@ -793,32 +697,34 @@ export default function ProbesPage() {
bwProto, bwProto,
bwDuration, bwDuration,
} }
const res = await apiFetch<{ output: string }>(`/api/servers/${numericServerId}/probes/run`, { const res = await apiFetch<unknown>(`/api/servers/${numericServerId}/probes/run`, {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
signal: abortCtrl.signal, signal: abortCtrl.signal,
}) })
const lines = parseProbeOutput(res.output ?? "") if (!alive.current || abortCtrl.signal.aborted) return
const looksErr = (res.output ?? "").trim().toLowerCase().startsWith("error:") const output = readProbeOutput(res)
const lines = parseProbeOutput(output || "Запрос выполнен: API вернул пустой вывод.")
setTests(p => p.map(t => (t.id === id setTests(p => p.map(t => (t.id === id
? { ? {
...t, ...t,
lines, lines,
totalLines: lines.length, totalLines: lines.length,
status: looksErr ? "error" : "done", status: "done",
} }
: t))) : t)))
} catch (e) { } catch (e) {
const aborted = if (!alive.current) return
const aborted = abortCtrl.signal.aborted ||
(typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError") (typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError")
|| (e instanceof Error && e.name === "AbortError") || (e instanceof Error && e.name === "AbortError")
if (aborted) { if (aborted) {
setTests(p => p.map(t => (t.id === id setTests(p => p.map(t => (t.id === id
? { ? {
...t, ...t,
lines: [{ text: "Запрос отменён (стоп или закрытие запроса).", kind: "dim" }], lines: [{ text: timedOut ? "Время ожидания истекло. Результат на устройстве не подтверждён; повтор выполняется вручную." : "Ожидание отменено. Остановка на устройстве не подтверждена.", kind: timedOut ? "err" : "dim" }],
totalLines: 1, totalLines: 1,
status: "done", status: timedOut ? "error" : "cancelled",
} }
: t))) : t)))
} else { } else {
@@ -833,8 +739,13 @@ export default function ProbesPage() {
: t))) : t)))
} }
} finally { } finally {
clearTimeout(timeout)
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null
setRunBusy(false) runLock.current = false
if (alive.current) {
setRunBusy(false)
requestAnimationFrame(() => { if (alive.current && (document.activeElement === runButtonRef.current || document.activeElement === document.body)) (targetRef.current ?? runButtonRef.current)?.focus() })
}
} }
return return
} }
@@ -867,6 +778,10 @@ export default function ProbesPage() {
setTab("history") setTab("history")
}, [ }, [
isLive, isLive,
liveLoad,
canRun,
canReadGre,
greReady,
allServers, allServers,
srcId, srcId,
tool, tool,
@@ -879,7 +794,6 @@ export default function ProbesPage() {
traceHops, traceHops,
dnsType, dnsType,
bwTun, bwTun,
bwTunId,
bwProto, bwProto,
bwDuration, bwDuration,
cmdPreview, cmdPreview,
@@ -887,21 +801,27 @@ export default function ProbesPage() {
]) ])
const stopTest = (id: string) => { const stopTest = (id: string) => {
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort() if (liveProbeRunRef.current?.testId === id) { liveProbeRunRef.current.ctrl.abort(); return }
setTests(p => p.map(t => (t.id === id && t.status === "running" setTests(p => p.map(t => (t.id === id && t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length } ? { ...t, status: "cancelled" as const, totalLines: t.lines.length }
: t))) : t)))
} }
const clearTest = (id: string) => setTests(p => p.filter(t => t.id !== id)) const clearTest = (id: string) => {
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort()
setTests(p => p.filter(t => t.id !== id))
runButtonRef.current?.focus()
}
const running = tests.filter(t => t.status === "running") const running = tests.filter(t => t.status === "running")
if (isLive && liveLoad === "loading") { if (!canRead) return <p role="status">Для просмотра устройств требуется mm:servers:read.</p>
if (isLive && liveLoad === "loading" && catalogReadAt === null) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]} /> <PageHeader crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]} />
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground"> <div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<LoaderCircleIcon className="size-8 animate-spin opacity-50" /> <LoaderCircleIcon aria-hidden="true" className="size-8 motion-safe:animate-spin opacity-50" />
<p className="text-sm">Загрузка серверов из бекенда…</p> <p className="text-sm">Загрузка серверов из бекенда…</p>
</div> </div>
</div> </div>
@@ -912,7 +832,8 @@ export default function ProbesPage() {
<ServerRailLayout <ServerRailLayout
items={probeRailItems} items={probeRailItems}
selectedId={srcId} selectedId={srcId}
onSelect={setSrcId} onSelect={selectServer}
preserveSelection
showAll={false} showAll={false}
loading={isLive && liveLoad === "loading" && allServers.length === 0} loading={isLive && liveLoad === "loading" && allServers.length === 0}
header={ header={
@@ -921,14 +842,12 @@ export default function ProbesPage() {
actions={ actions={
<> <>
<ServerRailMobileButton /> <ServerRailMobileButton />
{isLive && <Button type="button" size="sm" variant="outline" aria-label="Обновить устройства диагностики" disabled={liveLoad === "loading"} onClick={() => setCatalogRevision(n => n + 1)}>Обновить</Button>}
{running.length > 0 {running.length > 0
? <Button variant="outline" size="sm" onClick={() => { ? <Button variant="outline" size="sm" onClick={() => {
liveProbeRunRef.current?.ctrl.abort() running.forEach(test => stopTest(test.id))
setTests(p => p.map(t => (t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length }
: t)))
}}> }}>
<SquareIcon className="size-4" />Остановить все <SquareIcon aria-hidden="true" className="size-4" />Остановить все
</Button> </Button>
: null} : null}
</> </>
@@ -938,35 +857,61 @@ export default function ProbesPage() {
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{!server && <p role="status">Выберите устройство для диагностики. Выбор сохраняется в URL.</p>}
{server && (!isLive || catalogReadAt !== null) && !srcServer && <div role="alert" aria-label="Устройство диагностики"><p>Указанное устройство отсутствует в текущем каталоге. Другое устройство автоматически не выбирается.</p><Button type="button" variant="outline" onClick={() => selectServer("")}>Сбросить выбор устройства</Button></div>}
{srcServer && !srcServer.enabled && <p role="status">Выбранное устройство выключено. Диагностика недоступна.</p>}
{isLive && liveLoad === "error" && ( {isLive && liveLoad === "error" && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-xs text-destructive flex items-center gap-2"> <div role="alert" aria-label="Каталог диагностики" className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
<AlertCircleIcon className="size-3.5 shrink-0" /> <p>{catalogReadAt === null ? "Устройства пока неизвестны." : "Не удалось обновить устройства. Последний список сохранён; запуск приостановлен."}</p>
Бекенд недоступен — переключитесь в режим «Демо» в настройках источника данных или проверьте URL API. <p>{catalogError}</p>
</div>
)}
{isLive && catalogReadAt !== null && <p role="status" className="text-xs text-muted-foreground">Каталог получен: {new Date(catalogReadAt).toLocaleTimeString("ru-RU")}{liveLoad === "loading" ? " · обновляется" : ""}</p>}
{isLive && liveLoad === "idle" && allServers.length === 0 && <p role="status">В каталоге нет устройств. Добавьте устройство в разделе «Серверы».</p>}
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демо: запросы не отправляются; результаты синтетические.</p>}
{isLive && (
<details className="text-xs text-muted-foreground"><summary className="cursor-pointer">Где выполняются тесты</summary><p className="pt-2">
Сетевые тесты выполняются на выбранном устройстве. DNS использует резолвер приложения. Traceroute ограничен примерно минутой; сокращение числа хопов указывается в ответе. Стоп отменяет ожидание, но не подтверждает остановку на устройстве.
</p></details>
)}
{!canRun && <p role="status" className="text-sm text-muted-foreground">Для запуска диагностики требуется mm:servers:write.</p>}
{isLive && tool === "bandwidth" && !canReadGre && <p role="status" className="text-sm text-muted-foreground">Для выбора GRE требуется mm:filters:read.</p>}
{isLive && tool === "bandwidth" && canReadGre && (
<div className="text-sm">
{greState?.id === srcId && greState.state === "error" ? <div role="alert" aria-label="GRE диагностики"><p className="text-destructive">GRE недоступны. Последний список сохранён; BW-тест приостановлен. {greState.error}</p><Button type="button" size="sm" variant="outline" onClick={() => setGreRevision(n => n + 1)}>Повторить GRE</Button></div>
: !greReady ? <p role="status">GRE выбранного устройства загружаются…</p>
: !bwTunnels.length ? <p role="status">У выбранного устройства нет GRE-туннелей.</p> : !bwTun?.enabled || !bwTun.remoteAddress.trim() ? <p role="status">Выберите включённый GRE с адресом назначения.</p> : null}
</div> </div>
)} )}
{isLive && ( {isLive && srcServer?.enabled && sourceRequired && (
<p className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для источника поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik. {sourceCurrent?.error ? <div role="alert" aria-label="Источник preview"><p>{sourceCurrent.error}</p><Button type="button" size="sm" variant="outline" onClick={() => setSourceRevision(n => n + 1)}>Повторить определение IPv4</Button></div>
</p> : !sourceCurrent || sourceCurrent.loading ? <p role="status">Определяем IPv4 для preview…</p>
: <p role="status">{sourceCurrent.ipv4 === null ? "IPv4 для preview не определён." : "IPv4 для preview: " + sourceCurrent.ipv4}</p>}
<p>Адрес справочный: при запуске определяется заново.</p>
</div>
)} )}
{/* ── tool selector + config ── */} {/* ── tool selector + config ── */}
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4"> <OpsPanel contentClassName="pt-4 pb-4 px-3 md:px-4 flex min-w-0 flex-col gap-4">
{/* tool chips */} {/* tool chips */}
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => { {(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => {
const active = tool === t const active = tool === t
return ( return (
<button key={t} onClick={() => setTool(t)} <button key={t} type="button" aria-pressed={active} title={m.description} onClick={() => setTool(t)}
className={cn( className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors", "flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active active
? "border-foreground bg-foreground text-background" ? "border-foreground bg-foreground text-background"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40", : "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}> )}>
<m.Icon className={cn("size-3.5", active ? "" : m.color)} /> <m.Icon aria-hidden="true" className={cn("size-3.5", active ? "" : m.color)} />
{m.label} {m.label}
</button> </button>
) )
@@ -979,11 +924,12 @@ export default function ProbesPage() {
{/* target — all tools except bandwidth */} {/* target — all tools except bandwidth */}
{tool !== "bandwidth" && ( {tool !== "bandwidth" && (
<div className="flex-1 min-w-[140px]"> <div className="flex-1 min-w-[140px]">
<OptionLabel> <OptionLabel htmlFor="probe-target">
{tool === "dns" ? "Домен" : tool === "route" ? "Destination IP" : "Цель (IP или домен)"} {tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель (IP или домен)"}
</OptionLabel> </OptionLabel>
<Input value={target} onChange={e => setTarget(e.target.value)} <Input id="probe-target" ref={targetRef} maxLength={1024} value={target} onChange={e => setTarget(e.target.value)}
placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"} placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"}
aria-label={tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель диагностики"}
className="font-mono text-sm" className="font-mono text-sm"
onKeyDown={e => e.key === "Enter" && runTest()} onKeyDown={e => e.key === "Enter" && runTest()}
/> />
@@ -994,7 +940,8 @@ export default function ProbesPage() {
{tool === "bandwidth" && ( {tool === "bandwidth" && (
<div> <div>
<OptionLabel>GRE-туннель (цель)</OptionLabel> <OptionLabel>GRE-туннель (цель)</OptionLabel>
<NativeSelect value={bwTunId} onChange={setBwTunId} className="min-w-[180px]"> <NativeSelect ariaLabel="GRE-туннель для BW-теста" value={bwTunnels.some(t => t.id === bwTunId) ? bwTunId : ""} onChange={setBwTunId} className="min-w-[180px]">
{!bwTunnels.some(t => t.id === bwTunId) && <option value="" disabled>Выберите GRE-туннель</option>}
{bwTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)} {bwTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</NativeSelect> </NativeSelect>
</div> </div>
@@ -1004,14 +951,14 @@ export default function ProbesPage() {
{tool === "ping" && ( {tool === "ping" && (
<> <>
<div> <div>
<OptionLabel>Кол-во</OptionLabel> <OptionLabel>Количество пакетов</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8"> <div className="flex flex-wrap rounded-lg border border-input min-h-8">
{[5, 10, 25, 100].map(n => <SegBtn key={n} value={n} current={pingCount} onClick={setPingCount}>{n}</SegBtn>)} {[5, 10, 25, 100].map(n => <SegBtn key={n} value={n} current={pingCount} onClick={setPingCount}>{n}</SegBtn>)}
</div> </div>
</div> </div>
<div> <div>
<OptionLabel>Размер (байт)</OptionLabel> <OptionLabel>Размер (байт)</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8"> <div className="flex flex-wrap rounded-lg border border-input overflow-hidden min-h-8">
{[64, 128, 512, 1472].map(n => <SegBtn key={n} value={n} current={pingSize} onClick={setPingSize}>{n}</SegBtn>)} {[64, 128, 512, 1472].map(n => <SegBtn key={n} value={n} current={pingSize} onClick={setPingSize}>{n}</SegBtn>)}
</div> </div>
</div> </div>
@@ -1033,7 +980,7 @@ export default function ProbesPage() {
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik. Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
</p> </p>
</div> </div>
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} /> <FormToggle ariaLabel="Разрешать имена хопов на устройстве" checked={traceUseDns} onChange={setTraceUseDns} />
</div> </div>
</> </>
)} )}
@@ -1042,7 +989,7 @@ export default function ProbesPage() {
<div> <div>
<OptionLabel>Тип записи</OptionLabel> <OptionLabel>Тип записи</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8"> <div className="flex rounded-lg border border-input overflow-hidden h-8">
{(["A", "AAAA", "MX", "NS", "TXT", "PTR"] as DnsType[]).map(t => <SegBtn key={t} value={t} current={dnsType} onClick={setDnsType}>{t}</SegBtn>)} {(["A", "AAAA", "MX", "NS", "TXT", "CNAME", "PTR"] as DnsType[]).map(t => <SegBtn key={t} value={t} current={dnsType} onClick={setDnsType}>{t}</SegBtn>)}
</div> </div>
</div> </div>
)} )}
@@ -1064,25 +1011,25 @@ export default function ProbesPage() {
</> </>
)} )}
<Button <Button ref={runButtonRef} type="button" aria-busy={runBusy}
onClick={() => void runTest()} onClick={() => void runTest()}
disabled={runBusy || !srcServer || (tool === "bandwidth" && !bwTunnels.length)} disabled={!canRun || (tool === "bandwidth" && (!bwTun?.enabled || !bwTun.remoteAddress.trim() || !greReady || (isLive && !canReadGre))) || runBusy || !srcServer?.enabled || (isLive && liveLoad !== "idle") || (tool === "bandwidth" && !bwTunnels.length)}
className="h-8 shrink-0 gap-1.5 self-end" className="h-8 shrink-0 gap-1.5 self-end"
> >
{runBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : <PlayIcon className="size-3.5" />} {runBusy ? <LoaderCircleIcon aria-hidden="true" className="size-3.5 motion-safe:animate-spin" /> : <PlayIcon aria-hidden="true" className="size-3.5" />}
Запустить Запустить
</Button> </Button>
</div> </div>
{/* RouterOS command preview */} {/* RouterOS command preview */}
<div className="flex items-center gap-2 rounded-lg bg-zinc-950 dark:bg-zinc-900 px-3 py-2 border border-zinc-800"> <div className="flex min-w-0 items-center gap-2 rounded-lg bg-muted/30 px-3 py-2 border">
<TerminalIcon className="size-3 text-zinc-500 shrink-0" /> <TerminalIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
<span className="text-zinc-500 text-xs font-mono shrink-0">[{srcServer?.name}]</span> <span className="hidden md:inline text-muted-foreground text-xs font-mono truncate max-w-24">[{tool === "dns" ? (isLive ? "backend" : "демо") : srcServer?.name}]</span>
<span className="text-emerald-400 text-xs font-mono shrink-0">$</span> <span className="hidden md:inline text-success text-xs font-mono shrink-0">$</span>
<span className="text-zinc-300 text-xs font-mono truncate">{cmdPreview}</span> <span className="min-w-0 flex-1 text-foreground text-xs font-mono [overflow-wrap:anywhere]">{cmdPreview}</span>
<button onClick={() => navigator.clipboard.writeText(cmdPreview)} <button type="button" aria-label="Скопировать параметры диагностики" onClick={() => void copyProbeText(cmdPreview)}
className="shrink-0 text-zinc-600 hover:text-zinc-300 transition-colors ml-auto"> className="shrink-0 text-muted-foreground hover:text-foreground transition-colors ml-auto">
<CopyIcon className="size-3" /> <CopyIcon aria-hidden="true" className="size-3" />
</button> </button>
</div> </div>
</OpsPanel> </OpsPanel>
@@ -1094,7 +1041,7 @@ export default function ProbesPage() {
["history", "История тестов"], ["history", "История тестов"],
["schedule", "Расписание"], ["schedule", "Расписание"],
] as const).map(([t, label]) => ( ] as const).map(([t, label]) => (
<button key={t} onClick={() => setTab(t)} <button key={t} type="button" aria-pressed={tab === t} onClick={() => setTab(t)}
className={cn( className={cn(
"px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors", "px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
tab === t tab === t
@@ -1116,7 +1063,7 @@ export default function ProbesPage() {
{tab === "history" && ( {tab === "history" && (
tests.length === 0 ? ( tests.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2"> <div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
<TerminalIcon className="size-8 opacity-20" /> <TerminalIcon aria-hidden="true" className="size-8 opacity-20" />
<p className="text-sm">Запустите тест — результаты появятся здесь</p> <p className="text-sm">Запустите тест — результаты появятся здесь</p>
</div> </div>
) : ( ) : (
@@ -1124,52 +1071,46 @@ export default function ProbesPage() {
{tests.map(test => { {tests.map(test => {
const { Icon, color, label } = TOOL_META[test.tool] const { Icon, color, label } = TOOL_META[test.tool]
return ( return (
<Frame key={test.id} dense className="w-full overflow-hidden"> <Frame key={test.id} dense aria-busy={test.status === "running"} className="w-full h-[60dvh] min-h-0 flex flex-col overflow-hidden">
<FramePanel className="p-0 overflow-hidden"> <FramePanel className="p-0 flex-1 min-h-0 flex flex-col overflow-hidden">
{/* header */} {/* header */}
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20"> <div className="shrink-0 flex flex-wrap items-center justify-between gap-2 px-3 py-2.5 border-b bg-muted/20">
<div className="flex items-center gap-2 min-w-0"> <div className="flex flex-wrap items-center gap-2 min-w-0 w-full md:w-auto">
<Icon className={cn("size-4 shrink-0", color)} /> <Icon aria-hidden="true" className={cn("size-4 shrink-0", color)} />
<span className="text-sm font-medium">{label}</span> <span className="text-sm font-medium">{label}</span>
<span className="text-xs font-mono text-muted-foreground truncate">{test.target}</span> <span className="min-w-0 max-w-[70%] text-xs font-mono text-muted-foreground truncate" title={test.target}>{test.target}</span>
<span className="text-[10px] text-muted-foreground">← {test.srcServerName}</span> <span className="w-full md:w-auto max-w-full text-xs text-muted-foreground truncate" title={test.tool === "dns" ? "DNS приложения" : test.srcServerName}>{test.tool === "dns" ? "DNS приложения" : "← " + test.srcServerName}</span>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex flex-wrap items-center gap-2 min-w-0">
{test.status === "running" && ( {test.status === "running" && (
<> <>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground"> <span role="status" className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="size-1.5 rounded-full bg-emerald-400 animate-pulse" /> <span className="size-1.5 rounded-full bg-info motion-safe:animate-pulse" />
запущен ожидаем ответ
</span> </span>
<Button size="sm" variant="outline" className="h-6 px-2 text-xs" <Button size="sm" variant="outline" type="button" className="h-11 md:h-7 px-2 text-xs"
onClick={() => stopTest(test.id)}> onClick={() => stopTest(test.id)}>
<SquareIcon className="size-3" />Стоп <SquareIcon aria-hidden="true" className="size-3" />Стоп
</Button> </Button>
</> </>
)} )}
{test.status === "done" && test.source !== "live" && ( {test.status === "done" && <span role="status" className="text-xs text-muted-foreground">{test.source === "live" ? "ответ API" : "демо"}</span>}
<span className="text-[11px] text-muted-foreground"> {test.status === "cancelled" && <span role="status" className="text-[11px] text-muted-foreground">ожидание отменено</span>}
{((test.lines.length * 0.4)).toFixed(1)}с
</span>
)}
{test.status === "done" && test.source === "live" && (
<span className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">live</span>
)}
{test.status === "error" && ( {test.status === "error" && (
<span className="text-[11px] text-destructive">ошибка</span> <span role="alert" className="text-xs text-destructive">ошибка</span>
)} )}
<button onClick={() => navigator.clipboard.writeText(test.lines.map(l => l.text).join("\n"))} <button type="button" aria-label={"Скопировать показанный вывод · " + label + " · " + test.id} onClick={() => void copyProbeText(test.lines.map(l => l.text).join("\n"))}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-colors"> className="size-11 md:size-8 flex items-center justify-center rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:text-foreground hover:bg-muted transition-colors">
<CopyIcon className="size-3.5" /> <CopyIcon aria-hidden="true" className="size-3.5" />
</button> </button>
<button onClick={() => clearTest(test.id)} <button type="button" aria-label={"Удалить результат · " + label + " · " + test.id} onClick={() => clearTest(test.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"> className="size-11 md:size-8 flex items-center justify-center rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:text-destructive hover:bg-destructive/10 transition-colors">
<Trash2Icon className="size-3.5" /> <Trash2Icon aria-hidden="true" className="size-3.5" />
</button> </button>
</div> </div>
</div> </div>
{/* terminal output */} {/* terminal output */}
<div className="p-3"> <div className="flex-1 min-h-0 p-3">
<TerminalOutput test={test} /> <TerminalOutput test={test} />
</div> </div>
</FramePanel> </FramePanel>
@@ -1183,9 +1124,10 @@ export default function ProbesPage() {
{/* schedule */} {/* schedule */}
{tab === "schedule" && ( {tab === "schedule" && (
isLive isLive
? <ScheduleSpeedProbesLive apiFetch={apiFetch} serversForName={allServers} /> ? <ProbesSpeedSchedule base={backendUrl} serversForName={allServers} />
: ( : (
<ScheduleTab <ScheduleTab
key={srcId}
rules={rules} rules={rules}
setRules={setRules} setRules={setRules}
serverOptions={allServers} serverOptions={allServers}
+373 -224
View File
@@ -1,48 +1,49 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useRef, useMemo, useState, Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { readRecursiveUrl, patchRecursiveUrl, type RecursiveUrlPatch, type RecursiveUrlState } from "@/lib/recursive-route-url-state"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { import {
RecursiveRoutesDataGrid, RecursiveRoutesDataGrid,
type RecursiveRouteGroup, type RecursiveRouteGroup,
inferCountry,
} from "@/components/data-grids/recursive-routes-data-grid" } from "@/components/data-grids/recursive-routes-data-grid"
import { RecursiveGatewayChoices, type GatewayOption } from "@/components/recursive-gateway-choices"
import { FormField, SectionTitle } from "@/components/form-kit" import { FormField, SectionTitle } from "@/components/form-kit"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { StatusDot } from "@/components/status-dot" import { StatusDot } from "@/components/status-dot"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { can } from "@/lib/auth"
import { recursiveReadSchema, recursiveReceiptSchema, recursiveGatewaysSchema, recursiveServersSchema, type RecursiveRouteRow } from "@/lib/recursive-route-response"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data" import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react" import { PlusIcon, TrashIcon, SearchIcon, XIcon, CheckIcon, AlertCircleIcon, RefreshCwIcon, HistoryIcon, AlertTriangleIcon } from "lucide-react"
import { toast } from "sonner"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
import { type ServerTileItem } from "@/components/server-tile-rail" import { type ServerTileItem } from "@/components/server-tile-rail"
interface BackendServer { interface BackendServer {
id: number id: number
name: string name: string
host: string host: string
type: "jump-host" | "exit-node" | "home-router" type?: "jump-host" | "exit-node" | "home-router"
site: string site: string
country: string country: string
asn: string asn: string
enabled: boolean enabled: boolean
status: "online" | "offline" | null status: "online" | "offline" | "degraded" | null
latency: number | null latency: number | null
} }
interface GatewayOption {
id: string
name: string
ip: string
status: "up" | "down"
}
const COUNTRY_OPTIONS = [ const COUNTRY_OPTIONS = [
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" }, { code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" }, { code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
@@ -58,21 +59,7 @@ const COUNTRY_OPTIONS = [
{ code: "NO", label: "Норвегия" }, { code: "NO", label: "Норвегия" },
] ]
interface RecursiveRouteRow { type RouteGroup = RecursiveRouteGroup
id: string
dstAddress: string
gateway: string
distance: number
scope: number | null
targetScope: number | null
routingTable: string
checkGateway: string
comment: string
disabled: boolean
country: string
}
interface RouteGroup extends RecursiveRouteGroup {}
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
@@ -80,7 +67,7 @@ function makeApiFetch(backendUrl: string) {
} }
} }
function mapBackendServer(s: BackendServer): Server { function mapBackendServer(s: BackendServer): Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] } {
return { return {
id: String(s.id), id: String(s.id),
name: s.name || s.host, name: s.name || s.host,
@@ -92,7 +79,7 @@ function mapBackendServer(s: BackendServer): Server {
asn: s.asn, asn: s.asn,
type: s.type, type: s.type,
enabled: s.enabled, enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"], status: s.status,
latency: s.latency != null ? Math.round(s.latency) : null, latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0, sessions: 0,
} }
@@ -107,6 +94,7 @@ interface RouteForm {
interface RouteEndpointForm { interface RouteEndpointForm {
id: string id: string
disabled: boolean
gateway: string gateway: string
distance: number distance: number
scope: number | null scope: number | null
@@ -117,6 +105,7 @@ interface RouteEndpointForm {
const newEndpoint = (): RouteEndpointForm => ({ const newEndpoint = (): RouteEndpointForm => ({
id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
disabled: false,
gateway: "", gateway: "",
distance: 1, distance: 1,
scope: null, scope: null,
@@ -144,7 +133,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
<label className="text-sm font-medium">Страна</label> <label className="text-sm font-medium">Страна</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Input placeholder="Поиск или код (RU, DE…)" value={query} <Input aria-label="Код страны" placeholder="Поиск или код (RU, DE…)" value={query}
onChange={e => { onChange={e => {
const v = e.target.value.toUpperCase().slice(0, 3) const v = e.target.value.toUpperCase().slice(0, 3)
setQuery(v) setQuery(v)
@@ -167,7 +156,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
</span> </span>
)} )}
</div> </div>
<div className="grid grid-cols-5 gap-1.5 max-h-40 overflow-y-auto pr-0.5"> <div className="grid grid-cols-4 sm:grid-cols-5 gap-1.5">
{visible.map(c => ( {visible.map(c => (
<button key={c.code} type="button" <button key={c.code} type="button"
onClick={() => { onChange(c.code); setQuery("") }} onClick={() => { onChange(c.code); setQuery("") }}
@@ -188,133 +177,129 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
} }
function RouteSheet({ function RouteSheet({
open, mode, initial, onSave, onClose, gateways, open, mode, initial, onSave, onClose, gateways, busy, saveError, returnFocus,
}: { }: {
open: boolean open: boolean
mode: "create" | "edit" mode: "create" | "edit"
initial: RouteForm initial: RouteForm
onSave: (v: RouteForm) => void onSave: (v: RouteForm) => Promise<boolean>
onClose: () => void onClose: () => void
gateways: GatewayOption[] gateways: GatewayOption[]
busy: boolean
saveError: string | null
returnFocus: () => HTMLElement | null
}) { }) {
const [form, setForm] = useState<RouteForm>(initial) const [form, setForm] = useState<RouteForm>(initial)
const [endpointPage, setEndpointPage] = useState(0)
const lastEndpointPage = Math.max(0, Math.ceil(form.endpoints.length / 10) - 1)
if (endpointPage > lastEndpointPage) setEndpointPage(lastEndpointPage)
const currentEndpointPage = Math.min(endpointPage, lastEndpointPage)
const endpointOffset = currentEndpointPage * 10
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setForm(initial); setError(null) }, [initial, open]) useEffect(() => { setForm(initial); setError(null); setEndpointPage(0) }, [initial, open])
const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v })) const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) => const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) =>
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) })) setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
function handleSave() { const saveLock = useRef(false)
async function handleSave() {
if (busy || saveLock.current) return
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return } if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return } if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
if (form.endpoints.some(ep => !ep.gateway.trim())) { setError("У каждой конечной точки должен быть Gateway"); return } const emptyGateway = form.endpoints.findIndex(ep => !ep.gateway.trim())
if (emptyGateway >= 0) { setEndpointPage(Math.floor(emptyGateway / 10)); setError(`У каждой конечной точки должен быть Gateway (Endpoint ${emptyGateway + 1})`); return }
if (!form.routingTable.trim()) { setError("Укажите таблицу маршрутизации"); return }
const invalidEndpoint = form.endpoints.findIndex(ep => [ep.distance, ep.scope, ep.targetScope].some(value => value !== null && (!Number.isInteger(value) || value < 0 || value > 255)))
if (invalidEndpoint >= 0) { setEndpointPage(Math.floor(invalidEndpoint / 10)); setError(`Distance, Scope и T.Scope должны быть целыми числами от 0 до 255 (Endpoint ${invalidEndpoint + 1})`); return }
setError(null) setError(null)
onSave(form) saveLock.current = true
try { await onSave(form) } finally { saveLock.current = false }
} }
return ( return (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}> <Sheet open={open} onOpenChange={v => { if (!v && !busy && !saveLock.current) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}> <SheetContent side="right" finalFocus={returnFocus} className="data-[side=right]:w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0"> <SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div> <div>
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle> <SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription> <SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
</div> </div>
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5"><XIcon className="size-4" /></Button> <Button type="button" variant="ghost" size="icon-sm" disabled={busy} aria-label="Закрыть форму маршрута" onClick={onClose} className="shrink-0 mt-0.5"><XIcon aria-hidden="true" className="size-4" /></Button>
</div> </div>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5"> <fieldset disabled={busy} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle> <SectionTitle>Основные</SectionTitle>
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24"> <FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} /> <Input aria-label="Адрес назначения" className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
</FormField> </FormField>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SectionTitle>Конечные точки</SectionTitle> <SectionTitle>Конечные точки</SectionTitle>
{form.endpoints.length > 10 && <div className="flex flex-wrap items-center gap-2">
<Input key={currentEndpointPage} type="number" min={1} max={form.endpoints.length} defaultValue={endpointOffset + 1} aria-label="Перейти к endpoint" aria-describedby="recursive-endpoint-jump-hint" className="w-24 min-h-11" onKeyDown={event => {
if (event.key !== "Enter") return
event.preventDefault()
const number = Number(event.currentTarget.value)
if (Number.isInteger(number) && number >= 1 && number <= form.endpoints.length) { setEndpointPage(Math.floor((number - 1) / 10)); setError(null) }
else setError("Укажите номер endpoint от 1 до " + form.endpoints.length)
}} />
<span id="recursive-endpoint-jump-hint" className="text-xs text-muted-foreground">Номер endpoint → Enter</span>
<span role="status" className="text-xs text-muted-foreground">Endpoints {endpointOffset + 1}–{Math.min(endpointOffset + 10, form.endpoints.length)} из {form.endpoints.length}</span>
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущие endpoints формы" disabled={currentEndpointPage === 0} onClick={() => setEndpointPage(currentEndpointPage - 1)}>Назад</Button>
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующие endpoints формы" disabled={currentEndpointPage === lastEndpointPage} onClick={() => setEndpointPage(currentEndpointPage + 1)}>Далее</Button>
</div>}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{form.endpoints.map((ep, idx) => ( {form.endpoints.slice(endpointOffset, endpointOffset + 10).map((ep, idx) => (
<div key={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3"> <div key={ep.id} data-recursive-endpoint-form={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide"> <span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Endpoint {idx + 1} Endpoint {endpointOffset + idx + 1}{ep.disabled ? " · отключён" : ""}
</span> </span>
<button <button
type="button" type="button"
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))} onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
className="text-muted-foreground hover:text-destructive transition-colors" className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive transition-colors"
aria-label={`Удалить конечную точку ${endpointOffset + idx + 1}`}
disabled={form.endpoints.length <= 1} disabled={form.endpoints.length <= 1}
> >
<TrashIcon className="size-3.5" /> <TrashIcon aria-hidden="true" className="size-3.5" />
</button> </button>
</div> </div>
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} /> <EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway"> <FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} /> <Input aria-label="Шлюз" className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
</FormField> </FormField>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<FormField label="Distance (приоритет)"> <FormField label="Distance (приоритет)">
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} /> <Input aria-label="Distance (приоритет)" type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value))} />
</FormField> </FormField>
<FormField label="Check Gateway"> <FormField label="Check Gateway">
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} /> <Input aria-label="Check Gateway" className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
</FormField> </FormField>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<FormField label="Scope"> <FormField label="Scope">
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} /> <Input aria-label="Scope" type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
</FormField> </FormField>
<FormField label="T.Scope"> <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)} /> <Input aria-label="T.Scope" type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
</FormField> </FormField>
</div> </div>
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1"> <RecursiveGatewayChoices gateways={gateways} selectedValue={ep.gateway} endpointNumber={endpointOffset + idx + 1} onChange={value => setEp(ep.id, "gateway", value)} />
{gateways.map((g) => {
const value = `${g.ip}%${g.name}`
const selected = ep.gateway === value
const country = inferCountry(g.name)
return (
<button
key={`${ep.id}-${g.id}`}
type="button"
onClick={() => setEp(ep.id, "gateway", value)}
className={cn(
"text-left rounded-lg border px-3 py-2.5 transition-all",
selected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
)}
>
<div className="flex items-center gap-2">
<span className={cn("size-1.5 rounded-full shrink-0", g.status === "up" ? "bg-emerald-500" : "bg-red-500")} />
{country && <Flag code={country} className="shrink-0" />}
<span className="font-mono text-xs font-semibold flex-1 truncate">{g.name}</span>
{selected && <CheckIcon className="size-3.5 text-primary shrink-0" />}
</div>
<div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono">
<span className="text-muted-foreground/50 break-all">{g.ip}</span>
<span className="text-muted-foreground/30">→</span>
<span className={cn("min-w-0 break-all whitespace-normal leading-tight", selected ? "text-primary font-medium" : "")}>
{value}
</span>
</div>
</button>
)
})}
</div>
</div> </div>
))} ))}
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}> <Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => { setEndpointPage(Math.floor(form.endpoints.length / 10)); setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] })) }}>
<PlusIcon className="size-3.5" />Добавить endpoint <PlusIcon aria-hidden="true" className="size-3.5" />Добавить endpoint
</Button> </Button>
{gateways.length === 0 && ( {gateways.length === 0 && (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground"> <div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
@@ -326,16 +311,18 @@ function RouteSheet({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SectionTitle>Параметры</SectionTitle> <SectionTitle>Параметры</SectionTitle>
<FormField label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField> <FormField label="Routing Table"><Input aria-label="Таблица маршрутизации" className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
<FormField label="Комментарий"> <FormField label="Комментарий">
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} /> <Input aria-label="Комментарий" className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</FormField> </FormField>
</div> </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>} {saveError && <p role="alert" className="text-sm text-destructive">{saveError}</p>}
</div> {busy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения роутера…</p>}
{error && <div role="alert" className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon aria-hidden="true" className="size-4 shrink-0" />{error}</div>}
</fieldset>
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2"> <SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button> <Button type="button" variant="outline" disabled={busy} onClick={onClose} className="h-8 flex-none">Отмена</Button>
<Button onClick={handleSave} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button> <Button type="button" disabled={busy} onClick={() => void handleSave()} className="h-8 flex-none"><CheckIcon aria-hidden="true" className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -344,37 +331,81 @@ function RouteSheet({
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) { function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
return ( return (
<span className={cn( <Badge size="xs" className="shrink-0 text-foreground"
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0", variant={type === "home-router" ? "success-light" : type === "jump-host" ? "primary-light" : "info-light"}>
type === "home-router" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20" :
type === "jump-host"
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
)}>
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"} {type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
</span> </Badge>
) )
} }
export default function RecursiveRoutesPage() { export default function RecursiveRoutesPage() {
return <Suspense fallback={<p role="status" className="p-6 text-sm text-muted-foreground">Загрузка маршрутов…</p>}><RecursiveRoutesRoute /></Suspense>
}
function RecursiveRoutesRoute() {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const params = useSearchParams(), context = readRecursiveUrl(new URLSearchParams(params.toString()))
const sourceKey = JSON.stringify([mode, backendUrl])
const pendingSources = useRef(new Set<string>())
const [pending, setPending] = useState<string[]>([])
const [readVersions, setReadVersions] = useState<Record<string, number>>({})
const beginWrite = useCallback(() => {
if (pendingSources.current.has(sourceKey)) return false
pendingSources.current.add(sourceKey); setPending([...pendingSources.current]); return true
}, [sourceKey])
const endWrite = useCallback(() => {
pendingSources.current.delete(sourceKey); setPending([...pendingSources.current])
setReadVersions(versions => ({ ...versions, [sourceKey]: (versions[sourceKey] ?? 0) + 1 }))
}, [sourceKey])
const patchUrl = useCallback((patch: RecursiveUrlPatch, replace = false) => {
const url = new URL(window.location.href); url.search = patchRecursiveUrl(url.searchParams, patch).toString()
const target = url.pathname + url.search + url.hash
if (replace) window.history.replaceState(null, "", target); else window.history.pushState(null, "", target)
}, [])
return <RecursiveRoutesContent key={JSON.stringify([mode, backendUrl, context.serverId, context.error])} mode={mode} backendUrl={backendUrl} context={context} patchUrl={patchUrl} beginWrite={beginWrite} endWrite={endWrite} sharedBusy={pending.includes(sourceKey)} readVersion={readVersions[sourceKey] ?? 0} />
}
function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrite, endWrite, sharedBusy, readVersion }: {
beginWrite: () => boolean; endWrite: () => void; sharedBusy: boolean; readVersion: number
mode: string; backendUrl: string; context: RecursiveUrlState; patchUrl: (patch: RecursiveUrlPatch, replace?: boolean) => void
}) {
const isLive = mode === "live" const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [servers, setServers] = useState<Server[]>([]) const [servers, setServers] = useState<Array<Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] }>>([])
const [selectedServerId, setSelectedServerId] = useState<string>("") const [selectedServerId, setSelectedServerId] = useState<string>(context.serverId ?? "")
const [rows, setRows] = useState<RecursiveRouteRow[]>([]) const [rows, setRows] = useState<RecursiveRouteRow[]>([])
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null) const [busy, setBusy] = useState<"load" | "apply" | null>(null)
const [search, setSearch] = useState("") const search = context.q
const setSearch = (q: string) => patchUrl({ q }, true)
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create") const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm()) const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null) const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([]) const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null) const expandedGroupKey = context.route && context.table ? JSON.stringify([context.route.toLowerCase(), context.table]) : null
const [opError, setOpError] = useState<string | null>(null) const [opError, setOpError] = useState<string | null>(null)
const [liveStale, setLiveStale] = useState(true)
const [routeOwner, setRouteOwner] = useState("")
const [readAt, setReadAt] = useState<string | null>(null)
const [writeAt, setWriteAt] = useState<string | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [gatewayError, setGatewayError] = useState<string | null>(null)
const historyEpoch = useRef(0)
const alive = useRef(false)
const readEpoch = useRef(0), gatewayEpoch = useRef(0), writeLock = useRef(false)
useEffect(() => {
alive.current = true
return () => { alive.current = false }
}, [])
const [historyOpen, setHistoryOpen] = useState(false)
const [historyLoading, setHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState<string | null>(null)
const [historyRestoring, setHistoryRestoring] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */ /** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
const [liveServerListReady, setLiveServerListReady] = useState(false) const [liveServerListReady, setLiveServerListReady] = useState(false)
const [inventoryTick, setInventoryTick] = useState(0)
useEffect(() => { useEffect(() => {
if (!isLive) { if (!isLive) {
@@ -385,126 +416,175 @@ export default function RecursiveRoutesPage() {
setRows([]) setRows([])
return return
} }
// eslint-disable-next-line react-hooks/set-state-in-effect
setLiveServerListReady(false) setLiveServerListReady(false)
apiFetch<BackendServer[]>("/api/servers") let active = true
apiFetch<unknown>("/api/servers")
.then((data) => { .then((data) => {
const mapped = data.map(mapBackendServer) if (!active) return
const parsed = recursiveServersSchema.safeParse(data)
if (!parsed.success) throw new Error("Некорректный список устройств в ответе API")
const mapped = parsed.data.map(s => mapBackendServer({ ...s, type: s.type, site: s.site ?? "", asn: s.asn ?? "", status: s.status ?? null, latency: s.latency ?? null }))
setServers(mapped) setServers(mapped)
setSelectedServerId(prev => (mapped.some(s => s.id === prev) ? prev : (mapped[0]?.id ?? ""))) setSelectedServerId(context.serverId ?? mapped[0]?.id ?? "")
if (!context.serverId && !context.error && mapped[0]) patchUrl({ serverId: mapped[0].id }, true)
}) })
.catch(() => { .catch(error => {
if (!active) return
setOpError(error instanceof Error ? error.message : "Не удалось загрузить устройства")
setServers([]) setServers([])
setSelectedServerId("") setSelectedServerId("")
}) })
.finally(() => { .finally(() => {
setLiveServerListReady(true) if (active) setLiveServerListReady(true)
}) })
}, [isLive, apiFetch]) return () => { active = false }
}, [isLive, apiFetch, inventoryTick, context.serverId, context.error, patchUrl])
const loadRoutes = useCallback(async () => { const loadRoutes = useCallback(async () => {
if (!isLive || !liveServerListReady || !selectedServerId) return if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId) || writeLock.current) return
setOpError(null) const epoch = ++readEpoch.current
setBusy("load") setOpError(null); setBusy("load"); setLiveStale(true)
try { try {
const res = await apiFetch<{ routes: RecursiveRouteRow[] }>(`/api/recursive-routes?serverId=${selectedServerId}`) const parsed = recursiveReadSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
setRows(res.routes) if (!parsed.success) throw new Error("Некорректный снимок маршрутов в ответе API")
} catch (e) { const res = parsed.data
setRows([]) if (String(res.serverId) !== selectedServerId) throw new Error("Маршруты принадлежат другому устройству")
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты") if (!alive.current || epoch !== readEpoch.current) return
setRows(res.routes); setRouteOwner(selectedServerId); setLiveStale(res.stale)
if (res.live) setReadAt(new Date().toISOString())
else setOpError(res.error ?? "Роутер недоступен")
} catch (error) {
if (!alive.current || epoch !== readEpoch.current) return
setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось загрузить маршруты")
} finally { } finally {
setBusy(null) if (alive.current && epoch === readEpoch.current) setBusy(null)
} }
}, [isLive, liveServerListReady, selectedServerId, apiFetch]) }, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
const loadGateways = useCallback(async () => { const loadGateways = useCallback(async () => {
if (!isLive || !liveServerListReady || !selectedServerId) return if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId)) return
const epoch = ++gatewayEpoch.current
try { try {
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`) const parsed = recursiveGatewaysSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/gateways/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
setGatewayOptions(res.gateways) if (!parsed.success) throw new Error("Некорректный список шлюзов в ответе API")
} catch { const res = parsed.data
setGatewayOptions([]) if (String(res.serverId) !== selectedServerId) throw new Error("Шлюзы принадлежат другому устройству")
if (!alive.current || epoch !== gatewayEpoch.current) return
setGatewayOptions(res.gateways); setGatewayError(null)
} catch (error) {
if (!alive.current || epoch !== gatewayEpoch.current) return
setGatewayOptions([]); setGatewayError(error instanceof Error ? error.message : "Не удалось загрузить шлюзы")
} }
}, [isLive, liveServerListReady, selectedServerId, apiFetch]) }, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
void loadRoutes() void loadRoutes(); void loadGateways()
}, [loadRoutes]) const reads = readEpoch, gateways = gatewayEpoch
return () => { reads.current++; gateways.current++ }
}, [loadRoutes, loadGateways, readVersion])
useEffect(() => { const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
// eslint-disable-next-line react-hooks/set-state-in-effect if (!isLive || !selectedServerId || liveStale || routeOwner !== selectedServerId || busy !== null || sharedBusy || writeLock.current || !can("mm:network:write")) return false
void loadGateways() if (!beginWrite()) return false
}, [loadGateways]) writeLock.current = true; readEpoch.current++
setOpError(null); setSaveError(null); setBusy("apply")
const saveToDb = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("save")
try { try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes", { const raw = await apiFetch<unknown>("/api/recursive-routes", { method: "PUT", body: JSON.stringify({ serverId: selectedServerId, routes: next }) })
method: "PUT", const parsed = recursiveReceiptSchema.safeParse(raw)
body: JSON.stringify({ serverId: selectedServerId, routes: rows }), if (!parsed.success || String(parsed.data.serverId) !== selectedServerId || parsed.data.pushedRoutes !== next.length) throw new Error("Сервер не подтвердил применение маршрутов")
}) if (!alive.current) return false
await loadRoutes() setRows(parsed.data.routes); setLiveStale(false); setWriteAt(new Date().toISOString())
} catch (e) { toast.success("Маршруты применены на роутер")
setOpError(e instanceof Error ? e.message : "Не удалось сохранить маршруты в БД") return true
} catch (error) {
if (!alive.current) return false
const message = error instanceof Error ? error.message : "Не удалось применить маршруты на роутер"
setOpError(message); setSaveError(message); setLiveStale(true); toast.error(message)
return false
} finally { } finally {
setBusy(null) writeLock.current = false
endWrite()
if (alive.current) setBusy(null)
} }
}, [isLive, selectedServerId, rows, apiFetch, loadRoutes]) }, [isLive, selectedServerId, routeOwner, liveStale, busy, sharedBusy, apiFetch, beginWrite, endWrite])
const syncFromRouter = useCallback(async () => { useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("from")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/from-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
})
await loadRoutes()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось синхронизировать маршруты с роутера")
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, apiFetch, loadRoutes])
const syncToRouter = useCallback(async () => { const loadRevisions = useCallback(async () => {
if (!isLive || !selectedServerId) return if (!isLive || !selectedServerId) return
setOpError(null) const epoch = ++historyEpoch.current
setBusy("to") setHistoryLoading(true)
setHistoryError(null)
try { try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/to-router", { const res = await apiFetch<unknown>(
method: "POST", `/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
body: JSON.stringify({ serverId: selectedServerId }), )
}) if (epoch !== historyEpoch.current) return
} catch (e) { setRevisions(parseConfigRevisions(res, selectedServerId, "recursive-routes"))
setOpError(e instanceof Error ? e.message : "Не удалось применить маршруты на роутер") } catch (error) {
if (epoch !== historyEpoch.current) return
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
} finally { } finally {
setBusy(null) if (epoch === historyEpoch.current) setHistoryLoading(false)
} }
}, [isLive, selectedServerId, apiFetch]) }, [isLive, selectedServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || writeLock.current || busy !== null || sharedBusy || !can("mm:network:write")) throw new Error("Восстановление сейчас недоступно")
if (!beginWrite()) throw new Error("Другая операция ещё выполняется")
writeLock.current = true; readEpoch.current++
setHistoryRestoring(true); setBusy("apply")
try {
const raw = await apiFetch<unknown>(`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`, { method: "POST", body: JSON.stringify({ serverId: selectedServerId }) })
const parsed = recursiveReceiptSchema.safeParse(raw)
if (!parsed.success || String(parsed.data.serverId) !== selectedServerId) throw new Error("Сервер не подтвердил восстановление")
if (!alive.current) return
setRows(parsed.data.routes); setRouteOwner(selectedServerId); setLiveStale(false); setWriteAt(new Date().toISOString())
toast.success("Версия применена на роутер"); await loadRevisions()
} catch (error) {
if (alive.current) { setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось восстановить маршруты") }
throw error
} finally {
writeLock.current = false
endWrite()
if (alive.current) { setHistoryRestoring(false); setBusy(null) }
}
}, [isLive, selectedServerId, servers, busy, sharedBusy, apiFetch, loadRevisions, beginWrite, endWrite])
function groupKeyOf(row: RecursiveRouteRow): string { function groupKeyOf(row: RecursiveRouteRow): string {
return row.dstAddress.trim().toLowerCase() return JSON.stringify([row.dstAddress.trim().toLowerCase(), row.routingTable.trim()])
}
const sheetTrigger = useRef<HTMLElement | null>(null)
function returnSheetFocus() {
const target = sheetTrigger.current
if (target?.isConnected) return target
const label = target?.getAttribute("aria-label")
return label ? document.querySelector<HTMLElement>(`button[aria-label="${CSS.escape(label)}"]`) : null
} }
function openCreate() { function openCreate() {
if (mutationsLocked) return
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
setSaveError(null)
setSheetMode("create") setSheetMode("create")
setEditingGroupKey(null) setEditingGroupKey(null)
setSheetInitial(emptyForm()) setSheetInitial(emptyForm())
setSheetOpen(true) setSheetOpen(true)
} }
function openEdit(group: RouteGroup) { function openEdit(group: RouteGroup) {
if (mutationsLocked) return
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
setSaveError(null)
const endpoints = rows.filter(row => groupKeyOf(row) === group.key)
setSheetMode("edit") setSheetMode("edit")
setEditingGroupKey(group.key) setEditingGroupKey(group.key)
setSheetInitial({ setSheetInitial({
dstAddress: group.dstAddress, dstAddress: group.dstAddress,
routingTable: group.routingTable, routingTable: group.routingTable,
comment: group.comment, comment: group.comment,
endpoints: group.endpoints.map(ep => ({ endpoints: endpoints.map(ep => ({
id: ep.id, id: ep.id,
disabled: ep.disabled,
gateway: ep.gateway, gateway: ep.gateway,
distance: ep.distance, distance: ep.distance,
scope: ep.scope, scope: ep.scope,
@@ -515,7 +595,7 @@ export default function RecursiveRoutesPage() {
}) })
setSheetOpen(true) setSheetOpen(true)
} }
function handleSaveSheet(v: RouteForm) { async function handleSaveSheet(v: RouteForm) {
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({ const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
id, id,
dstAddress: v.dstAddress, dstAddress: v.dstAddress,
@@ -526,25 +606,31 @@ export default function RecursiveRoutesPage() {
routingTable: v.routingTable, routingTable: v.routingTable,
checkGateway: ep.checkGateway, checkGateway: ep.checkGateway,
comment: v.comment, comment: v.comment,
disabled: false, disabled: ep.disabled,
country: ep.country || inferCountry(ep.gateway) || "", country: ep.country || "",
}) })
let next: RecursiveRouteRow[]
if (sheetMode === "create") { if (sheetMode === "create") {
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`)) const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
setRows(prev => [...prev, ...expanded]) next = [...rows, ...expanded]
} else if (editingGroupKey) { } else if (editingGroupKey) {
setRows(prev => { const kept = rows.filter(r => groupKeyOf(r) !== editingGroupKey)
const kept = prev.filter(r => groupKeyOf(r) !== editingGroupKey) const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`)) next = [...kept, ...expanded]
return [...kept, ...expanded] } else {
}) return false
} }
setSheetOpen(false) const saved = await applyRoutes(next)
if (saved && alive.current) setSheetOpen(false)
return saved
} }
const contextError = context.error ?? (isLive && liveServerListReady && context.serverId && !servers.some(s => s.id === context.serverId) ? "Устройство из ссылки недоступно" : null)
const hasRouteSnapshot = routeOwner === selectedServerId && (!liveStale || rows.length > 0)
const currentServer = servers.find(s => s.id === selectedServerId) const currentServer = servers.find(s => s.id === selectedServerId)
const mutationsLocked = Boolean(contextError) || !isLive || busy !== null || sharedBusy || liveStale || routeOwner !== selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || !can("mm:network:write")
const rrRailItems = useMemo<ServerTileItem[]>(() => ( const rrRailItems = useMemo<ServerTileItem[]>(() => (
servers.map((s) => ({ servers.map((s) => ({
id: s.id, id: s.id,
@@ -552,7 +638,7 @@ export default function RecursiveRoutesPage() {
host: s.host, host: s.host,
site: s.site, site: s.site,
country: s.country, country: s.country,
status: s.status, status: s.status ?? undefined,
type: s.type, type: s.type,
enabled: s.enabled, enabled: s.enabled,
})) }))
@@ -592,7 +678,15 @@ export default function RecursiveRoutesPage() {
<ServerRailLayout <ServerRailLayout
items={rrRailItems} items={rrRailItems}
selectedId={selectedServerId} selectedId={selectedServerId}
onSelect={setSelectedServerId} onSelect={id => {
if (writeLock.current || sharedBusy || id === selectedServerId) return
readEpoch.current++; gatewayEpoch.current++; historyEpoch.current++
setSelectedServerId(id); setRows([]); setRouteOwner(""); setLiveStale(true); setBusy(null)
setGatewayOptions([]); setGatewayError(null); setOpError(null); setSaveError(null)
setReadAt(null); setWriteAt(null); setSheetOpen(false); setHistoryOpen(false); setRevisions([]); setHistoryError(null)
patchUrl({ serverId: id, route: null, table: null })
}}
preserveSelection={true}
showAll={false} showAll={false}
showCount={false} showCount={false}
loading={isLive && !liveServerListReady} loading={isLive && !liveServerListReady}
@@ -602,17 +696,34 @@ export default function RecursiveRoutesPage() {
actions={ actions={
<> <>
<ServerRailMobileButton /> <ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}> {isLive && (
{busy === "from" ? "Синхронизация..." : "Router => DB"} <>
</Button> <Button
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}> type="button" variant="outline"
{busy === "to" ? "Применение..." : "DB => Router"} size="sm"
</Button> onClick={() => { if (!servers.length) setInventoryTick(t => t + 1); else { void loadRoutes(); void loadGateways() } }}
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}> disabled={busy !== null}
<SaveIcon className="size-4" />Сохранить в БД title="Прочитать маршруты с роутера"
</Button> >
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}> <RefreshCwIcon aria-hidden="true" className={cn("size-4", busy === "load" && "animate-spin")} />
<PlusIcon className="size-4" />Добавить Обновить
</Button>
<Button
type="button" variant="outline"
size="sm"
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
disabled={busy !== null}
>
<HistoryIcon aria-hidden="true" className="size-4" />
История
</Button>
</>
)}
<Button type="button" size="sm" onClick={openCreate} disabled={mutationsLocked}>
<PlusIcon aria-hidden="true" className="size-4" />Добавить
</Button> </Button>
</> </>
} }
@@ -621,26 +732,39 @@ export default function RecursiveRoutesPage() {
banner={ banner={
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6"> <div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
<div className="relative min-w-[200px] max-w-xs flex-1"> <div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" /> <SearchIcon aria-hidden="true" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…" <Input aria-label="Поиск маршрутов" className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
value={search} onChange={(e) => setSearch(e.target.value)} /> value={search} onChange={(e) => setSearch(e.target.value)} />
{search && ( {search && (
<button onClick={() => setSearch("")} <button type="button" aria-label="Сбросить поиск маршрутов" onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"> className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" /> <XIcon aria-hidden="true" className="size-3.5" />
</button> </button>
)} )}
</div> </div>
<div className="flex items-center gap-1.5 text-xs"> <div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего маршрутов</span> <span className="text-muted-foreground">Всего маршрутов</span>
<span className="font-semibold tabular-nums">{rows.length}</span> <span className="font-semibold tabular-nums">{hasRouteSnapshot ? rows.length : "—"}</span>
</div> </div>
<p className="text-xs text-muted-foreground ml-auto"> <p className="text-xs text-muted-foreground ml-auto">
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`} {!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
</p> </p>
{opError && ( {busy === "load" && <p role="status" className="w-full text-xs text-muted-foreground">Обновление маршрутов…</p>}
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2"> {sharedBusy && busy !== "apply" && <p role="status" className="w-full text-sm text-muted-foreground">Операция предыдущего контекста ещё выполняется. Изменения заблокированы до ответа API.</p>}
{opError} {contextError && <p role="alert" className="w-full text-sm text-warning">{contextError}. Выберите устройство или сбросьте ссылку. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ serverId: null, q: null, route: null, table: null })}>Сбросить контекст маршрутов</Button></p>}
{expandedGroupKey && !liveStale && !groupedRoutes.some(group => group.key === expandedGroupKey) && <p role="status" className="w-full text-sm text-muted-foreground">Маршрут из ссылки отсутствует в текущем срезе. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ route: null, table: null })}>Закрыть маршрут</Button></p>}
{(opError || saveError) && (
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2" role="alert">
{opError || saveError}
</div>
)}
{isLive && readAt && <p role="status" className="w-full text-xs text-muted-foreground">Успешный ответ получен {new Date(readAt).toLocaleTimeString("ru-RU")}. Время получения браузером, не измерения RouterOS.</p>}
{isLive && writeAt && <p role="status" className="w-full text-xs text-muted-foreground">Запись подтверждена {new Date(writeAt).toLocaleTimeString("ru-RU")}.</p>}
{isLive && gatewayError && <p role="status" className="w-full text-xs text-warning">Шлюзы не загружены: {gatewayError}. Адрес можно ввести вручную.</p>}
{isLive && liveStale && (
<div className="w-full text-xs text-warning bg-warning/10 border border-warning/20 rounded-md px-3 py-2 flex items-center gap-2">
<AlertTriangleIcon aria-hidden="true" className="size-3.5 shrink-0" />
{routeOwner === selectedServerId ? "Последний снимок; актуальное состояние не подтверждено." : "Данные маршрутов пока неизвестны."} Изменения заблокированы.
</div> </div>
)} )}
</div> </div>
@@ -655,35 +779,43 @@ export default function RecursiveRoutesPage() {
) : ( ) : (
<DataPageCard> <DataPageCard>
{currentServer && ( {currentServer && (
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10"> <div className="flex flex-wrap items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} /> {currentServer.status ? <StatusDot status={currentServer.status} pulse={currentServer.status === "online"} /> : <span className="text-xs text-muted-foreground">Состояние неизвестно</span>}
<Flag code={currentServer.country} size={16} /> <Flag code={currentServer.country} size={16} />
<span className="font-mono text-sm font-semibold">{currentServer.name}</span> <span className="min-w-0 break-all font-mono text-sm font-semibold">{currentServer.name}</span>
<TypeChip type={currentServer.type} /> {currentServer.type && <TypeChip type={currentServer.type} />}
<code className="text-[11px] font-mono text-muted-foreground">{currentServer.host}</code> <code className="break-all text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
<span className="text-xs text-muted-foreground">{currentServer.asn}</span> <span className="text-xs text-muted-foreground">{currentServer.asn}</span>
{currentServer.latency !== null && ( {currentServer.latency !== null && (
<span className={cn( <span className={cn(
"text-xs font-mono", "text-xs font-mono",
currentServer.latency > 60 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", currentServer.latency > 60 ? "text-warning" : "text-muted-foreground",
)}>{currentServer.latency}мс</span> )}>{currentServer.latency}мс</span>
)} )}
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{rows.length} маршрутов</span> <span>{hasRouteSnapshot ? rows.length + " маршрутов" : "Количество неизвестно"}</span>
</div> </div>
</div> </div>
)} )}
<RecursiveRoutesDataGrid <RecursiveRoutesDataGrid
emptyTitle={liveStale ? "Маршруты пока неизвестны" : search ? "Нет маршрутов по текущему поиску" : undefined}
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))} groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
expandedKey={expandedGroupKey} expandedKey={expandedGroupKey}
onExpandedChange={setExpandedGroupKey} onExpandedChange={key => {
const group = groupedRoutes.find(group => group.key === key)
patchUrl({ route: group?.dstAddress ?? null, table: group?.routingTable ?? null })
}}
mutationsDisabled={mutationsLocked}
onEdit={openEdit} onEdit={openEdit}
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))} onDelete={(g) => {
if (mutationsLocked) return
void applyRoutes(rows.filter((r) => groupKeyOf(r) !== g.key))
}}
/> />
<button onClick={openCreate} <button type="button" onClick={openCreate} disabled={mutationsLocked}
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t"> className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t disabled:opacity-50">
<PlusIcon className="size-3.5" /> <PlusIcon aria-hidden="true" className="size-3.5" />
Добавить маршрут Добавить маршрут
</button> </button>
</DataPageCard> </DataPageCard>
@@ -691,13 +823,30 @@ export default function RecursiveRoutesPage() {
</ServerRailLayout> </ServerRailLayout>
<RouteSheet <RouteSheet
returnFocus={returnSheetFocus}
busy={busy !== null}
saveError={saveError}
open={sheetOpen} open={sheetOpen}
mode={sheetMode} mode={sheetMode}
initial={sheetInitial} initial={sheetInitial}
onSave={handleSaveSheet} onSave={handleSaveSheet}
onClose={() => setSheetOpen(false)} onClose={() => { if (!writeLock.current) setSheetOpen(false) }}
gateways={gatewayOptions} gateways={gatewayOptions}
/> />
<ConfigHistorySheet
open={historyOpen}
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
loadError={historyError}
onRetry={() => { void loadRevisions() }}
title="История маршрутов"
itemLabel="маршрутов"
revisions={revisions}
loading={historyLoading}
restoreDisabled={!can("mm:network:write") || !currentServer?.enabled || (busy !== null || sharedBusy) && !historyRestoring}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</> </>
) )
} }
+19 -18
View File
@@ -6,7 +6,6 @@ import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-v
import { import {
formatCommitSubject, formatCommitSubject,
getReleaseCommitPreview, getReleaseCommitPreview,
RELEASE_COMMIT_PREVIEW_LIMIT,
} from "@/lib/release-commits" } from "@/lib/release-commits"
import { readReleaseManifest } from "@/lib/release-manifest" import { readReleaseManifest } from "@/lib/release-manifest"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -16,23 +15,23 @@ export const dynamic = "force-dynamic"
function formatPublishedAt(value: string | null, isProdBuild: boolean): string { function formatPublishedAt(value: string | null, isProdBuild: boolean): string {
if (value) { if (value) {
return new Intl.DateTimeFormat("ru-RU", { return "Дата манифеста: " + new Intl.DateTimeFormat("ru-RU", {
dateStyle: "long", dateStyle: "long",
timeStyle: "short", timeStyle: "short",
}).format(new Date(value)) }).format(new Date(value))
} }
return isProdBuild ? "Прод-сборка" : "Локальная сборка" return isProdBuild ? "Дата манифеста не указана" : "Локальная сборка"
} }
export default function ReleasesPage() { export default function ReleasesPage() {
const manifest = readReleaseManifest() const manifest = readReleaseManifest()
const version = getAppVersion() const version = getAppVersion()
const isProdBuild = !version.endsWith("-dev") const isProdBuild = !version.endsWith("-dev")
const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null const releaseUrl = getReleaseUrl() || manifest?.releaseUrl || null
const commits = getReleaseCommitPreview(manifest.commits) const commits = getReleaseCommitPreview(manifest?.commits ?? [])
return ( return (
<div className="flex flex-col h-full"> <div className="flex min-h-0 flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Система" }, { label: "Релизы" }]} crumbs={[{ label: "Система" }, { label: "Релизы" }]}
actions={ actions={
@@ -40,53 +39,55 @@ export default function ReleasesPage() {
<Link <Link
href={releaseUrl} href={releaseUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")} className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
> >
<ExternalLinkIcon className="size-4" /> <ExternalLinkIcon className="size-4" aria-hidden />
Открыть в Gitea Открыть релиз
</Link> </Link>
) : null ) : null
} }
/> />
<div className="flex-1 overflow-y-auto p-6"> <div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
<div className="max-w-[960px] mx-auto space-y-6"> <div className="max-w-[960px] mx-auto space-y-6">
<OpsPanel <OpsPanel
title="Текущая версия" title="Текущая версия"
description="Сборка MikrotikManager, опубликованная через CI/CD." description={isProdBuild ? "Версия запущенной сборки MikrotikManager." : "Локальная разработка. Публикация релиза не подтверждена."}
contentClassName="flex flex-col gap-3 px-5 pb-5" contentClassName="flex flex-col gap-3 px-5 pb-5"
> >
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span> <span className="text-2xl font-semibold tracking-tight break-all">{formatAppVersionLabel(version)}</span>
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt, isProdBuild)}</span> <span className="text-sm text-muted-foreground">{isProdBuild ? formatPublishedAt(manifest?.version === version ? manifest.publishedAt : null, true) : "Локальная сборка"}</span>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits. Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
</p> </p>
</OpsPanel> </OpsPanel>
{!manifest ? <p role="alert" className="text-sm text-destructive">Метаданные релиза недоступны или повреждены. Дата, ссылка и список коммитов не подтверждены.</p> : manifest.version !== version && isProdBuild ? <p role="status" className="text-sm text-muted-foreground">Манифест относится к {formatAppVersionLabel(manifest.version)}; версия запущенной сборки отличается.</p> : null}
<OpsPanel <OpsPanel
title="Сводка коммитов" title="Сводка коммитов"
description={ description={
commits.length > 0 commits.length > 0
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.` ? `Коммиты манифеста ${formatAppVersionLabel(manifest!.version)}: ${commits.length}.`
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI." : manifest ? "В манифесте нет коммитов. При публикации список формируется CI." : "Список коммитов не подтверждён."
} }
contentClassName="px-5 pb-5" contentClassName="px-5 pb-5"
> >
{commits.length === 0 ? ( {commits.length === 0 ? (
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p> <p className="text-sm text-muted-foreground">{manifest ? "Коммитов для отображения пока нет." : "Нет подтверждённых метаданных."}</p>
) : ( ) : (
<ul className="flex flex-col gap-3"> <ul className="flex flex-col gap-3">
{commits.map((commit) => ( {commits.map((commit) => (
<li key={commit.sha} className="rounded-md border px-3 py-2"> <li key={commit.sha} className="rounded-md border px-3 py-2 break-words">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono uppercase">{commit.type}</span> <span className="font-mono uppercase">{commit.type}</span>
<span className="font-mono">{commit.shortSha}</span> <span className="font-mono">{commit.shortSha}</span>
<span>{commit.author}</span> <span>{commit.author}</span>
</div> </div>
<p className="mt-1 text-sm">{formatCommitSubject(commit.subject)}</p> <p className="mt-1 text-sm break-words">{formatCommitSubject(commit.subject)}</p>
</li> </li>
))} ))}
</ul> </ul>
File diff suppressed because it is too large. Load diff
+262 -76
View File
@@ -1,13 +1,21 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { Suspense, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { can } from "@/lib/auth"
import { useSearchParams } from "next/navigation"
import { serverCreateSchema } from "@mmapp/contracts/servers"
import { OperationGate, pollServers, serverOperationError, type PollResult } from "@/lib/server-operations"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit" import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid" import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
import type { Filter } from "@/components/reui/filters" import { useServerUrlState } from "@/hooks/use-server-url-state"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters" import { ServerFilters } from "@/components/data-grids/server-filters"
import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields" import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
import { SERVER_FILTER_ACCESSORS } from "@/lib/data-filters/server-filter-fields"
import { servers as initialServers } from "@/lib/data" import { servers as initialServers } from "@/lib/data"
import type { ServerType, Server, WanUplink } from "@/lib/data" import type { ServerType, Server, WanUplink } from "@/lib/data"
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers" import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
@@ -45,9 +53,9 @@ import {
StepperSeparator, StepperSeparator,
StepperTitle, StepperTitle,
StepperTrigger, StepperTrigger,
} from "@/components/reui/stepper" } from "@/components/reui-kit/accessible-stepper"
import { import {
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon, RefreshCwIcon, PlusIcon, TrashIcon,
EyeIcon, EyeOffIcon, EyeIcon, EyeOffIcon,
ChevronRightIcon, ChevronDownIcon, ChevronRightIcon, ChevronDownIcon,
CheckCircleIcon, XCircleIcon, LoaderCircleIcon, CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
@@ -213,15 +221,18 @@ type TypeFilter = "all" | ServerType
type SheetMode = "add" | "edit" type SheetMode = "add" | "edit"
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
export default function ServersPage() { function ServersPageContent() {
const searchParams = useSearchParams()
const selectedServerId = searchParams.get("server")
const clearParams = new URLSearchParams(searchParams.toString())
clearParams.delete("server")
const allServersHref = "/servers" + (clearParams.size ? "?" + clearParams : "")
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
const [serverList, setServerList] = useState<Server[]>([]) const [serverList, setServerList] = useState<Server[]>([])
const [_backendOk, setBackendOk] = useState(false) const [listState, setListState] = useState<"loading" | "ready" | "error">("loading")
const [search, setSearch] = useState("") const { search, setSearch, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset: resetFilters } = useServerUrlState()
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<SheetMode>("add") const [sheetMode, setSheetMode] = useState<SheetMode>("add")
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
@@ -231,35 +242,92 @@ export default function ServersPage() {
const [testMsg, setTestMsg] = useState("") const [testMsg, setTestMsg] = useState("")
const [pollingIds, setPollingIds] = useState<Set<string>>(new Set()) const [pollingIds, setPollingIds] = useState<Set<string>>(new Set())
const [pollAllBusy, setPollAllBusy] = useState(false) const [pollAllBusy, setPollAllBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [detailsLoading, setDetailsLoading] = useState(false)
const [detailsReady, setDetailsReady] = useState(true)
const [formError, setFormError] = useState("")
const [operationMessage, setOperationMessage] = useState("")
const [operationError, setOperationError] = useState("")
const [deleteTarget, setDeleteTarget] = useState<Server | null>(null)
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState("")
const [pollResults, setPollResults] = useState<PollResult[]>([])
const [rowBusy, setRowBusy] = useState(false)
const gate = useRef(new OperationGate())
const formVersion = useRef(0)
const testVersion = useRef(0)
const contextVersion = useRef(0)
const canWrite = can("mm:servers:write")
const accessDenied = "Недостаточно прав: изменение и опрос серверов требуют разрешения на запись."
function requireWrite(report: (message: string) => void = setOperationError) {
if (can("mm:servers:write")) return true
report(accessDenied)
return false
}
const controlsBusy = saving || rowBusy || pollAllBusy || pollingIds.size > 0
const set = <K extends keyof FormState>(k: K, v: FormState[K]) => const set = <K extends keyof FormState>(k: K, v: FormState[K]) => {
if (gate.current.has("save") || detailsLoading) return
if (["host", "port", "proto", "verifySsl", "apiPath", "username", "password"].includes(k)) {
testVersion.current++
setTestState("idle"); setTestMsg("")
}
setForm((f) => ({ ...f, [k]: v })) setForm((f) => ({ ...f, [k]: v }))
}
function closeForm(next: boolean) {
if (gate.current.has("save")) return
if (!next) { formVersion.current++; testVersion.current++ }
setOpen(next)
}
// ── load from backend when mode/url changes ─────────────────────────────── // ── load from backend when mode/url changes ───────────────────────────────
useEffect(() => { useEffect(() => {
const version = ++contextVersion.current
let cancelled = false
const invalidate = () => {
cancelled = true
contextVersion.current++
formVersion.current++
testVersion.current++
}
queueMicrotask(() => {
if (cancelled) return
formVersion.current++; testVersion.current++
setOpen(false); setDeleteTarget(null); setOperationError(""); setOperationMessage(""); setPollResults([])
setListState("loading")
})
if (mode !== "live") { if (mode !== "live") {
queueMicrotask(() => { queueMicrotask(() => {
if (cancelled) return
setServerList(initialServers) setServerList(initialServers)
setBackendOk(false) setListState("ready")
}) })
return return invalidate
} }
listServers(backendUrl) listServers(backendUrl)
.then(data => { .then(data => {
setBackendOk(true) if (cancelled || version !== contextVersion.current) return
setListState("ready")
setServerList(data.map(toFrontendServer)) setServerList(data.map(toFrontendServer))
}) })
.catch(() => { .catch((error) => {
setBackendOk(false) if (cancelled || version !== contextVersion.current) return
setOperationError(serverOperationError(error))
setListState("error")
setServerList([]) setServerList([])
}) })
// eslint-disable-next-line react-hooks/exhaustive-deps return invalidate
}, [mode, backendUrl]) }, [mode, backendUrl])
// ── actions ────────────────────────────────────────────────────────────── // ── actions ──────────────────────────────────────────────────────────────
function openAdd() { function openAdd() {
if (!requireWrite()) return
if (gate.current.has("save")) return
formVersion.current++; testVersion.current++
setFormError(""); setDetailsLoading(false); setDetailsReady(true)
setSheetMode("add"); setEditingId(null) setSheetMode("add"); setEditingId(null)
setForm(defaultForm); setTestState("idle"); setTestMsg("") setForm(defaultForm); setTestState("idle"); setTestMsg("")
setSheetStep(1) setSheetStep(1)
@@ -267,11 +335,16 @@ export default function ServersPage() {
} }
async function openEdit(s: Server) { async function openEdit(s: Server) {
if (!requireWrite()) return
if (gate.current.has("save")) return
const version = ++formVersion.current
testVersion.current++
setFormError(""); setDetailsLoading(isLive); setDetailsReady(!isLive)
setSheetMode("edit"); setEditingId(s.id) setSheetMode("edit"); setEditingId(s.id)
setForm({ setForm({
...defaultForm, ...defaultForm,
name: s.name, type: s.type, site: s.site, country: s.country ?? "RU", name: s.name, type: s.type, site: s.site, country: s.country ?? "RU",
asn: s.asn ?? "", enabled: s.status !== "offline", host: s.host, asn: s.asn ?? "", enabled: s.enabled, host: s.host,
comment: s.comment ?? "", comment: s.comment ?? "",
lanSubnet: s.lanSubnet ?? "", lanSubnet: s.lanSubnet ?? "",
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [], wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
@@ -282,9 +355,11 @@ export default function ServersPage() {
if (isLive) { if (isLive) {
try { try {
const full = await getServer(backendUrl, s.id) const full = await getServer(backendUrl, s.id)
if (version !== formVersion.current) return
setForm(prev => ({ setForm(prev => ({
...prev, ...prev,
username: full.username ?? "", username: full.username ?? "",
enabled: full.enabled,
password: full.password ?? "", password: full.password ?? "",
port: String(full.port ?? 443), port: String(full.port ?? 443),
proto: full.useSsl ? "https" : "http", proto: full.useSsl ? "https" : "http",
@@ -295,16 +370,24 @@ export default function ServersPage() {
? JSON.parse(JSON.stringify(full.wanUplinks)) ? JSON.parse(JSON.stringify(full.wanUplinks))
: prev.wanUplinks, : prev.wanUplinks,
})) }))
} catch { setDetailsReady(true)
// ignore — user can fill in manually } catch (error) {
if (version === formVersion.current) setFormError(serverOperationError(error))
} finally {
if (version === formVersion.current) setDetailsLoading(false)
} }
} }
} }
async function handleSave() { async function handleSave() {
if (!requireWrite(setFormError)) return
if (!detailsReady || detailsLoading || !gate.current.acquire("save")) return
const context = contextVersion.current
setSaving(true); setFormError("")
try {
const payload: ServerCreate = { const payload: ServerCreate = {
host: form.host, host: form.host,
port: Number(form.port) || (form.proto === "https" ? 443 : 80), port: Number(form.port),
username: form.username, username: form.username,
password: form.password, password: form.password,
useSsl: form.proto === "https", useSsl: form.proto === "https",
@@ -320,18 +403,32 @@ export default function ServersPage() {
wanUplinks: form.type === "home-router" ? form.wanUplinks : [], wanUplinks: form.type === "home-router" ? form.wanUplinks : [],
} }
if (!form.host.trim() || !form.username.trim() || !Number.isInteger(payload.port) || payload.port! > 65535 || payload.port! < 1) {
setFormError("Укажите хост, имя пользователя и порт от 1 до 65535.")
setSheetStep(3)
return
}
const validation = serverCreateSchema.safeParse(payload)
if (!validation.success) {
setFormError("Проверьте параметры сервера: " + validation.error.issues.map(i => i.path.join(".")).join(", "))
return
}
if (isLive) { if (isLive) {
try { try {
if (sheetMode === "edit" && editingId) { if (sheetMode === "edit" && editingId) {
const updatedPayload: ServerUpdate = payload const updatedPayload: ServerUpdate = payload
const updated = await updateServer(backendUrl, editingId, updatedPayload) const updated = await updateServer(backendUrl, editingId, updatedPayload)
if (context !== contextVersion.current) return
setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s)) setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s))
} else { } else {
const created = await createServer(backendUrl, payload) const created = await createServer(backendUrl, payload)
if (context !== contextVersion.current) return
setServerList(list => [...list, toFrontendServer(created)]) setServerList(list => [...list, toFrontendServer(created)])
} }
} catch (e) { } catch (e) {
console.error("Ошибка сохранения:", e) if (context === contextVersion.current) setFormError(serverOperationError(e))
return
} }
} else { } else {
// offline mode — update local state only // offline mode — update local state only
@@ -349,7 +446,7 @@ export default function ServersPage() {
comment: form.comment, comment: form.comment,
lanSubnet: form.lanSubnet || undefined, lanSubnet: form.lanSubnet || undefined,
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined, wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
status: form.enabled ? (s.status === "offline" ? "online" : s.status) : "offline", enabled: form.enabled,
} }
: s : s
)) ))
@@ -368,57 +465,85 @@ export default function ServersPage() {
} }
setOpen(false) setOpen(false)
formVersion.current++; testVersion.current++
setOperationMessage(isLive ? "Сервер сохранён." : "Сервер сохранён в демо-режиме.")
} finally {
gate.current.release("save"); setSaving(false)
}
} }
function handleToggleStatus(id: string) { async function handleToggleStatus(id: string) {
setServerList(list => list.map(s => if (!requireWrite()) return
s.id === id ? { ...s, status: s.status === "offline" ? "online" : "offline" } : s const server = serverList.find(s => s.id === id)
)) if (!server || !gate.current.acquire("rows")) return
const context = contextVersion.current
setRowBusy(true); setOperationError(""); setOperationMessage("")
try {
let enabled = !server.enabled
if (isLive) enabled = (await updateServer(backendUrl, id, { enabled })).enabled
if (context !== contextVersion.current) return
setServerList(list => list.map(s => s.id === id ? { ...s, enabled } : s))
setOperationMessage(`${server.name}: ${enabled ? "включён" : "отключён"}${isLive ? "." : " (демо)."}`)
} catch (error) {
if (context === contextVersion.current) setOperationError(serverOperationError(error))
} finally { gate.current.release("rows"); setRowBusy(false) }
} }
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (isLive) { if (!requireWrite(setDeleteError)) return
try { if (!gate.current.acquire("rows")) return
await deleteServer(backendUrl, id) const context = contextVersion.current
} catch (e) { setDeleting(true); setRowBusy(true); setDeleteError("")
console.error("Ошибка удаления:", e) try {
} if (isLive) await deleteServer(backendUrl, id)
} if (context !== contextVersion.current) return
setServerList(list => list.filter(s => s.id !== id)) setServerList(list => list.filter(s => s.id !== id))
setDeleteTarget(null); setOperationError(""); setOperationMessage("Сервер удалён из списка.")
} catch (error) {
if (context === contextVersion.current) setDeleteError(serverOperationError(error))
} finally { gate.current.release("rows"); setDeleting(false); setRowBusy(false) }
} }
async function handlePoll(id: string) { async function handlePoll(id: string) {
if (!isLive) return await runPoll([id])
setPollingIds(p => new Set(p).add(id))
try {
await pollServer(backendUrl, id)
// Reload full list to get updated status/version
const data = await listServers(backendUrl)
setServerList(data.map(toFrontendServer))
} catch (e) {
console.error("Poll error:", e)
} finally {
setPollingIds(p => { const n = new Set(p); n.delete(id); return n })
}
} }
async function handlePollAll() { async function handlePollAll() {
if (!isLive) return await runPoll(serverList.map(s => s.id))
setPollAllBusy(true) }
async function runPoll(ids: string[]) {
if (!requireWrite()) return
if (!isLive || !gate.current.acquire("rows")) return
const context = contextVersion.current
setPollAllBusy(true); setPollingIds(new Set(ids)); setOperationError(""); setOperationMessage(""); setPollResults([])
try { try {
const ids = serverList.map(s => s.id) const results = await pollServers(ids, async id => {
await Promise.all(ids.map(id => pollServer(backendUrl, id).catch(() => {}))) if (context !== contextVersion.current) throw new Error("Контекст изменился; опрос отменён.")
const data = await listServers(backendUrl) if (!can("mm:servers:write")) throw new Error(accessDenied)
setServerList(data.map(toFrontendServer)) await pollServer(backendUrl, id)
})
if (context !== contextVersion.current) return
setPollResults(results)
try {
const data = await listServers(backendUrl)
if (context === contextVersion.current) setServerList(data.map(toFrontendServer))
} catch (error) {
if (context === contextVersion.current) setOperationError("Опрос завершён, но список не обновлён. " + serverOperationError(error))
}
} finally { } finally {
setPollAllBusy(false) gate.current.release("rows"); setPollAllBusy(false); setPollingIds(new Set())
} }
} }
async function handleTest() { async function handleTest() {
if (!requireWrite(setFormError)) return
if (!detailsReady || gate.current.has("save") || gate.current.has("test")) return
const version = ++testVersion.current
if (!form.host || !form.username) { if (!form.host || !form.username) {
setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return
} }
gate.current.acquire("test")
setTestState("testing"); setTestMsg("") setTestState("testing"); setTestMsg("")
try { try {
const data = await testServerConnection(backendUrl, { const data = await testServerConnection(backendUrl, {
@@ -430,28 +555,36 @@ export default function ServersPage() {
username: form.username, username: form.username,
password: form.password, password: form.password,
}) })
if (version !== testVersion.current) return
if (data.success) { if (data.success) {
setTestState("ok"); setTestMsg(data.message) setTestState("ok"); setTestMsg(data.message)
} else { } else {
setTestState("error"); setTestMsg(data.message) setTestState("error"); setTestMsg(data.message)
} }
} catch { } catch (error) {
if (version !== testVersion.current) return
setTestState("error") setTestState("error")
setTestMsg(`Не удалось связаться с бекендом — убедитесь, что он запущен на ${backendUrl}`) setTestMsg(serverOperationError(error))
} finally {
gate.current.release("test")
} }
} }
// ── derived ────────────────────────────────────────────────────────────── // ── derived ──────────────────────────────────────────────────────────────
const selectedServer = serverList.find(server => server.id === selectedServerId)
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (urlError) return []
const base = serverList.filter(sv => { const base = serverList.filter(sv => {
if (selectedServerId !== null && sv.id !== selectedServerId) return false
if (typeFilter !== "all" && sv.type !== typeFilter) return false if (typeFilter !== "all" && sv.type !== typeFilter) return false
if (!search) return true if (!search) return true
const q = search.toLowerCase() const q = search.toLowerCase()
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q) return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
}) })
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS) return applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS)
}, [serverList, search, typeFilter, advancedFilters]) }, [serverList, search, typeFilter, advancedFilters, selectedServerId, urlError])
const counts = useMemo(() => ({ const counts = useMemo(() => ({
all: serverList.length, all: serverList.length,
@@ -478,11 +611,10 @@ export default function ServersPage() {
crumbs={[{ label: "Управление" }, { label: "Серверы" }]} crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
actions={ actions={
<> <>
<Button variant="outline" size="sm" onClick={handlePollAll} disabled={!isLive || pollAllBusy}> <Button variant="outline" size="sm" onClick={() => selectedServerId !== null ? selectedServer && void runPoll([selectedServer.id]) : void handlePollAll()} disabled={!canWrite || !isLive || controlsBusy || (selectedServerId !== null ? !selectedServer : serverList.length === 0)}>
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />Проверить все <RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />{selectedServerId !== null ? "Проверить сервер" : "Проверить все"}
</Button> </Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button> <Button size="sm" onClick={openAdd} disabled={!canWrite || controlsBusy}><PlusIcon className="size-4" />Добавить сервер</Button>
<Button size="sm" onClick={openAdd}><PlusIcon className="size-4" />Добавить сервер</Button>
</> </>
} }
/> />
@@ -490,6 +622,32 @@ export default function ServersPage() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{!canWrite && <Alert variant="info" role="status"><AlertTitle>Только просмотр</AlertTitle><AlertDescription>Изменение, удаление, опрос серверов и тест соединения требуют разрешения на запись. Просмотр деталей и фильтры доступны.</AlertDescription></Alert>}
{operationError && <Alert variant="destructive" role="alert"><AlertTitle>Действие не завершено</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
{operationMessage && <Alert variant="success" role="status"><AlertDescription>{operationMessage}</AlertDescription></Alert>}
{pollResults.length > 0 && (
<Alert variant={pollResults.some(r => r.error) ? "warning" : "success"} role="status">
<AlertTitle>Опрос: успешно {pollResults.filter(r => !r.error).length} из {pollResults.length}</AlertTitle>
<AlertDescription>
<ul>{pollResults.map(r => <li key={r.id}>{serverList.find(s => s.id === r.id)?.name ?? r.id}: {r.error ?? "опрос выполнен"}</li>)}</ul>
{pollResults.some(r => r.error) && <Button type="button" variant="outline" size="sm" disabled={!canWrite || controlsBusy} onClick={() => runPoll(pollResults.filter(r => r.error).map(r => r.id))}>Повторить неуспешные</Button>}
</AlertDescription>
</Alert>
)}
{selectedServerId !== null && (
<Alert variant={listState === "error" || (listState === "ready" && !selectedServer) ? "warning" : "info"} role="status" aria-label="Сервер по ссылке">
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedServer ? "Выбран сервер: " + selectedServer.name : "Сервер по ссылке"}</AlertTitle>
<AlertDescription>
<p>{listState === "loading" ? "Загрузка сервера…" : listState === "error" ? "Не удалось загрузить сервер. Проверьте сообщение об ошибке выше." : selectedServer ? selectedServer.host + " · Список ограничен выбранным сервером. Сводка относится ко всем серверам." : "Сервер не найден или недоступен. Возможно, он удалён или у вас нет доступа."}</p>
<div className="flex flex-wrap gap-4">
<Link href={allServersHref} className="underline underline-offset-2">Все серверы</Link>
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
</div>
</AlertDescription>
</Alert>
)}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка серверов" aria-label="Сводка серверов"
items={[ items={[
@@ -525,6 +683,8 @@ export default function ServersPage() {
/> />
{/* Table */} {/* Table */}
{urlError && <Alert variant="warning" role="alert"><AlertTitle>Фильтры требуют внимания</AlertTitle><AlertDescription>{urlError}</AlertDescription></Alert>}
{hasFilters && <div><Button variant="outline" size="sm" onClick={resetFilters}>Сбросить фильтры списка</Button></div>}
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
segmented={{ segmented={{
@@ -536,9 +696,7 @@ export default function ServersPage() {
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType], count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
})), })),
}} }}
filters={advancedFilters} filterControl={<ServerFilters query={advancedFilters} onQueryChange={setAdvancedFilters} />}
onFiltersChange={setAdvancedFilters}
filterFields={SERVER_FILTER_FIELDS}
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, хосту…" searchPlaceholder="Поиск по имени, хосту…"
@@ -546,11 +704,14 @@ export default function ServersPage() {
/> />
<ServersDataGrid <ServersDataGrid
servers={filtered} servers={filtered}
hasUnfilteredRows={serverList.length > 0}
isLive={isLive} isLive={isLive}
pollingIds={pollingIds} pollingIds={pollingIds}
busy={controlsBusy}
canWrite={canWrite}
onPoll={handlePoll} onPoll={handlePoll}
onEdit={openEdit} onEdit={openEdit}
onDelete={handleDelete} onDelete={id => { if (!requireWrite()) return; setDeleteError(""); setDeleteTarget(serverList.find(s => s.id === id) ?? null) }}
onToggleStatus={handleToggleStatus} onToggleStatus={handleToggleStatus}
/> />
</DataPageCard> </DataPageCard>
@@ -558,13 +719,19 @@ export default function ServersPage() {
</div> </div>
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */} {/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */}
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={closeForm}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0"> <SheetContent side="right" showCloseButton={!saving} className="data-[side=right]:w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0"> <SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>{sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"}</SheetTitle> <SheetTitle>{sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"}</SheetTitle>
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription> <SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
</SheetHeader> </SheetHeader>
{detailsLoading && <p className="px-6 py-2 text-sm" role="status">Загрузка параметров сервера…</p>}
{formError && <div className="px-6 py-2"><Alert variant="destructive" role="alert"><AlertDescription>{formError}</AlertDescription></Alert>
{!detailsReady && !detailsLoading && editingId && <Button type="button" variant="outline" onClick={() => { const server = serverList.find(s => s.id === editingId); if (server) void openEdit(server) }}>Повторить загрузку</Button>}
</div>}
<fieldset disabled={!canWrite || saving || !detailsReady} className="flex min-h-0 min-w-0 flex-1 flex-col" aria-busy={saving || detailsLoading}>
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5"> <Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5"> <StepperNav className="mb-5">
<StepperItem step={1}> <StepperItem step={1}>
@@ -605,6 +772,7 @@ export default function ServersPage() {
<FormField label="Тип узла" required> <FormField label="Тип узла" required>
<SegmentedControl <SegmentedControl
className="max-w-full flex-wrap"
value={form.type} value={form.type}
onChange={v => set("type", v)} onChange={v => set("type", v)}
options={[ options={[
@@ -725,7 +893,7 @@ export default function ServersPage() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button type="button" variant="outline" size="sm" className="w-fit gap-2" <Button type="button" variant="outline" size="sm" className="w-fit gap-2"
disabled={testState === "testing"} onClick={handleTest}> disabled={!canWrite || testState === "testing"} onClick={handleTest}>
{testState === "testing" {testState === "testing"
? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</> ? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</>
: <><WifiIcon className="size-4" />Проверить подключение</>} : <><WifiIcon className="size-4" />Проверить подключение</>}
@@ -776,22 +944,40 @@ export default function ServersPage() {
</StepperContent> </StepperContent>
</StepperPanel> </StepperPanel>
</Stepper> </Stepper>
</fieldset>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2"> <SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose> <SheetClose render={<Button variant="outline" disabled={saving} />}>Отмена</SheetClose>
{sheetStep > 1 && ( {sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button> <Button variant="outline" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)} )}
{sheetStep < 4 ? ( {sheetStep < 4 ? (
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button> <Button className="ml-auto" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : ( ) : (
<Button className="ml-auto" onClick={handleSave}> <Button className="ml-auto" disabled={!canWrite || saving || !detailsReady || testState === "testing"} onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"} {saving ? "Сохранение…" : sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button> </Button>
)} )}
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<AlertDialog open={deleteTarget !== null} onOpenChange={next => { if (!next && !gate.current.has("rows")) setDeleteTarget(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить сервер?</AlertDialogTitle>
<AlertDialogDescription>Сервер «{deleteTarget?.name}» ({deleteTarget?.host}) будет удалён из списка управления.</AlertDialogDescription>
</AlertDialogHeader>
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
<AlertDialogFooter>
<Button type="button" variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>Отмена</Button>
<Button type="button" variant="destructive" disabled={!canWrite || deleting} onClick={() => { if (deleteTarget) void handleDelete(deleteTarget.id) }}>{deleting ? "Удаление…" : "Удалить"}</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
) )
} }
export default function ServersPage() {
return <Suspense fallback={<p role="status" className="p-6">Загрузка серверов…</p>}><ServersPageContent /></Suspense>
}
+49 -805
View File
@@ -1,291 +1,44 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { useState } from "react"
import Link from "next/link" import Link from "next/link"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit" import { useTheme } from "@/components/theme-provider"
import { FileImportDialog } from "@/components/file-import-dialog" import { SystemDatabaseSection } from "@/components/settings/system-database-section"
import { EvoBgpSettingsSection } from "@/components/settings/evobgp-settings-section"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { useEvoBGP, type EvoBgpSavePayload, type EvoBgpTestDraft } from "@/lib/evobgp-context"
import { DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS } from "@/lib/route-optimizer-data"
import { import {
AlertDialog, CheckIcon,
AlertDialogAction, AlertCircleIcon,
AlertDialogCancel, XIcon,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
SaveIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
AlertCircleIcon, EyeIcon, EyeOffIcon,
RefreshCwIcon, LoaderCircleIcon,
DownloadIcon, UploadIcon, XIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
import { purgeTrafficFlowData } from "@/shared/api/traffic-flow"
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
import { toast } from "sonner"
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
const INIT_API_KEYS: ApiKey[] = [
{ id: "k1", name: "Monitoring Script", prefix: "rl_live_mU9k2…", created: "12 дн назад", last: "2м назад", scopes: ["servers:read", "probes:read"] },
{ id: "k2", name: "CI/CD deploy hook", prefix: "rl_live_vB3p8…", created: "1 мес назад", last: "вчера", scopes: ["filters:write", "domains:write"] },
{ id: "k3", name: "Grafana datasource", prefix: "rl_live_xK7r1…", created: "2 мес назад", last: "сейчас", scopes: ["traffic:read", "servers:read"] },
]
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const
type NavSection = typeof SECTIONS_NAV[number] type NavSection = typeof SECTIONS_NAV[number]
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(backendUrl, path, init)
}
}
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) { function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
return ( return (
<div className="flex items-center justify-between gap-4 py-3.5"> <div className="flex flex-col items-start sm:flex-row sm:items-center sm:justify-between gap-3 py-3.5">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium">{label}</p> <p className="text-sm font-medium">{label}</p>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>} {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div> </div>
<div className="shrink-0">{children}</div> <div className="min-w-0 max-w-full">{children}</div>
</div> </div>
) )
} }
function DatabaseRestoreConfirm({
open,
filename,
busy,
onConfirm,
onCancel,
}: {
open: boolean
filename: string
busy?: boolean
onConfirm: () => void
onCancel: () => void
}) {
return (
<AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<AlertCircleIcon />
</AlertDialogMedia>
<AlertDialogTitle>Восстановить базу приложения?</AlertDialogTitle>
<AlertDialogDescription>
Файл: {filename}. Текущие данные PostgreSQL на бекенде будут полностью заменены.
Рекомендуется сначала скачать актуальный бэкап.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={busy}
onClick={onConfirm}
>
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
{busy ? "Восстановление…" : "Восстановить"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// ─── page ───────────────────────────────────────────────────────────────────── // ─── page ─────────────────────────────────────────────────────────────────────
export default function SettingsPage() { export default function SettingsPage() {
const [section, setSection] = useState<NavSection>("Общие") const [section, setSection] = useState<NavSection>("Общие")
const [saved, setSaved] = useState(false) const { mode, setMode, backendUrl, backendUrlLocked, mockModeAvailable, backendStatus, backendChecking, checkBackend } = useDataSource()
const [copied, setCopied] = useState<string | null>(null) const { theme, setTheme } = useTheme()
// data source
const { mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, mockModeAvailable, backendStatus, checkBackend } = useDataSource()
const evo = useEvoBGP()
const [evoBaseDraft, setEvoBaseDraft] = useState("")
const [evoEnabledDraft, setEvoEnabledDraft] = useState(false)
const [evoKeyDraft, setEvoKeyDraft] = useState("")
const [evoSaveBusy, setEvoSaveBusy] = useState(false)
const [evoSaveErr, setEvoSaveErr] = useState<string | null>(null)
const [urlDraft, setUrlDraft] = useState(backendUrl)
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
useEffect(() => {
setUrlDraft(backendUrl)
}, [backendUrl])
const commitBackendUrl = useCallback(() => {
const normalized = urlDraft.trim().replace(/\/$/, "")
setUrlDraft(normalized)
setBackendUrl(normalized)
void checkBackend()
}, [urlDraft, setBackendUrl, checkBackend])
const [evoTestResult, setEvoTestResult] = useState<{ ok: boolean; message: string } | null>(null)
const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
const [showEvoKey, setShowEvoKey] = useState(false)
// general
const [lang, setLang] = useState("ru")
const [theme, setTheme] = useState("system")
const [timezone, setTimezone] = useState("Europe/Moscow")
const [refreshSec, setRefreshSec] = useState("30")
const [probeTimeout, setProbeTimeout] = useState("5")
const [dbBackupBusy, setDbBackupBusy] = useState(false)
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
const [dbPurgeOpen, setDbPurgeOpen] = useState(false)
const [dbPurgeBusy, setDbPurgeBusy] = useState(false)
// notifications
const [notifEmail, setNotifEmail] = useState(true)
const [notifSlack, setNotifSlack] = useState(false)
const [notifWh, setNotifWh] = useState(true)
const [notifDegr, setNotifDegr] = useState(true)
const [notifOffline, setNotifOffline] = useState(true)
const [notifBgp, setNotifBgp] = useState(true)
const [notifBackup, setNotifBackup] = useState(false)
// api keys
const [apiKeys, setApiKeys] = useState<ApiKey[]>(INIT_API_KEYS)
// security
const [mfa, setMfa] = useState(true)
const [sessMin, setSessMin] = useState("480")
const [ipAllow, setIpAllow] = useState("10.0.0.0/8\n192.168.0.0/16")
const [auditLog, setAuditLog] = useState(true)
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text).catch(() => {})
setCopied(text); setTimeout(() => setCopied(null), 1500)
toast.success("Скопировано в буфер обмена")
}
const markSaved = useCallback(() => {
setSaved(true)
toast.success("Настройки сохранены")
setTimeout(() => setSaved(false), 2000)
}, [])
useEffect(() => {
if (section !== "EvoBGP") return
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
}, [section, mode, backendStatus, evo.loadSettings])
useEffect(() => {
if (section !== "EvoBGP" || !evo.settingsLoaded) return
setEvoBaseDraft(evo.baseUrl)
setEvoEnabledDraft(evo.enabled)
setEvoKeyDraft("")
setEvoSaveErr(null)
}, [section, evo.settingsLoaded, evo.baseUrl, evo.enabled])
const handleSave = useCallback(async () => {
if (section === "EvoBGP") {
if (mode !== "live" || backendStatus !== true) {
markSaved()
return
}
setEvoSaveBusy(true)
setEvoSaveErr(null)
try {
const patch: EvoBgpSavePayload = {
baseUrl: evoBaseDraft.trim(),
enabled: evoEnabledDraft,
}
if (evoKeyDraft.trim()) patch.apiKey = evoKeyDraft.trim()
await evo.saveSettings(patch)
setEvoKeyDraft("")
markSaved()
toast.success("Настройки EvoBGP сохранены")
} catch (e) {
const msg = e instanceof Error ? e.message : "Ошибка сохранения"
setEvoSaveErr(msg)
toast.error(msg)
} finally {
setEvoSaveBusy(false)
}
return
}
markSaved()
}, [
section,
mode,
backendStatus,
evoBaseDraft,
evoEnabledDraft,
evoKeyDraft,
markSaved,
evo.saveSettings,
])
const systemDbAvailable = mode === "live" && backendStatus === true
const handleSystemDatabaseBackup = useCallback(async () => {
if (!systemDbAvailable) return
setDbBackupBusy(true)
try {
const { blob, filename } = await downloadSystemDatabaseBackup(backendUrl)
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
toast.success("Бэкап базы приложения скачан")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось создать бэкап")
} finally {
setDbBackupBusy(false)
}
}, [backendUrl, systemDbAvailable])
const handleSystemDatabaseRestoreConfirm = useCallback(async () => {
if (!dbRestoreFile || !systemDbAvailable) return
setDbRestoreBusy(true)
try {
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
toast.success("База приложения восстановлена")
setDbRestoreFile(null)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
} finally {
setDbRestoreBusy(false)
}
}, [backendUrl, dbRestoreFile, systemDbAvailable])
const handleNetflowPurgeConfirm = useCallback(async () => {
if (!systemDbAvailable) return
setDbPurgeBusy(true)
try {
const result = await purgeTrafficFlowData(backendUrl)
toast.success(formatFlowPurgeResult(result))
setDbPurgeOpen(false)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сбросить NetFlow")
} finally {
setDbPurgeBusy(false)
}
}, [backendUrl, systemDbAvailable])
const renderContent = () => { const renderContent = () => {
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
// ── Общие ── // ── Общие ──
if (section === "Общие") return ( if (section === "Общие") return (
@@ -296,18 +49,18 @@ export default function SettingsPage() {
title="Источник данных" title="Источник данных"
description={ description={
mockModeAvailable mockModeAvailable
? "Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts" ? "Выберите живые или демонстрационные данные. Режим применяется сразу в этом браузере."
: "В продакшен-образе доступны только живые данные от бекенда" : "Источник живых данных задан конфигурацией приложения."
} }
contentClassName="divide-y px-5" contentClassName="divide-y px-5"
> >
{/* mode toggle */} {/* mode toggle */}
{mockModeAvailable ? ( {mockModeAvailable ? (
<SettingRow label="Режим данных" description="Моковые — статичные тестовые данные; Живые — RouterOS REST API через бекенд"> <SettingRow label="Режим данных" description="Демонстрационный режим использует тестовые данные. Живой режим обращается к серверу.">
<div className="flex rounded-md border border-input overflow-hidden h-8"> <div className="flex rounded-md border border-input overflow-hidden h-8">
{([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => ( {([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => (
<button key={v} onClick={() => setMode(v)} <button key={v} type="button" aria-pressed={mode===v} onClick={() => setMode(v)}
className={cn( className={cn(
"px-3 text-xs transition-colors border-input", "px-3 text-xs transition-colors border-input",
i === 0 && "border-r", i === 0 && "border-r",
@@ -333,31 +86,11 @@ export default function SettingsPage() {
label="URL бекенда" label="URL бекенда"
description={ description={
backendUrlLocked backendUrlLocked
? "В Docker-образе URL API задаётся через прокси фронтенда на backend; поле только для просмотра." ? "Адрес подключения задан конфигурацией приложения."
: "Адрес Node.js/Fastify сервера (запускается командой npm run dev в /backend)" : "Адрес сервера приложения. Изменение применяется после Enter или выхода из поля."
} }
> >
<div className="flex items-center gap-2"> <BackendUrlField key={backendUrl} />
<Input
className="w-56 h-8 text-sm font-mono"
value={urlDraft}
onChange={e => setUrlDraft(e.target.value)}
onBlur={commitBackendUrl}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault()
commitBackendUrl()
}
}}
readOnly={backendUrlLocked}
placeholder="http://localhost:8000"
/>
<Button variant="outline" size="sm" className="h-8 px-2.5 shrink-0"
onClick={commitBackendUrl}
title="Проверить соединение">
<RefreshCwIcon className="size-3.5" />
</Button>
</div>
</SettingRow> </SettingRow>
{/* connection status */} {/* connection status */}
@@ -366,11 +99,11 @@ export default function SettingsPage() {
{backendStatus === undefined && ( {backendStatus === undefined && (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground"> <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="size-2 rounded-full bg-muted-foreground/40 inline-block" /> <span className="size-2 rounded-full bg-muted-foreground/40 inline-block" />
Не проверялось {backendChecking ? "Проверка…" : "Не проверялось"}
</span> </span>
)} )}
{backendStatus === true && ( {backendStatus === true && (
<span className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400"> <span className="flex items-center gap-1.5 text-xs text-emerald-700 dark:text-emerald-400">
<CheckIcon className="size-3.5" />Доступен <CheckIcon className="size-3.5" />Доступен
</span> </span>
)} )}
@@ -388,516 +121,49 @@ export default function SettingsPage() {
{/* hint when live but backend unreachable */} {/* hint when live but backend unreachable */}
{mode === "live" && backendStatus === false && ( {mode === "live" && backendStatus === false && (
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400"> <div className="flex items-start gap-2 py-3 text-xs text-amber-700 dark:text-amber-400">
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" /> <AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
<span>Бекенд недоступен — данные будут отображаться из кеша или моков. Запустите бекенд: <code className="font-mono bg-muted px-1 rounded">cd backend && npm run dev</code></span> <span>Сервер недоступен. Последние полученные данные сохраняются; новые запросы могут завершаться ошибкой.</span>
</div> </div>
)} )}
</OpsPanel> </OpsPanel>
{/* ── Основные настройки ── */} <OpsPanel title="Интерфейс" description="Тема применяется сразу в этом браузере." contentClassName="divide-y px-5">
<OpsPanel title="Основные настройки" contentClassName="divide-y px-5"> <SettingRow label="Язык интерфейса"><span className="text-sm">Русский</span></SettingRow>
<SettingRow label="Язык интерфейса"> <SettingRow label="Тема оформления"><div className="flex flex-wrap gap-2">
<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" {([["light","Светлая"],["dark","Тёмная"],["system","Авто"]] as const).map(([value,label]) => <Button key={value} size="sm" variant={theme===value?"default":"outline"} aria-pressed={theme===value} disabled={!theme} onClick={()=>setTheme(value)}>{label}</Button>)}
value={lang} onChange={e => setLang(e.target.value)}> </div></SettingRow>
<option value="ru">Русский</option> <p className="py-3 text-xs text-muted-foreground">Периоды сбора данных и параметры диагностики настраиваются в соответствующих разделах.</p>
<option value="en">English</option> <div className="flex flex-wrap gap-2 pb-4"><Link href="/data-collection" className={buttonVariants({variant:"outline",size:"sm"})}>Сбор данных</Link><Link href="/probes" className={buttonVariants({variant:"outline",size:"sm"})}>Диагностика</Link></div>
</select>
</SettingRow>
<SettingRow label="Тема оформления">
<div className="flex rounded-md border border-input overflow-hidden h-8">
{[["light","Светлая"],["dark","Тёмная"],["system","Авто"]].map(([v, l]) => (
<button key={v} onClick={() => setTheme(v)}
className={cn("px-3 text-xs transition-colors border-r last:border-r-0 border-input",
theme === v ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}>
{l}
</button>
))}
</div>
</SettingRow>
<SettingRow label="Часовой пояс">
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
value={timezone} onChange={e => setTimezone(e.target.value)}>
{["Europe/Moscow","Europe/Berlin","Europe/Amsterdam","Asia/Singapore","UTC"].map(z => (
<option key={z} value={z}>{z}</option>
))}
</select>
</SettingRow>
<SettingRow label="Интервал автообновления (сек)"
description="Как часто страницы обновляют данные">
<Input className="w-20 h-8 text-sm" value={refreshSec} onChange={e => setRefreshSec(e.target.value)} />
</SettingRow>
<SettingRow label="Таймаут диагностики (сек)"
description="Максимальное время ожидания при probe-тестах">
<Input className="w-20 h-8 text-sm" value={probeTimeout} onChange={e => setProbeTimeout(e.target.value)} />
</SettingRow>
</OpsPanel> </OpsPanel>
<OpsPanel <SystemDatabaseSection />
title="База данных приложения"
description="Резервная копия PostgreSQL (pg_dump custom) и сброс таблиц NetFlow. На время операции планировщик и коллектор IPFIX приостанавливаются. Preview: https://reui.io/preview/base/settings-16"
contentClassName="divide-y px-5"
>
{!systemDbAvailable && (
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
<span>Доступно только в live-режиме при доступном бекенде.</span>
</div>
)}
<SettingRow
label="Скачать бэкап"
description="Консистентный дамп PostgreSQL (pg_dump -Fc)"
>
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
onClick={() => { void handleSystemDatabaseBackup() }}
>
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
{dbBackupBusy ? "Подготовка…" : "Скачать"}
</Button>
</SettingRow>
<SettingRow
label="Восстановить из файла"
description="Полностью заменяет текущую базу PostgreSQL (pg_restore)"
>
<div className="flex flex-col items-end gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
onClick={() => setDbRestoreDialogOpen(true)}
>
<UploadIcon className="size-4" />
Выбрать файл
</Button>
{dbRestoreFile && (
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
)}
</div>
</SettingRow>
<SettingRow
label="Сбросить данные NetFlow"
description="Удалит сессии и агрегаты NetFlow в PostgreSQL. Ключи WG и пиры не трогает"
>
<Button
variant="destructive"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
onClick={() => setDbPurgeOpen(true)}
>
{dbPurgeBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <TrashIcon className="size-4" />}
{dbPurgeBusy ? "Сброс…" : "Сбросить"}
</Button>
</SettingRow>
</OpsPanel>
<OpsPanel <OpsPanel id="route-ai" title="Оптимизатор маршрутов" description="Текущие пороги, веса и режим применения доступны на странице оптимизатора." contentClassName="px-5 py-4"><Link href="/route-optimizer" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оптимизатор</Link></OpsPanel>
id="route-ai"
className="scroll-mt-4"
title="Route AI"
description="Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и веса настраиваются в инструменте, не через отдельный API."
contentClassName="flex flex-col gap-4 px-5 pb-5"
>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-xs">
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
<dt className="text-muted-foreground">Мин. выигрыш (переключение)</dt>
<dd className="font-mono tabular-nums">{ra.switchThreshold}%</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
<dt className="text-muted-foreground">Гистерезис</dt>
<dd className="font-mono tabular-nums">{ra.hysteresisThreshold}%</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
<dt className="text-muted-foreground">Вес задержки (ping)</dt>
<dd className="font-mono tabular-nums">{ra.pingWeight}%</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
<dt className="text-muted-foreground">Интервал зондирования</dt>
<dd className="font-mono tabular-nums">{ra.probeIntervalMin} мин</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5 sm:col-span-2">
<dt className="text-muted-foreground">Автоприменение</dt>
<dd>{ra.autoApply ? `да, каждые ${ra.autoApplyIntervalMin} мин` : "нет"}</dd>
</div>
</dl>
<Link
href="/route-optimizer"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
>
Открыть оптимизатор маршрутов
</Link>
</OpsPanel>
</div> </div>
) )
// ── EvoBGP ── if (section === "EvoBGP") return <EvoBgpSettingsSection />
if (section === "EvoBGP") return (
<div className="flex flex-col gap-4">
{(mode !== "live" || backendStatus !== true) && (
<Frame dense className="w-full">
<FramePanel className="px-4 py-4">
<p className="text-sm font-medium">Интеграция доступна в live-режиме</p>
<p className="text-xs text-muted-foreground mt-1">
Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»).
</p>
</FramePanel>
</Frame>
)}
<OpsPanel if (section === "Уведомления") return <OpsPanel title="Оповещения" description="Каналы и правила оповещений настраиваются в отдельном разделе." contentClassName="px-5 py-4"><Link href="/alerts" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оповещения</Link></OpsPanel>
title="EvoBGP API" if (section === "API-ключи") return <OpsPanel title="API-ключи" contentClassName="px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Создание и управление отдельными API-ключами в приложении пока недоступны.</p></OpsPanel>
description={ return <OpsPanel title="Безопасность" description="Параметры доступа задаются в сервисе авторизации и конфигурации сервера." contentClassName="flex flex-col gap-3 px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Состояние MFA, ограничений IP и настроек аудита в этом разделе не получено. Изменение этих параметров здесь недоступно.</p><Link href="/users" className={buttonVariants({variant:"outline",size:"sm"})}>Управление пользователями</Link></OpsPanel>
<>
Control plane EvoBGP: Bearer-ключ и роль viewer+ — см.{" "}
<a
className="underline underline-offset-2"
href="https://git.shx.one/denozord/EvoBGP/src/branch/main/docs/access.md"
target="_blank"
rel="noreferrer"
>
access.md
</a>
. URL и ключ хранятся в PostgreSQL на сервере бекенда (
<code className="font-mono bg-muted px-1 rounded">evobgp_settings</code>
). Каталог —{" "}
<code className="font-mono bg-muted px-1 rounded">GET /v1/router-lists/catalog</code> через прокси.
</>
}
contentClassName="divide-y px-5 flex flex-col gap-4 pb-5"
>
<SettingRow
label="Базовый URL API"
description="Например http://control.example:8080 — без суффикса /v1"
>
<Input
className="w-full max-w-md h-8 text-sm font-mono"
value={evoBaseDraft}
onChange={(e) => setEvoBaseDraft(e.target.value)}
placeholder="http://localhost:8080"
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
/>
</SettingRow>
<SettingRow
label="API-ключ"
description={
evo.secretConfigured
? "В БД уже есть ключ — введите новый только для замены"
: "Сохраняется общей кнопкой «Сохранить» в шапке страницы"
}
>
<div className="flex items-center gap-2">
<Input
className="w-full max-w-md h-8 text-sm font-mono"
type={showEvoKey ? "text" : "password"}
autoComplete="off"
value={evoKeyDraft}
onChange={(e) => setEvoKeyDraft(e.target.value)}
placeholder={evo.secretConfigured ? "Оставьте пустым, чтобы не менять" : "Bearer-токен"}
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0"
onClick={() => setShowEvoKey((v) => !v)}
>
{showEvoKey ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</Button>
</div>
</SettingRow>
<SettingRow
label="Подставлять данные EvoBGP"
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
>
<FormToggle
checked={evoEnabledDraft}
onChange={(v) => setEvoEnabledDraft(v)}
/>
</SettingRow>
<div className="flex flex-wrap items-center gap-2 pt-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-destructive border-destructive/40 hover:bg-destructive/10"
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy || !evo.secretConfigured}
onClick={async () => {
setEvoSaveBusy(true)
setEvoSaveErr(null)
try {
await evo.saveSettings({ apiKey: null })
setEvoKeyDraft("")
markSaved()
} catch (e) {
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка")
} finally {
setEvoSaveBusy(false)
}
}}
>
Удалить ключ из БД
</Button>
</div>
{evoSaveErr && <p className="text-xs text-destructive">{evoSaveErr}</p>}
<p className="text-[11px] text-muted-foreground">
«Проверить ключ» использует поля формы; незаполненное поле подставляется из БД. «Обновить каталог» — только по сохранённым в БД настройкам.
</p>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={
mode !== "live" ||
backendStatus !== true ||
evoBusy !== null ||
evoSaveBusy ||
!(
(evoBaseDraft.trim() || evo.baseUrl.trim()) &&
(evoKeyDraft.trim() || evo.secretConfigured)
)
}
onClick={async () => {
setEvoBusy("test")
setEvoTestResult(null)
const b = evoBaseDraft.trim()
const k = evoKeyDraft.trim()
let draft: EvoBgpTestDraft | undefined
if (b || k) {
draft = {}
if (b) draft.baseUrl = b
if (k) draft.apiKey = k
}
const r = await evo.testConnection(draft)
setEvoTestResult({ ok: r.ok, message: r.message })
setEvoBusy(null)
}}
>
{evoBusy === "test" ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
Проверить ключ
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={mode !== "live" || backendStatus !== true || evoBusy !== null || evoSaveBusy || !evo.enabled}
onClick={async () => {
setEvoBusy("refresh")
await evo.refresh()
setEvoBusy(null)
}}
>
{evoBusy === "refresh" || evo.loading ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
Обновить каталог
</Button>
{evo.snapshot?.fetchedAt && (
<span className="text-xs text-muted-foreground">
Загружено: {new Date(evo.snapshot.fetchedAt).toLocaleString("ru-RU")}
</span>
)}
</div>
{evoTestResult && (
<p className={cn(
"text-xs",
evoTestResult.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive",
)}>
{evoTestResult.message}
</p>
)}
{evo.error && (
<p className="text-xs text-destructive">{evo.error}</p>
)}
</OpsPanel>
</div>
)
// ── Уведомления ──
if (section === "Уведомления") return (
<div className="flex flex-col gap-4">
<OpsPanel title="Каналы уведомлений" contentClassName="divide-y px-5">
<SettingRow label="Email" description="Отправка уведомлений на admin@routerlists.io">
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
</SettingRow>
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="admin@routerlists.io" /></div>}
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
<FormToggle checked={notifSlack} onChange={setNotifSlack} />
</SettingRow>
{notifSlack && <div className="py-3"><Input className="text-sm h-8 font-mono" placeholder="https://hooks.slack.com/…" /></div>}
<SettingRow label="Webhook" description="POST-запрос на произвольный endpoint">
<FormToggle checked={notifWh} onChange={setNotifWh} />
</SettingRow>
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
</OpsPanel>
<OpsPanel title="Триггеры" contentClassName="divide-y px-5">
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
</SettingRow>
<SettingRow label="Узел ушёл offline">
<FormToggle checked={notifOffline} onChange={setNotifOffline} />
</SettingRow>
<SettingRow label="Падение BGP-сессии">
<FormToggle checked={notifBgp} onChange={setNotifBgp} />
</SettingRow>
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
</SettingRow>
</OpsPanel>
</div>
)
// ── API-ключи ──
if (section === "API-ключи") return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button size="sm"><PlusIcon className="size-4" />Создать ключ</Button>
</div>
<div className="flex flex-col gap-3">
{apiKeys.map(k => (
<Frame key={k.id} dense className="w-full">
<FramePanel className="pt-4 pb-3 px-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">{k.name}</p>
<div className="flex items-center gap-2 mt-1">
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{k.prefix}</code>
<button onClick={() => handleCopy(k.prefix)} className="text-muted-foreground/40 hover:text-muted-foreground transition-colors">
{copied === k.prefix ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
</button>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{k.scopes.map(s => (
<span key={s} className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{s}</span>
))}
</div>
</div>
<div className="text-right shrink-0">
<p className="text-xs text-muted-foreground">Создан: {k.created}</p>
<p className="text-xs text-muted-foreground mt-0.5">Использован: {k.last}</p>
<div className="flex gap-1 justify-end mt-2">
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs">Переиздать</Button>
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"
onClick={() => setApiKeys(p => p.filter(x => x.id !== k.id))}>
<TrashIcon className="size-3.5" />
</Button>
</div>
</div>
</div>
</FramePanel>
</Frame>
))}
</div>
<OpsPanel
title="Документация API"
description="Base URL: https://api.routerlists.io/v1"
contentClassName="px-4 pb-4"
>
<div className="grid grid-cols-2 gap-2 text-xs">
{[
["GET", "/servers", "Список серверов"],
["GET", "/traffic", "Данные трафика"],
["GET", "/filters", "Фильтры маршрутов"],
["POST", "/filters", "Создать фильтр"],
["GET", "/probes", "Диагностические пробы"],
["POST", "/probes/run", "Запустить тест"],
].map(([method, path, desc], i) => (
<div key={`${method}-${path}-${i}`} className="flex items-baseline gap-2">
<span className={cn("font-mono text-[10px] font-bold w-8 shrink-0",
method === "GET" ? "text-emerald-500" : "text-sky-500")}>{method}</span>
<code className="font-mono text-muted-foreground">{path}</code>
<span className="text-muted-foreground/60 text-[10px]">{desc}</span>
</div>
))}
</div>
</OpsPanel>
</div>
)
// ── Безопасность ──
if (section === "Безопасность") return (
<div className="flex flex-col gap-4">
<OpsPanel title="Аутентификация" contentClassName="divide-y px-5">
<SettingRow label="Двухфакторная аутентификация (MFA)"
description="TOTP / Authenticator app для всех администраторов">
<FormToggle checked={mfa} onChange={setMfa} />
</SettingRow>
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
</SettingRow>
</OpsPanel>
<OpsPanel
title="Allowlist IP-адресов"
description="Доступ разрешён только с этих сетей. Одна запись на строку."
contentClassName="px-4 pb-4"
>
<textarea value={ipAllow} onChange={e => setIpAllow(e.target.value)} rows={4}
className="w-full rounded-md border bg-muted px-3 py-2 text-xs font-mono resize-none outline-none focus:ring-1 focus:ring-ring" />
</OpsPanel>
<OpsPanel title="Аудит" contentClassName="divide-y px-5">
<SettingRow label="Расширенный журнал аудита"
description="Записывать все изменения конфигурации с указанием пользователя и IP">
<FormToggle checked={auditLog} onChange={setAuditLog} />
</SettingRow>
</OpsPanel>
<OpsPanel title={<span className="text-destructive">Опасная зона</span>} contentClassName="px-4 pb-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Сбросить все настройки</p>
<p className="text-xs text-muted-foreground">Удалит пользовательские параметры и вернёт заводские значения</p>
</div>
<Button variant="outline" size="sm" className="border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground">
Сбросить
</Button>
</div>
</OpsPanel>
</div>
)
} }
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Система" }, { label: "Настройки" }]} crumbs={[{ label: "Система" }, { label: "Настройки" }]}
actions={
<Button
size="sm"
onClick={() => { void handleSave() }}
disabled={
section === "EvoBGP" &&
(evoSaveBusy || mode !== "live" || backendStatus !== true)
}
>
{section === "EvoBGP" && evoSaveBusy ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : saved ? (
<CheckIcon className="size-4" />
) : (
<SaveIcon className="size-4" />
)}
{section === "EvoBGP" && evoSaveBusy
? "Сохранение…"
: saved
? "Сохранено!"
: "Сохранить"}
</Button>
}
/> />
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="max-w-[960px] mx-auto grid grid-cols-[180px_1fr] gap-6"> <div className="max-w-[960px] mx-auto grid grid-cols-1 md:grid-cols-[180px_minmax(0,1fr)] gap-6">
{/* nav */} {/* nav */}
<nav className="flex flex-col gap-1 self-start sticky top-0"> <nav className="flex flex-wrap md:flex-col gap-1 self-start md:sticky md:top-0">
{SECTIONS_NAV.map(s => ( {SECTIONS_NAV.map(s => (
<button key={s} onClick={() => setSection(s)} <button key={s} aria-current={s===section?"page":undefined} onClick={() => setSection(s)}
className={cn( className={cn(
"text-left px-3 py-2 rounded-md text-sm transition-colors", "text-left px-3 py-2 rounded-md text-sm transition-colors",
s === section ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground hover:text-foreground", s === section ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground hover:text-foreground",
@@ -912,40 +178,18 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* delete confirm */}
{dbRestoreFile && (
<DatabaseRestoreConfirm
open={!!dbRestoreFile}
filename={dbRestoreFile.name}
busy={dbRestoreBusy}
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
onCancel={() => {
if (dbRestoreBusy) return
setDbRestoreFile(null)
}}
/>
)}
<NetflowPurgeConfirm
open={dbPurgeOpen}
busy={dbPurgeBusy}
onConfirm={() => { void handleNetflowPurgeConfirm() }}
onCancel={() => { if (!dbPurgeBusy) setDbPurgeOpen(false) }}
/>
<FileImportDialog
open={dbRestoreDialogOpen}
onOpenChange={setDbRestoreDialogOpen}
title="Восстановление базы данных"
description="Выберите файл pg_dump custom (.dump) — текущая база будет полностью заменена"
accept=".dump,.backup,application/octet-stream"
onImport={async (files) => {
const file = files[0]
if (!file) return
setDbRestoreFile(file)
}}
/>
</div> </div>
) )
} }
function BackendUrlField(){
const {backendUrl,setBackendUrl,backendUrlLocked}=useDataSource()
const [draft,setDraft]=useState(backendUrl),[error,setError]=useState<string|null>(null)
function commit(){
if(backendUrlLocked||draft===backendUrl)return
try{const url=new URL(draft.trim());if(!["http:","https:"].includes(url.protocol)||url.username||url.password||url.search||url.hash)throw Error();setBackendUrl(url.toString().replace(/\/$/,""));setError(null)}
catch{setError("Укажите HTTP(S) адрес без учётных данных и параметров")}
}
return <div className="flex min-w-0 flex-col gap-2"><Input aria-label="URL бекенда" className="w-full sm:w-64 font-mono" value={draft} onChange={e=>{setDraft(e.target.value);setError(null)}} onBlur={commit} onKeyDown={e=>{if(e.key==="Enter"){e.preventDefault();commit()}}} readOnly={backendUrlLocked} aria-invalid={!!error} placeholder="http://localhost:8000"/>{error?<p role="alert" className="text-xs text-destructive">{error}</p>:null}</div>
}
+94 -85
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import { import {
ActivityIcon, ActivityIcon,
DatabaseIcon, DatabaseIcon,
@@ -9,6 +9,7 @@ import {
ServerIcon, ServerIcon,
UsersIcon, UsersIcon,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
@@ -26,13 +27,14 @@ import {
type StatisticsSliceKind, type StatisticsSliceKind,
} from "@/components/data-grids/statistics-breakdown-data-grid" } from "@/components/data-grids/statistics-breakdown-data-grid"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import type { Filter } from "@/components/reui/filters" import { StatisticsFilters } from "@/components/statistics/statistics-filters"
import { STATISTICS_FILTER_FIELDS } from "@/lib/data-filters/statistics-filter-fields"
import { import {
isStatisticsPivotDim, isStatisticsPivotDim,
isStatisticsSliceKind, isStatisticsSliceKind,
STATISTICS_DIMS, STATISTICS_DIMS,
} from "@/lib/statistics-dims" } from "@/lib/statistics-dims"
import { statisticsContextError, STATISTICS_CONTEXT_KEYS } from "@/lib/statistics-context"
import { readStatisticsRange } from "@/lib/statistics-range"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { fmtBps, formatBytes } from "@/lib/fmt-rate" import { fmtBps, formatBytes } from "@/lib/fmt-rate"
import { import {
@@ -95,13 +97,6 @@ interface CubeSlices {
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
function readRange(sp: URLSearchParams): DateRangeYmd {
const from = sp.get("from")
const to = sp.get("to")
if (from && to && from <= to) return { from, to }
return rangeForPreset("7d")
}
function readDim(sp: URLSearchParams): StatisticsSliceKind { function readDim(sp: URLSearchParams): StatisticsSliceKind {
const t = sp.get("dim") ?? sp.get("tab") const t = sp.get("dim") ?? sp.get("tab")
return t && isStatisticsSliceKind(t) ? t : "users" return t && isStatisticsSliceKind(t) ? t : "users"
@@ -111,6 +106,10 @@ function readView(sp: URLSearchParams): "explore" | "pivot" {
return sp.get("view") === "pivot" ? "pivot" : "explore" return sp.get("view") === "pivot" ? "pivot" : "explore"
} }
function readPlanes(sp: URLSearchParams): "unique" | "all" {
return sp.get("planes") === "all" ? "all" : "unique"
}
function readPivotDim(sp: URLSearchParams, key: string, fallback: StatisticsPivotDim): StatisticsPivotDim { function readPivotDim(sp: URLSearchParams, key: string, fallback: StatisticsPivotDim): StatisticsPivotDim {
const v = sp.get(key) const v = sp.get(key)
return v && isStatisticsPivotDim(v) ? v : fallback return v && isStatisticsPivotDim(v) ? v : fallback
@@ -125,30 +124,7 @@ function readSlices(sp: URLSearchParams): CubeSlices {
return next return next
} }
function slicesToFilters(slices: CubeSlices): Filter[] { function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery {
return SLICE_KEYS.flatMap((key) => {
const val = slices[key]
if (!val) return []
return [{ id: key, field: key, operator: "is", values: [val] }]
})
}
function filtersToSlices(filters: Filter[]): CubeSlices {
const next: CubeSlices = {}
for (const f of filters) {
const raw = String(f.values[0] ?? "").trim()
if (!raw) continue
if (f.field === "country") next.country = raw.toUpperCase().slice(0, 2)
else if (f.field === "service") next.service = raw
else if (f.field === "asn") next.asn = raw.replace(/[^\d]/g, "")
else if (f.field === "serverId") next.serverId = raw
else if (f.field === "userId") next.userId = raw
else if (f.field === "iface") next.iface = raw
}
return next
}
function toQuery(range: DateRangeYmd, slices: CubeSlices): StatisticsQuery {
const serverId = slices.serverId ? Number(slices.serverId) : undefined const serverId = slices.serverId ? Number(slices.serverId) : undefined
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
return { return {
@@ -160,6 +136,7 @@ function toQuery(range: DateRangeYmd, slices: CubeSlices): StatisticsQuery {
country: slices.country && slices.country.length === 2 ? slices.country : undefined, country: slices.country && slices.country.length === 2 ? slices.country : undefined,
service: slices.service, service: slices.service,
asn: Number.isFinite(asn) ? asn : undefined, asn: Number.isFinite(asn) ? asn : undefined,
planes,
} }
} }
@@ -249,48 +226,55 @@ function chipList(slices: CubeSlices): { key: string; label: string }[] {
return chips return chips
} }
export default function StatisticsPage() { function StatisticsPageInner() {
const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { mode, backendUrl, prefsHydrated } = useDataSource() const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
const range = useMemo(() => readRange(searchParams), [searchParams]) const { range, error: rangeError } = useMemo(() => readStatisticsRange(searchParams, rangeForPreset("7d")), [searchParams])
const paramsError = useMemo(() => statisticsContextError(searchParams), [searchParams])
const slices = useMemo(() => readSlices(searchParams), [searchParams]) const slices = useMemo(() => readSlices(searchParams), [searchParams])
const filters = useMemo(() => slicesToFilters(slices), [slices])
const dim = useMemo(() => readDim(searchParams), [searchParams]) const dim = useMemo(() => readDim(searchParams), [searchParams])
const view = useMemo(() => readView(searchParams), [searchParams]) const view = useMemo(() => readView(searchParams), [searchParams])
const planes = useMemo(() => readPlanes(searchParams), [searchParams])
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams]) const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams]) const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
const [data, setData] = useState<StatisticsDto>(EMPTY) const [retry, setRetry] = useState(0)
const [pivot, setPivot] = useState<StatisticsPivotDto>(EMPTY_PIVOT) const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry, rangeError, paramsError })
const [loading, setLoading] = useState(false) const [result, setResult] = useState<{
const [error, setError] = useState<string | null>(null) key: string; data: StatisticsDto; pivot: StatisticsPivotDto; error: string | null
} | null>(null)
const current = result?.key === requestKey ? result : null
const loading = isLive && !rangeError && !paramsError && current === null
const error = rangeError ?? paramsError ?? current?.error ?? null
const dataReady = isLive && current !== null && !error
const pivot = dataReady ? current.pivot : EMPTY_PIVOT
const replaceParams = useCallback( const updateParams = useCallback(
(patch: Record<string, string | undefined>) => { (patch: Record<string, string | undefined>) => {
const sp = new URLSearchParams(searchParams.toString()) const sp = new URLSearchParams(window.location.search)
for (const [k, v] of Object.entries(patch)) { for (const [k, v] of Object.entries(patch)) {
if (v) sp.set(k, v) if (v) sp.set(k, v)
else sp.delete(k) else sp.delete(k)
} }
const qs = sp.toString() const qs = sp.toString()
router.replace(qs ? `/statistics?${qs}` : "/statistics") if (qs === searchParams.toString()) return
window.history.pushState(null, "", (qs ? `/statistics?${qs}` : "/statistics") + window.location.hash)
}, },
[router, searchParams], [searchParams],
) )
const setRange = useCallback( const setRange = useCallback(
(next: DateRangeYmd) => { (next: DateRangeYmd) => {
replaceParams({ from: next.from, to: next.to }) updateParams({ from: next.from, to: next.to })
}, },
[replaceParams], [updateParams],
) )
const setSlices = useCallback( const setSlices = useCallback(
(next: CubeSlices) => { (next: CubeSlices) => {
replaceParams({ updateParams({
country: next.country, country: next.country,
service: next.service, service: next.service,
asn: next.asn, asn: next.asn,
@@ -299,49 +283,45 @@ export default function StatisticsPage() {
iface: next.iface, iface: next.iface,
}) })
}, },
[replaceParams], [updateParams],
) )
useEffect(() => { useEffect(() => {
if (!prefsHydrated || !isLive) return if (!prefsHydrated || !isLive || rangeError || paramsError) return
let cancelled = false let cancelled = false
void (async () => { void (async () => {
setLoading(true)
setError(null)
try { try {
const query = toQuery(range, slices) const query = toQuery(range, slices, planes)
const dto = await getStatistics(backendUrl, query) const dto = await getStatistics(backendUrl, query)
if (!cancelled) setData(dto) if (cancelled) return
let matrix = EMPTY_PIVOT
if (view === "pivot" && pivotRow !== pivotCol) { if (view === "pivot" && pivotRow !== pivotCol) {
const matrix = await getStatisticsPivot(backendUrl, { matrix = await getStatisticsPivot(backendUrl, {
...query, ...query,
row: pivotRow, row: pivotRow,
col: pivotCol, col: pivotCol,
metric: "bytes", metric: "bytes",
}) })
if (!cancelled) setPivot(matrix)
} }
if (!cancelled) setResult({ key: requestKey, data: dto, pivot: matrix, error: null })
} catch (e: unknown) { } catch (e: unknown) {
if (!cancelled) { if (!cancelled) {
setData(EMPTY) setResult({ key: requestKey, data: EMPTY, pivot: EMPTY_PIVOT, error: e instanceof Error ? e.message : "Не удалось загрузить статистику" })
setPivot(EMPTY_PIVOT)
setError(e instanceof Error ? e.message : "Не удалось загрузить статистику")
} }
} finally {
if (!cancelled) setLoading(false)
} }
})() })()
return () => { return () => {
cancelled = true cancelled = true
} }
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol]) }, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes, requestKey, rangeError, paramsError])
const viewData = isLive ? data : EMPTY const viewData = dataReady ? current.data : EMPTY
const sliced = hasAnySlice(slices) const sliced = hasAnySlice(slices)
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0) const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
function handleRowClick(kind: StatisticsSliceKind, row: StatisticsBreakdownRow) { function handleRowClick(kind: StatisticsSliceKind, row: StatisticsBreakdownRow) {
if (kind === "users" && row.id === STATISTICS_UNBOUND_USER_ID) return if (kind === "users" && row.id === STATISTICS_UNBOUND_USER_ID) return
if (kind === "interfaces" && row.label.includes("· дубль")) return
setSlices(applyDimValue(slices, kind, row.id)) setSlices(applyDimValue(slices, kind, row.id))
} }
@@ -349,7 +329,7 @@ export default function StatisticsPage() {
if (rowId === "__other__" || colId === "__other__") return if (rowId === "__other__" || colId === "__other__") return
let next = applyPivotDim(slices, pivotRow, rowId) let next = applyPivotDim(slices, pivotRow, rowId)
next = applyPivotDim(next, pivotCol, colId) next = applyPivotDim(next, pivotCol, colId)
replaceParams({ updateParams({
country: next.country, country: next.country,
service: next.service, service: next.service,
asn: next.asn, asn: next.asn,
@@ -373,7 +353,7 @@ export default function StatisticsPage() {
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<PageHeader <PageHeader
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]} crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
actions={<PeriodSelector range={range} onChange={setRange} />} actions={rangeError ? <Button type="button" variant="outline" onClick={() => setRange(rangeForPreset("7d"))}>Выбрать последние 7 дней</Button> : <PeriodSelector range={range} onChange={setRange} />}
/> />
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5"> <div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
@@ -387,13 +367,22 @@ export default function StatisticsPage() {
) : null} ) : null}
{error ? ( {error ? (
<Alert variant="destructive"> <Alert variant="destructive" role="alert">
<AlertTitle>Ошибка загрузки</AlertTitle> <AlertTitle>{rangeError ? "Некорректный период" : paramsError ? "Параметры отчёта требуют внимания" : "Ошибка загрузки"}</AlertTitle>
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}{!rangeError && !paramsError && <Button type="button" variant="outline" size="sm" onClick={() => setRetry(n => n + 1)}>Повторить загрузку</Button>}{paramsError && <Button type="button" variant="outline" size="sm" onClick={() => updateParams(Object.fromEntries(STATISTICS_CONTEXT_KEYS.map(key => [key, undefined])))}>Сбросить параметры отчёта</Button>}</AlertDescription>
</Alert> </Alert>
) : null} ) : null}
<KpiStatGrid {!slices.serverId && dataReady && !emptyCube ? (
<Alert>
<AlertTitle>Уникальный объём</AlertTitle>
<AlertDescription>
Объём — трафик клиентов на GRE/WG, без повторного учёта JH↔EN и WAN.
</AlertDescription>
</Alert>
) : null}
{(dataReady || loading) && <KpiStatGrid
aria-label="Сводка трафика" aria-label="Сводка трафика"
isLoading={loading} isLoading={loading}
skeletonCount={5} skeletonCount={5}
@@ -402,7 +391,7 @@ export default function StatisticsPage() {
id: "bytes", id: "bytes",
label: "Объём", label: "Объём",
value: formatBytes(kpis.bytes), value: formatBytes(kpis.bytes),
hint: kpis.topCountry ? `топ: ${kpis.topCountry}` : undefined, hint: "GRE/WG клиентов, без hops",
icon: <DatabaseIcon />, icon: <DatabaseIcon />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
@@ -432,14 +421,18 @@ export default function StatisticsPage() {
id: "servers", id: "servers",
label: "Серверы", label: "Серверы",
value: String(kpis.servers), value: String(kpis.servers),
hint: kpis.ifaces ? `${kpis.ifaces} iface` : undefined, hint: slices.serverId
? (kpis.ifaces ? `${kpis.ifaces} iface` : undefined)
: planes === "all"
? "WAN и дубли в списке"
: "без WAN и overlay",
icon: <ServerIcon />, icon: <ServerIcon />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
]} ]}
/> />}
<StatisticsVolumeChart series={viewData.series} grain={viewData.grain} /> {dataReady && <StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />}
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
@@ -447,17 +440,25 @@ export default function StatisticsPage() {
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<SegmentedControl <SegmentedControl
value={view} value={view}
onChange={(next) => replaceParams({ view: next === "pivot" ? "pivot" : "explore" })} onChange={(next) => updateParams({ view: next === "pivot" ? "pivot" : "explore" })}
options={[ options={[
{ value: "explore", label: "Разрез" }, { value: "explore", label: "Разрез" },
{ value: "pivot", label: "Сводка" }, { value: "pivot", label: "Сводка" },
]} ]}
/> />
<SegmentedControl
value={planes}
onChange={(next) => updateParams({ planes: next === "all" ? "all" : undefined })}
options={[
{ value: "unique", label: "Уникальный" },
{ value: "all", label: "Все плоскости" },
]}
/>
{view === "explore" && !sliced ? ( {view === "explore" && !sliced ? (
<DimensionSelect <DimensionSelect
label="Критерий" label="Критерий"
value={dim} value={dim}
onChange={(next) => replaceParams({ dim: next })} onChange={(next) => updateParams({ dim: next, tab: undefined })}
/> />
) : null} ) : null}
{view === "pivot" ? ( {view === "pivot" ? (
@@ -466,22 +467,20 @@ export default function StatisticsPage() {
label="Строки" label="Строки"
value={pivotRow} value={pivotRow}
exclude={pivotCol} exclude={pivotCol}
onChange={(next) => replaceParams({ pivotRow: next })} onChange={(next) => updateParams({ pivotRow: next })}
/> />
<PivotDimSelect <PivotDimSelect
label="Колонки" label="Колонки"
value={pivotCol} value={pivotCol}
exclude={pivotRow} exclude={pivotRow}
onChange={(next) => replaceParams({ pivotCol: next })} onChange={(next) => updateParams({ pivotCol: next })}
/> />
</> </>
) : null} ) : null}
</div> </div>
} }
filters={filters} filterControl={<StatisticsFilters key={JSON.stringify(slices)} slices={slices} onApply={setSlices} />}
onFiltersChange={(next) => setSlices(filtersToSlices(next))} countLabel={dataReady ? countLabel : undefined}
filterFields={STATISTICS_FILTER_FIELDS}
countLabel={countLabel}
/> />
<SliceChips <SliceChips
chips={chips} chips={chips}
@@ -491,7 +490,9 @@ export default function StatisticsPage() {
setSlices(next) setSlices(next)
}} }}
/> />
{emptyCube ? ( {isLive && !dataReady ? (
<div role="status" className="p-6 text-sm text-muted-foreground">{loading ? "Загрузка статистики…" : rangeError || paramsError ? "Исправьте параметры отчёта для загрузки статистики." : "Статистика недоступна. Повторите загрузку."}</div>
) : emptyCube ? (
<EmptyState <EmptyState
title="Нет данных куба" title="Нет данных куба"
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год." description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
@@ -520,3 +521,11 @@ export default function StatisticsPage() {
</div> </div>
) )
} }
export default function StatisticsPage() {
return (
<Suspense fallback={null}>
<StatisticsPageInner />
</Suspense>
)
}
+111 -421
View File
@@ -1,27 +1,30 @@
"use client" "use client"
import { useState, useRef, useEffect, useCallback, useMemo } from "react" import { useState, useRef, useEffect, Suspense } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { servers as mockServers } from "@/lib/data" import { servers as mockServers } from "@/lib/data"
import type { ServerStatus } from "@/lib/data" import {useSearchParams} from "next/navigation"
import {can} from "@/lib/auth"
import {terminalServersSchema,readTerminalOutput,readTerminalUrl,patchTerminalUrl} from "@/lib/terminal-response"
import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert"
import {Input} from "@/components/ui/input"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { Badge } from "@/components/reui/badge"
import { import {
Frame, Frame,
FrameDescription,
FrameFooter, FrameFooter,
FrameHeader, FrameHeader,
FramePanel, FramePanel,
FrameTitle, FrameTitle,
} from "@/components/reui/frame" } from "@/components/reui/frame"
import { ScrollArea } from "@/components/ui/scroll-area"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { import {
type ServerTileItem, TrashIcon, RefreshCwIcon,
} from "@/components/server-tile-rail"
import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -171,231 +174,72 @@ function mockResolve(raw: string, serverName: string): string {
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)` return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
} }
// ─── MOTD builders ────────────────────────────────────────────────────────────
function mockMotd(server: TermServer): string { function Terminal({server,isLive,backendUrl,canExecute,blockedReason}:{server:TermServer;isLive:boolean;backendUrl:string;canExecute:boolean;blockedReason?:string}) {
return [ const [lines,setLines]=useState<TermLine[]>([{id:0,kind:"info",text:isLive?"REST: каждая команда — отдельный запрос. Результат появится после ответа API.":"Демо: команды возвращают синтетические данные."}])
"", const [input,setInput]=useState(""),[history,setHistory]=useState<string[]>([]),[histIdx,setHistIdx]=useState(-1),[executing,setExecuting]=useState(false),[error,setError]=useState<string|null>(null),[completed,setCompleted]=useState<string|null>(null)
" MMM MMM KKK TTTTTTTTTTT KKK", const nextId=useRef(1),lock=useRef(false),reader=useRef<{alive:boolean;controller:AbortController|null}>({alive:true,controller:null})
" MMM MMMM MMM III KKK KKK RRR OOOOOO TTT TTT II KKK KKK", const inputRef=useRef<HTMLInputElement>(null),scrollRef=useRef<HTMLDivElement>(null),followOutput=useRef(true),pendingFocus=useRef(false)
" MMM MMM III KKK KKK RRR RRROOOOO TTT TTT II KKK KKK", useEffect(()=>{const state=reader.current;state.alive=true;return()=>{state.alive=false;state.controller?.abort()}},[])
"", useEffect(()=>{if(followOutput.current)scrollRef.current?.scrollTo({top:scrollRef.current.scrollHeight,behavior:"auto"})},[lines])
` MikroTik RouterOS ${server.rosVersion ?? "7.14.3"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`, // Focus only after React has committed the re-enabled input.
"", useEffect(()=>{
"[?] Gives the list of available commands", if(executing||!pendingFocus.current)return
"[Tab] Completes the command/word", pendingFocus.current=false
"[/] Move up to base level", if(reader.current.alive&&!inputRef.current?.disabled)inputRef.current?.focus()
"[..] Move up one level", },[executing,lines])
"", function append(kind:TermLine["kind"],text:string) {
].join("\n") const added=text.split("\n").map(text=>({id:nextId.current++,kind,text}))
} setLines(previous=>{const merged=[...previous.filter(line=>line.id!==-1),...added];return merged.length>1000||previous.some(line=>line.id===-1)?[{id:-1,kind:"info",text:"Показаны последние 1000 строк; ранний вывод удалён."},...merged.slice(-1000)]:merged})
}
function liveMotd(server: TermServer): string { async function submit(){
return [ const command=input.trim();if(lock.current||!command||!canExecute||!server.enabled||blockedReason)return
"", if(command.toLowerCase()==="clear"||command.toLowerCase()==="/clear"){setLines([]);setInput("");return}
` MikroTik RouterOS ${server.rosVersion ?? "7.x"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`, lock.current=true;setExecuting(true);setError(null);setCompleted(null);setHistIdx(-1)
"", append("prompt",server.name+" > "+command)
` Connected to ${server.name} (${server.host})`, setHistory(previous=>[command,...previous.filter(item=>item!==command)].slice(0,100))
"", const controller=new AbortController();reader.current.controller=controller
"[?] Gives the list of available commands — type help or ?", let timedOut=false;const timer=window.setTimeout(()=>{timedOut=true;controller.abort()},60000)
"[Tab] Completes the command/word", try {
"[/] Move up to base level", const output=isLive?readTerminalOutput(await requestJson<unknown>(backendUrl,"/api/servers/"+server.backendId+"/exec",{method:"POST",body:JSON.stringify({command}),signal:controller.signal})):mockResolve(command,server.name)
"", if(!reader.current.alive)return
].join("\n") if(output==="__CLEAR__")setLines([]);else append("output",output||"Пустой вывод.")
} setInput("");setCompleted((isLive?"Ответ API получен":"Результат демо")+" · "+new Date().toLocaleTimeString("ru-RU"));followOutput.current=true
} catch(err) {
// ─── terminal component ─────────────────────────────────────────────────────── if(!reader.current.alive)return
const message=timedOut?"Время ожидания истекло. Результат запроса не подтверждён.":err instanceof Error?err.message:"Не удалось получить вывод команды."
function Terminal({ append("error",message);setError(message)
server, } finally {
isLive, window.clearTimeout(timer);lock.current=false
backendUrl, if(reader.current.alive){pendingFocus.current=true;setExecuting(false);reader.current.controller=null}
}: {
server: TermServer
isLive: boolean
backendUrl: string
}) {
const [lines, setLines] = useState<TermLine[]>([])
const [input, setInput] = useState("")
const [history, setHistory] = useState<string[]>([])
const [_histIdx, setHistIdx] = useState(-1)
const [_idCtr, setIdCtr] = useState(0)
const [executing, setExecuting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const nextId = useCallback((): number => {
let id = 0
setIdCtr(prev => { id = prev + 1; return prev + 1 })
return id
}, [])
// Initialize MOTD on mount / server change
useEffect(() => {
const motd = isLive ? liveMotd(server) : mockMotd(server)
const init = motd.split("\n").map((text, i) => ({ id: i, kind: "output" as const, text }))
setLines(init)
setIdCtr(init.length)
setInput("")
setHistory([])
setHistIdx(-1)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [server.uid, isLive])
// Auto-scroll
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" })
}, [lines])
const prompt = `[admin@${server.identityName ?? server.name}] > `
const submit = useCallback(async () => {
if (executing) return
const cmd = input.trim()
setLines(prev => [...prev, { id: nextId(), kind: "prompt", text: prompt + input }])
setInput("")
setHistIdx(-1)
if (!cmd) return
setHistory(h => [cmd, ...h.filter(x => x !== cmd)].slice(0, 100))
// local always-available commands
const lower = cmd.toLowerCase()
if (lower === "clear" || lower === "/clear") { setLines([]); return }
if (lower === "quit" || lower === "exit") {
setLines(prev => [...prev, { id: nextId(), kind: "info", text: "Connection closed." }])
return
}
if (isLive && server.backendId !== null) {
setExecuting(true)
try {
const data = await requestJson<{ output?: string; error?: string }>(
backendUrl,
`/api/servers/${server.backendId}/exec`,
{
method: "POST",
body: JSON.stringify({ command: cmd }),
},
)
const text = data.output ?? data.error ?? "(empty response)"
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
text.split("\n").forEach(line =>
setLines(prev => [...prev, { id: nextId(), kind, text: line }])
)
} catch (err) {
setLines(prev => [...prev, { id: nextId(), kind: "error", text: `network error: ${String(err)}` }])
} finally {
setExecuting(false)
}
} else {
// mock mode
const result = mockResolve(cmd, server.name)
if (result === "__CLEAR__") { setLines([]); return }
if (result) {
result.split("\n").forEach(line =>
setLines(prev => [...prev, { id: nextId(), kind: "output", text: line }])
)
}
}
}, [executing, input, nextId, prompt, isLive, server, backendUrl])
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault(); void submit()
} else if (e.key === "ArrowUp") {
e.preventDefault()
setHistIdx(i => {
const next = Math.min(i + 1, history.length - 1)
if (history[next] !== undefined) setInput(history[next])
return next
})
} else if (e.key === "ArrowDown") {
e.preventDefault()
setHistIdx(i => {
const next = i - 1
if (next < 0) { setInput(""); return -1 }
if (history[next] !== undefined) setInput(history[next])
return next
})
} else if (e.key === "l" && e.ctrlKey) {
e.preventDefault(); setLines([])
}
} }
}
const lineColor = (kind: TermLine["kind"]) => { function handleKey(e:React.KeyboardEvent<HTMLInputElement>){
if (kind === "error") return "text-red-400" if(e.key==="Enter"){e.preventDefault();void submit()}
if (kind === "prompt") return "text-emerald-400" else if(e.key==="ArrowUp"){e.preventDefault();const index=Math.min(histIdx+1,history.length-1);setHistIdx(index);if(history[index]!==undefined)setInput(history[index])}
if (kind === "info") return "text-sky-400" else if(e.key==="ArrowDown"){e.preventDefault();const index=histIdx-1;setHistIdx(Math.max(-1,index));setInput(index<0?"":history[index]??"")}
return "text-[#c9d1d9]" else if(e.key.toLowerCase()==="l"&&e.ctrlKey){e.preventDefault();setLines([])}
} }
const blocked=blockedReason??(!server.enabled?"Устройство отключено в каталоге.":!canExecute?"Для выполнения требуется mm:servers:write.":null)
return ( return <Frame spacing="sm" className="h-full min-h-0 min-w-0">
<div <FramePanel className="flex flex-1 flex-col min-h-0 min-w-0 p-0">
className="flex flex-col bg-[#0d1117] rounded-lg border border-[#30363d] overflow-hidden font-mono text-xs h-full" <FrameHeader className="border-b shrink-0 flex-row items-start justify-between gap-2">
onClick={() => inputRef.current?.focus()} <div className="min-w-0"><FrameTitle className="[overflow-wrap:anywhere]">{server.name}</FrameTitle><FrameDescription className="[overflow-wrap:anywhere]">{server.host} · {isLive?"RouterOS REST":"Демо"} · версия {server.rosVersion??"неизвестна"}</FrameDescription></div>
> <Button type="button" variant="ghost" size="icon-sm" aria-label="Очистить вывод терминала" onClick={()=>setLines([])}><TrashIcon aria-hidden="true" className="size-4"/></Button>
{/* title bar */} </FrameHeader>
<div className="flex items-center gap-2 px-3 py-2 border-b border-[#30363d] bg-[#161b22] shrink-0"> <div ref={scrollRef} role="region" aria-label="Вывод терминала" tabIndex={0} onScroll={e=>{const el=e.currentTarget;followOutput.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}} className="flex-1 min-h-0 min-w-0 overflow-auto p-3 font-mono text-xs focus-visible:outline-2 focus-visible:outline-ring">
<CircleIcon className="size-3 text-red-500 fill-red-500" /> {lines.length?lines.map(line=><div key={line.id} className={cn("whitespace-pre-wrap [overflow-wrap:anywhere] leading-5",line.kind==="error"?"text-destructive":line.kind==="prompt"?"text-primary":line.kind==="info"?"text-muted-foreground":"text-foreground")}>{line.text}</div>):<p className="text-muted-foreground">Вывод очищен. Следующая команда добавит новый результат.</p>}
<CircleIcon className="size-3 text-amber-400 fill-amber-400" /> </div>
<CircleIcon className="size-3 text-emerald-500 fill-emerald-500" /> <FrameFooter className="shrink-0 border-t flex flex-col items-stretch gap-2">
<span className="mx-auto text-[#8b949e] text-[11px]"> <p role="status" className="text-xs text-muted-foreground">{executing?"Выполняем команду; ожидаем ответ…":completed??"Команда ещё не выполнена."}</p>
{server.name} — {isLive ? "RouterOS REST" : "SSH Terminal (mock)"} {error&&<p role="alert" className="text-xs text-destructive [overflow-wrap:anywhere]">{error} Команда сохранена для повторного выполнения.</p>}
</span> {blocked&&<p role="status" className="text-xs text-muted-foreground">{blocked}</p>}
{isLive && ( <label className="text-xs" htmlFor="terminal-command">Команда · {server.name}</label>
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium border border-emerald-500/30 bg-emerald-500/10 text-emerald-400"> <div className="flex gap-2 min-w-0"><Input ref={inputRef} id="terminal-command" value={input} maxLength={1024} onChange={e=>setInput(e.target.value)} onKeyDown={handleKey} disabled={executing||!!blocked} className="flex-1 min-w-0 font-mono text-xs" autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false}/><Button type="button" size="sm" disabled={executing||!!blocked||!input.trim()} onClick={()=>void submit()}>{executing?"Ожидание…":"Выполнить"}</Button></div>
<span className="size-1 rounded-full bg-emerald-400" />LIVE <div className="flex flex-col sm:flex-row gap-2 text-xs text-muted-foreground"><select aria-label="Быстрая команда" value="" disabled={executing||!!blocked} className="min-w-0 rounded-md border bg-background px-2 py-1" onChange={e=>{setInput(e.target.value);setHistIdx(-1);inputRef.current?.focus()}}><option value="">Подставить команду…</option>{QUICK_CMDS.map(item=><option key={item.cmd} value={item.cmd}>{item.label}</option>)}</select><p>↑↓ — история · Ctrl+L — очистить · до 1024 символов</p></div>
</span> </FrameFooter>
)} </FramePanel>
<button </Frame>
onClick={e => { e.stopPropagation(); setLines([]) }}
className="text-[#8b949e] hover:text-white transition-colors ml-1"
title="Очистить"
>
<TrashIcon className="size-3.5" />
</button>
</div>
{/* output */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 space-y-0.5 min-h-0">
{lines.map((line, i) => (
<div key={`${line.id}-${i}`} className={cn("whitespace-pre-wrap break-all leading-5", lineColor(line.kind))}>
{line.text}
</div>
))}
{/* executing indicator */}
{executing && (
<div className="flex items-center gap-2 text-amber-400 opacity-80">
<Loader2Icon className="size-3 animate-spin" />
<span>executing…</span>
</div>
)}
{/* input line */}
{!executing && (
<div className="flex items-center gap-0">
<span className="text-emerald-400 select-none shrink-0">{prompt}</span>
<input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKey}
className="terminal-input-active flex-1 bg-transparent outline-none text-[#c9d1d9] caret-emerald-400"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
</div>
)}
</div>
</div>
)
} }
// ─── quick commands ─────────────────────────────────────────────────────────── // ─── quick commands ───────────────────────────────────────────────────────────
@@ -423,201 +267,47 @@ function mockServersToTermServers(): TermServer[] {
name: s.name, name: s.name,
host: s.host, host: s.host,
country: s.country, country: s.country,
status: s.status as TermServer["status"], status: s.status==="unknown"?null:s.status,
enabled: s.enabled, enabled: s.enabled,
rosVersion: s.os ?? null, rosVersion: s.os ?? null,
identityName: s.name, identityName: s.name,
})) }))
} }
/** Backend ServerRead shape (only fields we need) */
interface BackendServer { export default function TerminalPage(){
id: number return <Suspense fallback={<p role="status" className="p-4">Загружаем терминал…</p>}><TerminalRoute/></Suspense>
name: string
host: string
country: string
status: "online" | "offline" | null
enabled: boolean
os: string | null
identityName: string | null
} }
function TerminalRoute(){
export default function TerminalPage() { const {mode,backendUrl,prefsHydrated}=useDataSource(),params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
const { mode, backendUrl, prefsHydrated } = useDataSource() function reset(){const href=patchTerminalUrl(new URLSearchParams(params.toString()),null)+window.location.hash;window.history.replaceState(null,"",href)}
const isLive = mode === "live" if(url.error)return <Alert variant="destructive"><AlertTitle>Некорректная ссылка терминала</AlertTitle><AlertDescription>{url.error}<Button type="button" variant="outline" onClick={reset}>Сбросить контекст терминала</Button></AlertDescription></Alert>
if(!prefsHydrated)return <p role="status" className="p-4">Загружаем источник…</p>
// Server list state return <TerminalContent key={JSON.stringify([mode,backendUrl])}/>
const [liveServers, setLiveServers] = useState<TermServer[]>([]) }
const [serversLoading, setServersLoading] = useState(false) function TerminalContent(){
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect const {mode,backendUrl}=useDataSource(),isLive=mode==="live",params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
const [liveServers,setLiveServers]=useState<TermServer[]>([]),[loading,setLoading]=useState(false),[error,setError]=useState<string|null>(null),[loaded,setLoaded]=useState(false),[readAt,setReadAt]=useState<string|null>(null),[refresh,setRefresh]=useState(0)
// Load servers from backend when in live mode const canRead=can("mm:servers:read"),canExecute=!isLive||can("mm:servers:write")
useEffect(() => { useEffect(()=>{
if (!isLive || !prefsHydrated) return if(!isLive||!canRead)return
let cancelled = false let cancelled=false;const controller=new AbortController()
queueMicrotask(() => { queueMicrotask(()=>{if(cancelled)return;setLoading(true);void requestJson<unknown>(backendUrl,"/api/servers",{signal:controller.signal}).then(raw=>{
if (cancelled) return const rows=terminalServersSchema.parse(raw);if(cancelled)return
setServersLoading(true) setLiveServers(rows.map(row=>({uid:String(row.id),backendId:row.id,name:row.name||row.host,host:row.host,country:row.country,status:row.status,enabled:row.enabled,rosVersion:row.os??null,identityName:row.identityName??null})));setLoaded(true);setReadAt(new Date().toLocaleTimeString("ru-RU"));setError(null)
void requestJson<BackendServer[]>(backendUrl, "/api/servers") }).catch(err=>{if(!cancelled)setError(err instanceof Error?(err.name==="ZodError"?"Некорректный каталог устройств.":err.message):"Не удалось загрузить устройства.")}).finally(()=>{if(!cancelled)setLoading(false)})})
.then((data) => { return()=>{cancelled=true;controller.abort()}
if (cancelled) return },[isLive,backendUrl,canRead,refresh])
setLiveServers(data.map(s => ({ const termServers=isLive?liveServers:mockServersToTermServers()
uid: String(s.id), const matches=url.server?termServers.filter(server=>server.uid===url.server):url.host?termServers.filter(server=>server.host===url.host):[]
backendId: s.id, const selected=matches.length===1?matches[0]:null
name: s.name || s.host, const unavailable=(loaded||!isLive)&&(url.server||url.host)&&!selected
host: s.host, const items=termServers.map(server=>({id:server.uid,name:server.name,host:server.host,country:server.country||undefined,status:server.status??"unknown" as const,enabled:server.enabled,selectable:true,title:[server.name,server.host].join(" · ")}))
country: s.country || "", function select(id:string){const href=patchTerminalUrl(new URLSearchParams(params.toString()),id||null)+window.location.hash;window.history.pushState(null,"",href)}
status: s.status, const blocked=!!error||loading||isLive&&!loaded
enabled: s.enabled, return <ServerRailLayout items={items} selectedId={selected?.uid??url.server??""} onSelect={select} preserveSelection showAll={false} showCount={false} showType={false} loading={loading} contentClassName="min-h-0 min-w-0 overflow-hidden p-2 md:p-3"
rosVersion: s.os, header={<PageHeader crumbs={[{label:"Инструменты"},{label:"Терминал"}]} actions={<><ServerRailMobileButton/>{isLive&&<Button type="button" variant="outline" size="sm" disabled={loading||!canRead} onClick={()=>setRefresh(value=>value+1)}><RefreshCwIcon aria-hidden="true" className="size-4"/>Обновить устройства</Button>}</>}/>}
identityName: s.identityName, banner={<div className="px-3 py-2 space-y-2"><p role="status" className="text-xs text-muted-foreground">{isLive?(readAt?"Каталог · ответ "+readAt+" (время браузера)":"Каталог ещё не подтверждён."):"Демо: команды не отправляются на устройство."} REST выполняет только поддерживаемые диагностические команды.</p>{!canRead&&isLive&&<Alert variant="destructive"><AlertTitle>Нет доступа к каталогу</AlertTitle><AlertDescription>Требуется mm:servers:read.</AlertDescription></Alert>}{error&&<Alert variant="destructive"><AlertTitle>{loaded?"Каталог не обновлён":"Устройства пока неизвестны"}</AlertTitle><AlertDescription>{error} {loaded?"Сохранён предыдущий каталог; выполнение приостановлено до успешного обновления.":"Повторите загрузку устройств."}</AlertDescription></Alert>}{unavailable&&<Alert variant="destructive"><AlertTitle>Устройство терминала недоступно</AlertTitle><AlertDescription>{matches.length>1?"Host неоднозначен: выберите точное устройство.":"Указанный server/host отсутствует в текущем каталоге."}<Button type="button" variant="outline" onClick={()=>select("")}>Сбросить выбор устройства</Button></AlertDescription></Alert>}</div>}>
}))) {selected?<Terminal key={JSON.stringify([selected.uid,selected.host])} server={selected} isLive={isLive} backendUrl={backendUrl} canExecute={canExecute} blockedReason={blocked?"Каталог не подтверждён; выполнение приостановлено до успешного обновления.":undefined}/>:<Frame spacing="sm" className="h-full min-h-0"><FramePanel className="h-full flex flex-col gap-2 items-center justify-center p-4 text-center"><FrameTitle>{loading?"Загружаем устройства…":loaded&&!termServers.length?"Устройств пока нет":"Выберите устройство для терминала"}</FrameTitle><FrameDescription>Выбор сохраняется в URL. Команда отправляется только после нажатия «Выполнить».</FrameDescription></FramePanel></Frame>}
setServersLoading(false) </ServerRailLayout>
})
.catch(() => { if (!cancelled) setServersLoading(false) })
})
return () => { cancelled = true }
}, [isLive, backendUrl, refreshKey, prefsHydrated])
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
// Select first available server by default
const [selectedUid, setSelectedUid] = useState<string>("")
const defaultUid = useMemo(() => {
const first = termServers.find(s => s.status !== "offline" && s.enabled)
?? termServers[0]
return first?.uid ?? ""
}, [termServers])
useEffect(() => {
queueMicrotask(() => {
if (!selectedUid && defaultUid) setSelectedUid(defaultUid)
})
}, [defaultUid, selectedUid])
// Reset selection when switching modes
useEffect(() => {
queueMicrotask(() => setSelectedUid(""))
}, [isLive])
const selected = termServers.find(s => s.uid === selectedUid) ?? termServers[0]
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
const railItems = useMemo<ServerTileItem[]>(() => {
return termServers.map((s) => ({
id: s.uid,
name: s.name,
host: s.host,
country: s.country || undefined,
status: (s.status ?? undefined) as ServerStatus | undefined,
enabled: s.enabled,
selectable: s.enabled && s.status !== "offline",
title: [s.name, s.host].filter(Boolean).join(" · "),
}))
}, [termServers])
const handleSelectServer = useCallback((id: string) => {
setSelectedUid(id)
setRefreshKey((k) => k + 1)
}, [])
const railHeaderRight = isLive && !serversLoading
? <Badge variant="success-light" size="xs">LIVE</Badge>
: undefined
function QuickCmds() {
return (
<Frame dense spacing="sm" className="min-h-0 shrink-0">
<FramePanel className="flex max-h-56 flex-col gap-0 p-0">
<FrameHeader className="border-b px-3 py-2">
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Быстрые команды
</FrameTitle>
</FrameHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 p-1.5">
{QUICK_CMDS.map(({ cmd, label }) => (
<button
key={cmd}
type="button"
className="truncate rounded-md px-2 py-1.5 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => injectCommand(cmd)}
title={cmd}
>
{label}
</button>
))}
</div>
</ScrollArea>
<FrameFooter className="border-t text-[10px] text-muted-foreground/70">
<p>↑↓ — история команд</p>
<p>Ctrl+L — очистить экран</p>
{isLive
? <p className="text-info">Команды выполняются на роутере</p>
: <p>Режим: mock-данные</p>}
</FrameFooter>
</FramePanel>
</Frame>
)
}
function injectCommand(cmd: string) {
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
if (!el) return
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set
nativeSetter?.call(el, cmd)
el.dispatchEvent(new Event("input", { bubbles: true }))
el.focus()
}
return (
<ServerRailLayout
items={railItems}
selectedId={selected?.uid ?? selectedUid}
onSelect={handleSelectServer}
showAll={false}
showCount={false}
showType={false}
headerRight={railHeaderRight}
loading={serversLoading}
extra={<QuickCmds />}
contentClassName="overflow-hidden p-3 md:p-4"
header={
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => {
setRefreshKey((k) => k + 1)
if (isLive) setSelectedUid("")
}}
>
<RefreshCwIcon className="size-4" />
Переподключить
</Button>
</>
}
/>
}
>
{selected ? (
<Terminal
key={termKey}
server={selected}
isLive={isLive}
backendUrl={backendUrl}
/>
) : (
<div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
</div>
)}
</ServerRailLayout>
)
} }
File diff suppressed because it is too large. Load diff
+596 -502
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
.container { container-type: inline-size; }
.header { display: none; }
.serverName { flex: 1 1 160px; }
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: "name name" "status toggle" "iface filter" "rtt loss" "bt graph" "star actions";
}
.row > * { min-width: 0; overflow-wrap: anywhere; }
.name { grid-area: name; }
.status { grid-area: status; display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; }
.toggle { grid-area: toggle; display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; justify-content: flex-end; }
.iface { grid-area: iface; }
.filter { grid-area: filter; }
.rtt { grid-area: rtt; }
.loss { grid-area: loss; }
.bt { grid-area: bt; }
.graph { grid-area: graph; }
.star { grid-area: star; display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; }
.actions { grid-area: actions; }
.label { display: block; font-size: 0.625rem; color: var(--muted-foreground); }
.actions button, .star button { min-width: 2rem; min-height: 2rem; }
@container (min-width: 1100px) {
.header, .row {
display: grid;
grid-template-columns: 36px 16px 130px 120px 140px 70px 44px minmax(132px, 1fr) 96px 36px 72px;
grid-template-areas: "toggle status iface name filter rtt loss bt graph star actions";
}
.label, .mobile { display: none; }
.toggle, .star { justify-content: center; }
.actions button, .star button { min-width: unset; min-height: unset; }
}
Loaded 100 of 3721 files, more files were not shown because too many files have changed in this diff. Show more