fix(ui): выровнять chrome Frame KPI и доступность
Docker / build (push) Failing after 22s

Унифицировать оболочку ops-экранов, IconTile, skip-link и контракт UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-08-17 15:36:53 +07:00
co-authored by Cursor
parent edf1da165a
commit 55014105f5
23 changed files with 1728 additions and 399 deletions
@@ -0,0 +1,200 @@
---
name: UX UI Unification Audit
overview: "Discovery завершён. Уровень унификации (решение пользователя): идентичный chrome во всех 6 приложениях + общая UX-система; доменные страницы остаются разными. Deliverable после approval — только vps-tracker/UX-UI-PLAN.md, без правок production-кода."
todos:
- id: write-plan-file
content: Создать c:\Users\shats\Dev\vps-tracker\UX-UI-PLAN.md со всеми секциями правила аудита (без правок production-кода)
status: in_progress
- id: registry-changes
content: "Заполнить REUI Reference Registry + Final Change Matrix: каждый UI CHANGE с Ref ID и официальным URL"
status: in_progress
- id: consistency-gate
content: "Пройти Final Consistency Gate: 6/6 coverage, no fabricated URLs, functionality preserved"
status: pending
- id: chrome-spec
content: В UX-UI-PLAN.md зафиксировать Chrome Identity Spec для всех 6 приложений (header/sidebar/theme/App Switcher) как обязательный 1:1
status: in_progress
isProject: false
---
# UX/UI Unification — Audit Complete, Plan File Next
## Scope of this phase
После подтверждения плана будет создан **только** [`c:\Users\shats\Dev\vps-tracker\UX-UI-PLAN.md`](c:/Users/shats/Dev/vps-tracker/UX-UI-PLAN.md) (выбрано пользователем). Production/source code, зависимости, CSS и компоненты **не изменяются**. Implementation UI — отдельный запрос.
Источники истины для references: [reui.io/docs](https://reui.io/docs), [reui.io/llms.txt](https://reui.io/llms.txt), MCP `user-reui` (`get_block` / `list_components` / `compose_page` / `get_component`). Skill ReUI локально и в MCP: `668fb463eb` (совпадает). Версию ReUI PRO **не** указываем — её нет.
## Coverage gate
`Discovered: 6` · `Audited: 6` · `Planned: 6` · `Coverage: 100%`
| # | Project | Path | Type | UI |
|---|---|---|---|---|
| 1 | VPS Tracker | `c:\Users\shats\Dev\vps-tracker` | fullstack Vite SPA | Frame + kit, mixed Card on charts/detail |
| 2 | Cloudflare Domain Manager | `c:\Users\shats\Dev\cloudflare-domain-manager` | fullstack Vite SPA | Frame-first, closest to contract |
| 3 | EvoBGP | `c:\Users\shats\Dev\EvoBGP` | fullstack (Go API + Vite SPA) | SoT hybrid KPI; leftover Card on stub/empty |
| 4 | Auth Portal | `c:\Users\shats\Dev\auth-portal` | fullstack Vite SPA | Frame admin; thin kit; no SystemMonitor |
| 5 | EvoFirewall | `c:\Users\shats\Dev\EvoFirewall` | fullstack Vite SPA | Frame + kit; SettingsShell unused |
| 6 | MikrotikManager | `c:\Users\shats\Dev\MikrotikManager-3` | fullstack Next.js 16 | **Card SoT `/servers`**; ReUI partial (data-grid/filters/stepper), **нет** `reui-kit` |
Backend-only проектов нет. Все шесть — frontend/fullstack UI.
## What the final file will contain
Полная структура из правила аудита (§37): Executive Summary, Coverage, Inventory, MCP/Skills, Rules, 6 project audits, Cross-project matrix, Duplicates, REUI Registry, Target rules, CHANGE-XXX, Roadmap, Acceptance, Risks, Consistency Gate.
Ниже — ядро находок, которые попадут в файл с evidence и Ref ID.
## Что будет одинаковым, а что нет (решение пользователя)
Уровень унификации: **система + идентичный chrome во всех 6 приложениях**. Доменные экраны **не** копируются.
### Будет одинаковым (chrome identity — все 6, включая Auth Portal и MikrotikManager)
Эталон: [app-shell-12](https://reui.io/preview/base/app-shell-12) + [app-shell-7](https://reui.io/preview/base/app-shell-7) (SystemMonitor) + [app-shell-1](https://reui.io/preview/base/app-shell-1) (NavUser).
- Sidebar `--sidebar-width: 240px`
- Sidebar header: App Switcher (ids `cfdm` · `vps` · `bgp` · `fw` · `dns`, источник — auth-portal API)
- Sidebar footer: **NavUser** + segmented theme (light / dark / system), без Sun/Moon icon toggle
- Header right: **AppsMenu → SystemMonitorPopover**; без ModeToggle и без Search-кнопки (поиск только ⌘K)
- Main padding: `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5`
- Тема: next-themes (или эквивалент с тем же UX) + ReUI semantic tokens (`--success/--warning/--info/--invert`)
- SSO/portal: вход через auth-portal, тот же App Switcher
Сейчас этого нет: Auth Portal (нет SystemMonitor), MikrotikManager (нет AppsMenu/NavUser/switcher, icon theme toggle).
### Будет одним паттерном (не pixel-copy страниц)
- Surface ops-экрана: Frame, не Card
- Списки: DataGrid + Filters
- KPI: stats-12 + IconTile `elevated`
- Quick Actions: Frame tiles (card-12 DNA)
- Settings rows: settings-3 / integrations: settings-16
- Create/edit: Sheet (sheet-8 / sheet-1 / sheet-9)
- Empty: Empty + IconStack, Frame-friendly
Одинаковый **рецепт**, разные данные, колонки, фильтры, действия.
### Останется разным (домен)
- VPS: topology canvas, BILLmanager sync, тарифы, баланс
- CFDM: DNS, kanban групп/сервисов, сертификаты Cloudflare
- EvoBGP: modules/lookup/BIRD jobs/network
- Auth Portal: login, users/OIDC/audit (это IdP, не ops CRUD)
- EvoFirewall: agent fleet cards, policy sortable, blocklists
- MikrotikManager: `/servers` CRUD, terminal, network-map, контейнеры — **функциональность** та же, chrome и surface выравниваются
- Framework: Vite SPA vs Next.js 16 — **не** меняем ради UI
- Package scopes (`@cfdm/ui`, `@evobgp/ui`, …) — не сливать в один npm без отдельного technical решения
## Critical cross-project findings
```mermaid
flowchart LR
subgraph viteFamily [Vite plus Frame kit]
CFDM[CFDM]
VPS[VPS Tracker]
BGP[EvoBGP SoT KPI]
FW[EvoFirewall]
Auth[Auth Portal]
end
subgraph nextFamily [Next Card SoT]
MM[MikrotikManager]
end
ReUI[REUI PRO Frame]
viteFamily --> ReUI
MM -.->|"proposed RULE-CHANGE"| ReUI
```
1. **Две дизайн-системы в одном workspace.** Пять Vite-приложений уже lock `surface: frame` + `reui-kit`. MikrotikManager — Next App Router, SoT [`/servers`](c:/Users/shats/Dev/MikrotikManager-3/app/(main)/servers/page.tsx) на `DataPageCard` → shadcn Card. Правила `next-shadcn-production.mdc` / `ma-ui-guardian.mdc` **противоречат** цели унификации. UX/UI rule разрешено менять: target = Frame + DataGrid, **без** смены Next.js (technical constraint).
2. **KPI DNA устарела относительно официального primitive.** Сейчас kit использует hand-roll `Item` `size-10.5` `bg-muted` и ссылается на [stats-12](https://reui.io/preview/base/stats-12) (MCP: Frame + Badge + Item). Официальный primitive [Icon Tile](https://reui.io/docs/components/base/icon-tile) (`variant="elevated"`, preview [icon-tile](https://reui.io/components/icon-tile)) закрывает ту же задачу. **Ни один** из 6 проектов не установил `icon-tile`. Match текущий KPI → stats-12: **partial**. Target: IconTile elevated + stats-12 composition.
3. **settings-7 — Card surface.** EvoBGP kit/reference [`settings-7`](https://reui.io/preview/base/settings-7) официально `surface: card`. Это нарушает Frame lock. Frame-эталоны: [settings-3](https://reui.io/preview/base/settings-3), [settings-8](https://reui.io/preview/base/settings-8), [settings-16](https://reui.io/preview/base/settings-16).
4. **Chrome drift.** Контракты цитируют [app-shell-12](https://reui.io/preview/base/app-shell-12). MCP `compose_page` для ops dashboard вернул **app-shell-7** ([preview](https://reui.io/preview/base/app-shell-7)) — в описании есть system monitor popover, ближе к реальному `SystemMonitorPopover`. Auth Portal: AppsMenu без SystemMonitor. MikrotikManager: нет AppsMenu / NavUser / auth-portal switcher; theme = icon toggle в sidebar.
5. **Дубли kit.** `KpiStatGrid` / `QuickActionGrid` / `ResourcePage` / `OpsDashboard` скопированы в 5 репо (`@cfdm/ui` vs `@evobgp/ui` vs `@evofw/ui` vs `@authportal/ui`). Нет shared package. Unification UX — через одинаковые rules + DNA; общий npm-пакет = отдельный technical risk (не ломаем package boundaries в этом плане).
6. **Мёртвый / недоиспользованный kit.** SettingsShell и DetailPanel экспортируются, но не используются в EvoBGP routes и частично в EvoFirewall. Auth Portal `ResourcePage` — stub. CFDM ближе всех к контракту (Card не импортируется в `apps/web`).
7. **Rules drift.** Правила перечисляют ReUI-компоненты, которых нет на диске (kanban в vps-tracker, stepper unused в FW, Frame unused в MM). `kpi-hybrid.mdc` дублируется 5 раз. CFDM нет `AGENTS.md` / `ui-surface.ts`. Auth contract ids без `dns`, код включает `dns`.
## Verified REUI PRO registry (will expand in file)
Каждый CHANGE получит Ref ID. Уже подтверждено MCP `get_block` / `list_components` / official docs:
| Ref | Item | Official docs | Preview | Typical match |
|---|---|---|---|---|
| REUI-001 | Frame | [docs/components/base/frame](https://reui.io/docs/components/base/frame) | [components/frame](https://reui.io/components/frame) | 1:1 for ops surface |
| REUI-002 | Data Grid | [docs/components/base/data-grid](https://reui.io/docs/components/base/data-grid) | [components/data-grid](https://reui.io/components/data-grid) | 1:1 lists; MCP: TanStack v9 |
| REUI-003 | Filters | [docs/components/base/filters](https://reui.io/docs/components/base/filters) | [components/filters](https://reui.io/components/filters) | 1:1 |
| REUI-004 | stats-12 | [blocks](https://reui.io/blocks) | [preview/base/stats-12](https://reui.io/preview/base/stats-12) | partial vs current Item hybrid |
| REUI-005 | Icon Tile | [docs/components/base/icon-tile](https://reui.io/docs/components/base/icon-tile) | [components/icon-tile](https://reui.io/components/icon-tile) | target for KPI/row tiles |
| REUI-006 | data-grid-filtering-2 | [blocks](https://reui.io/blocks) | [preview/base/data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) | partial (kit ResourcePage) |
| REUI-007 | settings-16 | [blocks/application/settings](https://reui.io/blocks/application/settings) | [preview/base/settings-16](https://reui.io/preview/base/settings-16) | integrations |
| REUI-008 | settings-3 | same category | [preview/base/settings-3](https://reui.io/preview/base/settings-3) | settings rows Frame |
| REUI-009 | settings-8 | same | [preview/base/settings-8](https://reui.io/preview/base/settings-8) | webhook-like lists |
| REUI-010 | app-shell-12 | [blocks](https://reui.io/blocks) | [preview/base/app-shell-12](https://reui.io/preview/base/app-shell-12) | current chrome cite |
| REUI-011 | app-shell-7 | [blocks](https://reui.io/blocks) | [preview/base/app-shell-7](https://reui.io/preview/base/app-shell-7) | better SystemMonitor match |
| REUI-012 | app-shell-1 | [blocks](https://reui.io/blocks) | [preview/base/app-shell-1](https://reui.io/preview/base/app-shell-1) | NavUser |
| REUI-013 | card-12 | [blocks](https://reui.io/blocks) | [preview/base/card-12](https://reui.io/preview/base/card-12) | Quick Actions |
| REUI-014 | dashboard-1 | [blocks](https://reui.io/blocks) | [preview/base/dashboard-1](https://reui.io/preview/base/dashboard-1) | OpsDashboard |
| REUI-015 | empty-state-14 | [blocks](https://reui.io/blocks) | [preview/base/empty-state-14](https://reui.io/preview/base/empty-state-14) | Frame empty (prefer over card empty-state-11) |
| REUI-016 | empty-state-12 | [blocks](https://reui.io/blocks) | [preview/base/empty-state-12](https://reui.io/preview/base/empty-state-12) | current cite; surface none |
| REUI-017 | sheet-8 / sheet-1 / sheet-9 | [blocks](https://reui.io/blocks) | [sheet-8](https://reui.io/preview/base/sheet-8) [sheet-1](https://reui.io/preview/base/sheet-1) [sheet-9](https://reui.io/preview/base/sheet-9) | create/edit/detail sheets |
| REUI-018 | auth-13 | [blocks](https://reui.io/blocks) | [preview/base/auth-13](https://reui.io/preview/base/auth-13) | Frame login |
| REUI-019 | auth-18 | [blocks](https://reui.io/blocks) | [preview/base/auth-18](https://reui.io/preview/base/auth-18) | auth-portal actual; surface none |
| REUI-020 | solution-users-1 / 6 | [blocks](https://reui.io/blocks) | [users-1](https://reui.io/preview/base/solution-users-1) [users-6](https://reui.io/preview/base/solution-users-6) | auth admin |
| REUI-021 | form-7 | [blocks](https://reui.io/blocks) | [preview/base/form-7](https://reui.io/preview/base/form-7) | inline detail Frame |
| REUI-022 | chart-15 | [blocks](https://reui.io/blocks) | [preview/base/chart-15](https://reui.io/preview/base/chart-15) | Frame charts (replace Card chart shells) |
| REUI-023 | kanban / sortable / stepper / timeline / autocomplete | primitive docs under `/docs/components/base/<name>` | matching `/components/<name>` | domain-specific |
`settings-7` **не** будет target (Card). `empty-state-11` **не** будет target (Card).
## Target UX/UI rules (summary)
- **TARGET-001:** Один surface на ops-экран: ReUI Frame. Не смешивать Card+Frame.
- **TARGET-002:** Списки = DataGrid + Filters внутри Frame ([data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2)). Запрет raw `<table>` / shadcn Table как ops list.
- **TARGET-003:** KPI = stats-12 composition + IconTile `elevated` (не hand-roll Item tile). Semantic `text-*` / Badge variants, не `bg-emerald-*`.
- **TARGET-004:** Quick Actions = Frame tiles sibling KPI ([card-12](https://reui.io/preview/base/card-12) + stats-12 DNA).
- **TARGET-005:** Settings rows = settings-3; integrations = settings-16; не settings-7.
- **TARGET-006 (P0, все 6 приложений):** Chrome identity 1:1. Sidebar 240px + App Switcher; header AppsMenu → SystemMonitor; footer NavUser segmented theme. Эталон: [app-shell-12](https://reui.io/preview/base/app-shell-12) + [app-shell-7](https://reui.io/preview/base/app-shell-7) + [app-shell-1](https://reui.io/preview/base/app-shell-1). Auth Portal и MikrotikManager **не** исключение.
- **TARGET-007:** Create/edit = Sheet blocks (sheet-8 create/settings, sheet-1 create, sheet-9 detail). Confirm = Alert Dialog examples ([alert-dialog](https://reui.io/components/alert-dialog)).
- **TARGET-008:** Empty = Empty + IconStack, Frame-friendly empty-state-14 / empty-state-12; не Card empty-state-11.
- **TARGET-009:** MikrotikManager UX SoT сменить с Card `/servers` на Frame+DataGrid **при сохранении** всего CRUD `/servers` (technical: остаётся Next.js).
- **TARGET-010:** Rules синхронизировать с диском (не перечислять неустановленные компоненты как «установленные»).
## Change themes per project (detail in file)
**VPS Tracker (P1):** Card shells на `vps.$vpsId`, `reports`, `resources`, `renewals`, charts → Frame / chart-15. `spaces.tsx` shadcn Table → ResourcePage/DataGrid. SettingsShell Separator. Dashboard QA order — зафиксировать как exception или выровнять с EvoBGP.
**CFDM (P2):** Наименьший gap. Добавить `ui-surface.ts` / AGENTS.md. IconTile вместо Item tiles. Удалить naming `*KpiCards`.
**EvoBGP (P1):** `modules/new.tsx` Card stub → Frame. `illustrated-empty-state` Card → empty-state-14. Подключить SettingsShell/DetailPanel или удалить из barrel. Forms: RHF в package.json, в src не используется — выровнять на Field + sheet-8.
**Auth Portal (P0 chrome / P1 pages):** Добавить SystemMonitorPopover в header (сейчас только AppsMenu). NavUser уже segmented — оставить. `/apps` tiles → QuickActionGrid. ResourcePage stub. Contract + `dns` id. Login-страница **без** AppShell остаётся отдельным auth surface ([auth-18](https://reui.io/preview/base/auth-18) / [auth-13](https://reui.io/preview/base/auth-13)); chrome identity применяется только к `_auth` shell.
**EvoFirewall (P1/P2):** `/settings` → SettingsShell или явно REPLACE rule. Agent cards view оставить как Frame domain, не Card. IconTile для AgentPlatformIcon. Orphans: lifecycle timeline, FormFieldSimple, unused stepper.
**MikrotikManager (P0 chrome, P1 surface, high risk):** Сначала chrome identity: AppsMenu, SystemMonitor, NavUser segmented, App Switcher `dns`/`fw`/…, `--sidebar-width: 240px` (сейчас hand-rolled footer + Sun/Moon). Затем RULE-CHANGE SoT: `DataPageCard` Card → Frame. KPI → stats-12 + IconTile. Не мигрировать Next.js. Сохранить весь CRUD `/servers` и остальные страницы.
## Roadmap (in file, not executed now)
- Phase 0: этот план-файл (единственный deliverable сейчас)
- Phase 1: **Chrome identity во всех 6** (Auth SystemMonitor; MM AppsMenu/NavUser/Switcher; Vite — сверка 1:1)
- Phase 2: shared rules + IconTile + Frame lock (Vite + начало MM)
- Phase 3: kit DNA (KPI/QA/ResourcePage) per repo
- Phase 4: page-level Card→Frame (VPS charts/detail, BGP stub/empty, MM DataPageCard)
- Phase 5: states (empty/loading/error) + sheets
- Phase 6: a11y/responsive verification
## Constraints to preserve
Не трогать: API contracts, auth/SSO/OIDC, permissions, TanStack vs Next routing, Go vs Fastify, package names, CRUD поведения (create/edit/delete/sync/filter/sort/pagination).
Technical dependency (отдельная секция Risks): Data Grid docs говорят TanStack Table **v9**; установленный `@reui/data-grid` в проектах может быть v8 — не ломать kit без явной миграции (`<uncertain>` до проверки `package.json` каждого web-приложения в тексте плана).
## After approval
Написать полный `UX-UI-PLAN.md` (~все секции правила, CHANGE-XXX с Ref ID и прямыми URL). Остановиться. Implementation — только по отдельному запросу.
@@ -0,0 +1,67 @@
---
name: UX UI Unification Implementation
overview: "Реализация UX-UI-PLAN.md: идентичный chrome 1:1 во всех 6 приложениях, затем IconTile/rules, kit DNA, Card→Frame. Phase 0 (аудит) уже в UX-UI-PLAN.md. Production-код начинается с Phase 1."
todos:
- id: phase-1-auth
content: "CHANGE-001: Auth Portal SystemMonitorPopover + header AppsMenu→monitor (app-shell-7)"
status: completed
- id: phase-1-mm
content: "CHANGE-002/032/033/034: MM chrome — 240px, App Switcher, NavUser segmented, AppsMenu+SystemMonitor, без Rail/Search pill"
status: completed
- id: phase-1-vite
content: "CHANGE-003: сверка VPS/CFDM/EvoBGP/FW + Auth SIDEBAR_WIDTH 240px"
status: completed
- id: phase-2-icontile
content: "Phase 2: CLI @reui/icon-tile во всех 6 + RULE-CHANGE Frame SoT MM (CHANGE-004, 024028)"
status: completed
- id: phase-3-kit
content: "Phase 3: KpiStatGrid/QuickActionGrid на IconTile; Auth /apps kit (CHANGE-005008, 021022)"
status: completed
- id: phase-4-frame
content: "Phase 4: Card→Frame на ops-страницах VPS/EvoBGP/MM (CHANGE-009018)"
status: completed
- id: phase-5-6
content: "Phase 6: a11y/responsive verification (Phase 5 CHANGE-016/019/020/030/031 done)"
status: completed
isProject: false
---
# UX/UI Unification — Implementation
Источник: [`UX-UI-PLAN.md`](c:/Users/shats/Dev/vps-tracker/UX-UI-PLAN.md). Аудит-план [`ux_ui_unification_audit_276a34f8.plan.md`](c:/Users/shats/Dev/vps-tracker/.cursor/plans/ux_ui_unification_audit_276a34f8.plan.md) **не менять**.
Уровень: **идентичный chrome во всех 6** + общая UX-система; доменные страницы остаются разными.
Эталоны chrome: [app-shell-12](https://reui.io/preview/base/app-shell-12) · [app-shell-7](https://reui.io/preview/base/app-shell-7) · [app-shell-1](https://reui.io/preview/base/app-shell-1). Docs: [reui.io/blocks](https://reui.io/blocks) · [reui.io/docs](https://reui.io/docs).
## Chrome Identity Spec (1:1 — все 6)
- Sidebar `--sidebar-width: 240px`
- Sidebar header: App Switcher (`cfdm` · `vps` · `bgp` · `fw` · `dns`; MM локальный `mm` + те же ids)
- Footer: NavUser + segmented theme (light/dark/system), без Sun/Moon
- Header right: AppsMenu → SystemMonitorPopover; без ModeToggle; поиск только ⌘K
- Main: `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5`
- Запрещено: SidebarRail, Search pill в header/sidebar
- Auth **login** без AppShell ([auth-18](https://reui.io/preview/base/auth-18))
**Не менять:** Vite vs Next, package scopes, API/SSO/OIDC, CRUD. Не мержить kit в один npm. Не мигрировать TanStack Table v8→v9 в этом плане.
## Phase 1 — Chrome (сейчас)
| ID | Project | Что сделать |
|---|---|---|
| CHANGE-001 | Auth Portal | `SystemMonitorPopover` после AppsMenu; header `z-10` `gap-2` |
| CHANGE-002 | MM | Switcher, header cluster, убрать Search pill + Sun/Moon |
| CHANGE-032 | MM | NavUser в footer, segmented theme |
| CHANGE-033 | MM | удалить SidebarRail |
| CHANGE-034 | MM | `SIDEBAR_WIDTH = 240px` |
| CHANGE-003 | VPS/CFDM/BGP/FW + Auth primitive | сверка; Auth `packages/ui` sidebar 16rem → 240px |
MM остаётся Next.js 16. Без `@tanstack/react-query` для chrome (fetch + `useEffect`).
## Later phases (не в этом проходе)
- **Phase 2:** IconTile CLI, rules Frame SoT MM
- **Phase 3:** kit DNA KPI/QA
- **Phase 4:** Card→Frame pages
- **Phase 56:** empty/sheets, a11y
-1
View File
@@ -36,7 +36,6 @@ Monorepo — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). ReUI — [`reui-m
| `PageShell` | `page-shell.tsx` |
| `ResourcePage` | `reui-kit/resource-page.tsx` |
| `OpsDashboard` / `KpiStatGrid` / `QuickActionGrid` | `reui-kit/ops-dashboard.tsx`, `kpi-stat-grid.tsx`, `quick-action-grid.tsx` |
| `KanbanBoard` | `reui-kit/kanban-board.tsx` |
| `DetailPanel` | `reui-kit/detail-panel.tsx` |
| `SettingsShell` | `reui-kit/settings-shell.tsx` |
| `EmptyState` | `empty-state.tsx` |
+2 -16
View File
@@ -48,7 +48,6 @@ apps/web/src/components/ ← shared + domain + layout
page-shell.tsx
reui-kit/
resource-page.tsx ← list: Frame + line Tabs + Filters + DataGrid
kanban-board.tsx ← kanban + KanbanBoardSkeleton
detail-panel.tsx ← detail: Frame header/metrics
settings-shell.tsx
ops-dashboard.tsx ← KPI stats-12 + charts
@@ -70,8 +69,6 @@ apps/web/src/components/ ← shared + domain + layout
|---------|--------|-----------|
| Page wrapper | `PageShell` | — |
| List page | `ResourcePage` | ReUI `Frame` + `data-grid` + `filters` + shadcn `Tabs` `variant="line"` |
| Catalog / Board | `CatalogBoardToggle` + `ResourcePage` / `KanbanBoard` | `?view=board` на `/groups`, `/services` |
| Kanban | `KanbanBoard` / `KanbanBoardSkeleton` | ReUI `kanban` + `Frame` |
| Detail | `DetailPanel` | ReUI `Frame` |
| Settings | `SettingsShell` | — |
| Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [stats-12](https://reui.io/preview/base/stats-12) hybrid |
@@ -95,7 +92,7 @@ apps/web/src/components/ ← shared + domain + layout
Max 1 primary (`default`) на экран; остальные `outline` / `ghost`.
Toggle «Доска» / «К каталогу» — всегда `outline` в `primaryAction` / `KanbanBoard.toolbarActions` (не отдельный Frame-shell).
Max 1 primary (`default`) на экран; остальные `outline` / `ghost`.
## Line tabs (project standard)
@@ -116,17 +113,6 @@ Toggle «Доска» / «К каталогу» — всегда `outline` в `p
- Active state — Base UI `data-active`, не Radix `data-[state=active]`
- Не трогать internals `reui/date-selector`
## Catalog / Board (`/groups`, `/services`)
| Режим | UI | Search |
|-------|-----|--------|
| Catalog (default) | `ResourcePage` + primary create | omit / `view=catalog` |
| Board | `KanbanBoard` DnD | `?view=board` |
- Groups tabs: Все / С доменами / Пустые
- Services tabs: Все / Включены / Выключены / Без группы
- DnD только на board; kanban hooks/cards не удалять
## Dashboard KPI
Эталон: [stats-12](https://reui.io/preview/base/stats-12) — icon tile + value + label + optional badge/footer; клик через `to` (`Link`) / `onSelect`. Compact strip: [card-35](https://reui.io/preview/base/card-35).
@@ -178,7 +164,7 @@ Shared chrome (vps-tracker / CFDM / EvoBGP): см. [`docs/ui-design-contract.md`
Каждый блок: **default, hover, focus, disabled, empty, loading, error**.
- **Loading** — `Skeleton` / `ResourcePage` skeleton / `KanbanBoardSkeleton` / `OpsDashboard` skeleton, не Spinner на странице
- **Loading** — `Skeleton` / `ResourcePage` skeleton / `OpsDashboard` skeleton, не Spinner на странице
- **Empty** — `EmptyState` с CTA
- **Zero-results** — message внутри DataGrid (+ «Сбросить»)
- **Error** — `QueryState` / `Alert` + `onRetry`
+10 -9
View File
@@ -1,11 +1,11 @@
---
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
alwaysApply: true
---
# KPI hybrid — только kit (stats-12 DNA)
# KPI hybrid — только kit (stats-12 + IconTile)
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
@@ -14,16 +14,17 @@ Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Ma
| Зона | Компонент / DNA |
|------|-----------------|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
Импорты UI: `@cfdm/ui/components/*`.
Импорты UI: `@cfdm/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
## NEVER
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
- Другой size / radius / solid brand fill вместо `bg-muted`
- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile
- Другой size / radius / solid brand fill вместо `elevated`
- `card-35` как замена stats-12 hybrid KPI
- Копипаст ReUI block в route — adapt через `reui-kit/`
- Голый lucide `size-4` в name-cell без hybrid tile
- Голый lucide `size-4` в name-cell без IconTile
+2 -2
View File
@@ -76,9 +76,9 @@ alwaysApply: true
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
+945
View File
@@ -0,0 +1,945 @@
# UX/UI Unification Plan
Дата аудита: 2026-08-17
Implementation: **DONE 2026-08-17** — CHANGE-001…034, Phases 16, Acceptance §19.
Уровень унификации (решение пользователя): **система + идентичный chrome во всех 6 приложениях**; доменные экраны **не** копируются.
Источники истины REUI PRO: [reui.io/docs](https://reui.io/docs) · [reui.io/llms.txt](https://reui.io/llms.txt) · MCP `user-reui` (`get_block` / `get_component` / `get_example` / `compose_page`). Skill ReUI: `668fb463eb` (локально и MCP совпадают). Версию REUI PRO **не** указываем — её нет.
---
## 1. Executive Summary
Workspace — 6 fullstack UI-приложений. Пять Vite SPA уже lock `surface: frame` + `reui-kit`. MikrotikManager — Next.js 16 App Router со SoT `/servers` на shadcn `Card` (`DataPageCard`). Это **две дизайн-системы** в одном workspace.
Целевое состояние:
- **Одинаковый chrome 1:1** во всех шести (включая Auth Portal и MikrotikManager): sidebar 240px, App Switcher, header AppsMenu → SystemMonitor, footer NavUser + segmented theme.
- **Одинаковый рецепт** ops-экранов: Frame, DataGrid+Filters, KPI = stats-12 + IconTile `elevated`, Quick Actions = Frame tiles, settings-3/16, Sheet для create/edit, Frame-friendly empty.
- **Разный домен:** topology VPS, DNS/kanban CFDM, BIRD/lookup EvoBGP, IdP Auth Portal, agents EvoFirewall, `/servers`/terminal MikrotikManager.
- **Не менять:** Vite vs Next, package scopes, API/SSO/OIDC, CRUD-поведение.
Главные gaps: Auth Portal без SystemMonitor; MikrotikManager без AppsMenu/NavUser/Switcher и с Sun/Moon toggle; ни один проект не установил `icon-tile`; VPS Card-shells на detail/charts; MM `DataPageCard` = Card.
---
## 2. Workspace Coverage
`Discovered projects: 6`
`Audited projects: 6`
`Projects with plan: 6`
`Coverage: 100%`
`Implementation: 34/34 CHANGE DONE (2026-08-17)`
| # | Project | Path | Type | Audited | Changes | Status |
|---|---|---|---|---|---|---|
| 1 | VPS Tracker | `c:\Users\shats\Dev\vps-tracker` | fullstack Vite SPA | YES | CHANGE-003, 004, 005, 009014, 025, 029 | Implemented |
| 2 | Cloudflare Domain Manager | `c:\Users\shats\Dev\cloudflare-domain-manager` | fullstack Vite SPA | YES | CHANGE-003, 004, 005, 006, 024 | Implemented |
| 3 | EvoBGP | `c:\Users\shats\Dev\EvoBGP` | fullstack Go API + Vite SPA | YES | CHANGE-003, 004, 005, 015017, 019 | Implemented |
| 4 | Auth Portal | `c:\Users\shats\Dev\auth-portal` | fullstack Vite SPA | YES | CHANGE-001, 004, 005, 021023 | Implemented |
| 5 | EvoFirewall | `c:\Users\shats\Dev\EvoFirewall` | fullstack Vite SPA | YES | CHANGE-003, 004, 005, 007, 020 | Implemented |
| 6 | MikrotikManager | `c:\Users\shats\Dev\MikrotikManager-3` | fullstack Next.js 16 | YES | CHANGE-002, 004, 008, 018, 027, 032034 | Implemented |
Backend-only проектов нет. Shared UX/UI rules: CHANGE-026, 028, 030, 031, RULE-CHANGE-001…010.
---
## 3. Workspace Inventory
| Project | Framework | Language | UI | CSS | Theme | Package UI | Kit |
|---|---|---|---|---|---|---|---|
| VPS Tracker | Vite + TanStack Router/Query | TS | shadcn base-nova + ReUI | Tailwind v4 | next-themes | `@cfdm/ui` | `reui-kit` (9 files) |
| CFDM | Vite + TanStack | TS | shadcn base-nova + ReUI | Tailwind v4 | next-themes | `@cfdm/ui` | `reui-kit` (8 files) |
| EvoBGP | Vite + TanStack; API Go | TS / Go | shadcn base-nova + ReUI | Tailwind v4 | next-themes | `@evobgp/ui` | `reui-kit` (7 files) — **KPI SoT** |
| Auth Portal | Vite + TanStack | TS | shadcn base-nova + ReUI | Tailwind v4 | next-themes | `@authportal/ui` | `reui-kit` (11 files; `ResourcePage` stub) |
| EvoFirewall | Vite + TanStack | TS | shadcn base-nova + ReUI | Tailwind v4 | next-themes | `@evofw/ui` | `reui-kit` (6 files) |
| MikrotikManager | Next.js 16 App Router | TS | shadcn + **partial** ReUI | Tailwind v4 | custom `theme-provider` (`rl-theme`) | `@/components/ui` | **нет** `reui-kit` |
TanStack Table во всех web-приложениях: `^8.21.x`. ReUI Data Grid docs (MCP `get_component`): TanStack Table **v9**. Это technical risk — см. §18. Не мигрировать v8→v9 в этом плане.
`icon-tile`: **не установлен ни в одном** из 6 (поиск `IconTile` / `icon-tile` = 0 файлов).
---
## 4. MCP / Skills / Plugins Inventory
| Tool | Status | Use in audit |
|---|---|---|
| MCP `user-reui` | ready | `get_block`, `get_component`, `get_example`, `get_agent_skill` — официальные `docsUrl`/`previewUrl` |
| MCP `plugin-shadcn-shadcn` | listed | primitives `@shadcn` (secondary) |
| MCP codegraph (vps / CFDM / EvoBGP) | available | не заменял filesystem inventory |
| Skill `.claude/skills/reui` | `668fb463eb` | совпадает с MCP `get_agent_skill` |
| Skill shadcn (plugin + CFDM/EvoFirewall) | present | CLI/registry, не SoT для PRO blocks |
| Context7 MCP | available | не нужен для REUI PRO URLs |
Приоритет: официальная документация REUI PRO > MCP metadata > project rules.
---
## 5. Project Rules Inventory
| Source | Project | Category | Used? |
|---|---|---|---|
| `docs/ui-design-contract.md` | VPS, CFDM, EvoBGP, Auth, FW | UX/UI contract (Frame, chrome, kit) | YES — baseline |
| `.cursor/rules/reui-mcp.mdc` | 5 Vite | ReUI MCP workflow | YES |
| `.cursor/rules/kpi-hybrid.mdc` | 5 Vite | KPI Item `size-10.5` DNA | YES — **устарело vs IconTile** |
| `.cursor/rules/frontend-shadcn.mdc` / `frontend-ui-patterns.mdc` | VPS, CFDM, Auth, FW | patterns | YES; VPS lists `kanban-board.tsx`, которого нет на диске |
| `.cursor/rules/web-shadcn.mdc` | EvoBGP | WEB-01…WEB-22 | YES |
| `AGENTS.md` | VPS, EvoBGP, Auth, FW, MM | agent guide | YES; **CFDM: `<not_found>`** |
| `.cursor/rules/next-shadcn-production.mdc` | MM | SoT = `/servers` Card | YES — **противоречит унификации** |
| `.cursor/rules/ma-ui-guardian.mdc` | MM | UI Guardian = `/servers` | YES — **противоречит** |
| Workspace `reui-pro-senior-frontend.mdc` | all | chrome + Frame lock | YES; Auth ids без `dns` |
Technical rules (Fastify/Drizzle/Go/Next App Router/CI) — **не менять** ради UI.
---
## 6. Project UX/UI Rules Audit
| Rule | Source | Current Usage | Problem | Recommendation | Status |
|---|---|---|---|---|---|
| Surface `frame` | ui-design-contract | 5 Vite mostly; MM Card | MM + VPS mixed Card | TARGET-001 | MODIFY (enforce all 6) |
| Chrome 240px / AppsMenu / NavUser | contract | 4 Vite close; Auth no monitor; MM none | Identity drift | TARGET-006 | NEW (1:1 all 6) |
| KPI hybrid Item tile | kpi-hybrid.mdc | 5 Vite kit | Official primitive IconTile unused | TARGET-003 | REPLACE |
| Lists DataGrid+Filters | contract | Vite ResourcePage; MM DataPageCard | MM Card; VPS `spaces` Table | TARGET-002 | MODIFY |
| Settings settings-16 / settings-3 | contract | CFDM closest | settings-7 is `surface: card` | TARGET-005 | MODIFY |
| Empty empty-state-12 | contract | mixed | empty-state-11 is Card; prefer 14 | TARGET-008 | MODIFY |
| MM SoT `/servers` Card | next-shadcn-production | enforced | Blocks Frame unification | RULE-CHANGE-001 | REPLACE |
| App Switcher ids | Auth contract vs `APP_IDS` | code has `dns` | Contract omits `dns` | CHANGE-023 | MODIFY |
| Installed ReUI lists | reui-mcp.mdc | claims kanban in VPS | File missing | CHANGE-025 | MODIFY |
| CFDM AGENTS.md / ui-surface.ts | contract mentions | `<not_found>` | Rules drift | CHANGE-024 | NEW |
---
## Chrome Identity Spec (обязательный 1:1 — все 6)
Эталон: [app-shell-12](https://reui.io/preview/base/app-shell-12) + [app-shell-7](https://reui.io/preview/base/app-shell-7) (SystemMonitor) + [app-shell-1](https://reui.io/preview/base/app-shell-1) (NavUser).
Применяется к **каждому** приложению, включая Auth Portal `_auth` shell и MikrotikManager `(main)` layout. Login Auth Portal **без** AppShell — отдельный auth surface ([auth-18](https://reui.io/preview/base/auth-18)).
| Зона | Обязательное значение | Сейчас VPS/CFDM/BGP/FW | Auth Portal | MikrotikManager |
|---|---|---|---|---|
| `--sidebar-width` | `240px` | `240px` (`packages/ui/.../sidebar.tsx`) | 240px (ожидаемо, тот же паттерн) | **`16rem`** (`components/ui/sidebar.tsx`) |
| Sidebar header | App Switcher ids `cfdm` · `vps` · `bgp` · `fw` · `dns` | есть | есть | **нет** |
| Sidebar footer | NavUser + segmented theme (light/dark/system), без Sun/Moon icon | NavUser segmented | NavUser segmented | **hand-roll** avatar + Sun/Moon (`app-sidebar.tsx` ~239266); `nav-user.tsx` **не используется** |
| Header right | AppsMenu → SystemMonitorPopover; без ModeToggle; без Search-кнопки (только ⌘K) | AppsMenu + SystemMonitor | AppsMenu **без** SystemMonitor (`site-header.tsx` 7375) | нет AppsMenu / SystemMonitor; `CommandPaletteButton` в sidebar header |
| Header | `h-12`, sticky, `border-b`, `px-4 md:px-6` | да | да | `<uncertain>` vs Vite (Next layout свой) |
| Main | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` | contract | contract | `<not_verified>` pixel-match |
| Theme | next-themes **или эквивалент с тем же UX** + semantic `--success/--warning/--info/--invert` | next-themes | next-themes | custom `theme-provider` `rl-theme` — UX должен стать segmented 3-way |
| Запрещено | `SidebarRail`, Search pill в header, ModeToggle в header | Vite: без Rail | без Rail | **`SidebarRail` есть** (`app-sidebar.tsx` 267) |
SSO: вход через auth-portal; App Switcher `GET /api/v1/app-switcher`. Ids в коде: `packages/shared/src/contracts/auth.ts` `APP_IDS = ['cfdm', 'vps', 'bgp', 'fw', 'dns']`.
---
## 7. UX/UI Audit — VPS Tracker
**Identity:** Vite SPA `apps/web`, `@cfdm/ui`, ReUI CLI в `apps/web/src/components/reui/`.
**Routes (`_auth/`):** dashboard, vps, `vps.$vpsId`, accounts, providers, projects, `projects.$projectId`, payments, balance, tariffs, renewals, resources, reports, spaces, topology, audit, sync-journal.
**Kit:** `resource-page`, `kpi-stat-grid`, `quick-action-grid`, `ops-dashboard`, `settings-shell`, `detail-panel`, `frame-data-grid`, `settings-card`, `topology-canvas`. Нет `kanban-board`.
**ReUI on disk:** autocomplete, badge, icon-stack, filters, frame, alert, color-picker, timeline, number-field, date-selector + `data-grid/*`. Нет `icon-tile`, нет `kanban`.
**Chrome:** `app-shell.tsx` — SystemMonitor есть; sidebar 240px.
**Surface drift:** Card в `vps.$vpsId.tsx`, `resources.tsx`, `renewals.tsx`, `domain/charts.tsx`, `integrations/app-switcher-editor.tsx`. shadcn Table в `spaces.tsx`.
**Dashboard QA:** contract: VPS order = banner → KPI → charts → attention → QuickActionGrid (исключение vs EvoBGP KPI → QA → rest).
**Preserve:** VPS CRUD, BILLmanager sync, тарифы, баланс, платежи, topology canvas, CSV export, фильтры отчётов.
---
## 8. UX/UI Audit — Cloudflare Domain Manager
**Identity:** Vite SPA, `@cfdm/ui`, Frame-first. Card **не** импортируется в `apps/web` (grep 0). Ближе всех к контракту.
**Routes:** index, domains, `domains/$domainId`, DNS, services, groups, `groups/$groupId`, certificates, settings (index/appearance/integrations).
**Kit:** + `kanban-board`, `dashboard-analytics`. Нет `frame-data-grid` отдельным файлом (`<uncertain>` — может быть внутри ResourcePage).
**Gaps:** нет `AGENTS.md`; нет `ui-surface.ts`; naming `*KpiCards` (`domain-kpi-cards.tsx`, `cert-kpi-cards.tsx`, `service-kpi-cards.tsx`) при том что внутри уже `KpiStatGrid`; KPI tiles = Item hybrid, не IconTile.
**Preserve:** DNS CRUD, Cloudflare certs, groups/services kanban, integrations.
---
## 9. UX/UI Audit — EvoBGP
**Identity:** KPI SoT. `@evobgp/ui`. `lib/ui-surface.ts` есть.
**Routes:** dashboard, modules, `modules/$moduleId`, `modules/new`, lookup, network, operations, monitoring, directories, schedule, settings, tenant-settings, access.
**Card leftovers:** `modules/new.tsx`, `illustrated-empty-state.tsx`, `kpi-sparkline-card.tsx`, examples `c-tabs-*`.
**Kit unused:** `SettingsShell` определён в `reui-kit/settings-shell.tsx`, **не** импортируется routes (grep только definition).
**Forms:** WEB-05 требует RHF; фактическое использование в `src``<uncertain>` / выравнивать на Field + sheet-8.
**Preserve:** modules CRUD, lookup, BIRD jobs, network, access keys.
---
## 10. UX/UI Audit — Auth Portal
**Identity:** IdP, не ops CRUD. Login: `routes/index.tsx``PortalLoginForm` на базе **auth-18** (`components/blocks/auth-18/...`).
**`_auth` routes:** apps, admin users, user detail, oidc, logins, audit, admin apps.
**Chrome gap:** `site-header.tsx` — только `AppsMenu`, **нет** `SystemMonitorPopover` (grep 0 по репо).
**Kit:** `ResourcePage` — stub types only (`reui-kit/resource-page.tsx`). `/apps` — hand-roll Frame tiles, не `QuickActionGrid`.
**Contract vs code:** `docs/ui-design-contract.md` ids без `dns`; `APP_IDS` включает `dns`.
**Preserve:** SSO JWT, OIDC authorize/token/userinfo, app switcher admin, user access, audit.
---
## 11. UX/UI Audit — EvoFirewall
**Identity:** `@evofw/ui`. Chrome: `site-header.tsx` AppsMenu + SystemMonitor.
**Routes:** index, agents, `agents/$id`, lists, `lists/$id`, rules, `rules/$setId`, stats, settings.
**SettingsShell:** файл есть, **не** используется routes (grep 0 в routes). `/settings` — PageShell + Frame (допустимо как REPLACE rule).
**AgentPlatformIcon:** Item `size-10.5` `bg-muted` (`agents/agent-platform-icon.tsx`) — та же устаревшая KPI DNA.
**Agent cards view:** Frame domain (`?view=cards|table`) — **оставить**, не Card.
**Preserve:** agent fleet, blocklists, policy rules, stats, EvoBGP prefix ingest.
---
## 12. UX/UI Audit — MikrotikManager
**Identity:** Next.js 16, `components.json` с `@reui`, **нет** `reui-kit`. SoT UI: `app/(main)/servers/page.tsx` + `DataPageCard``Card` (`components/data-page-card.tsx`).
**Pages (27):** servers, dashboard, firewall, filters, certificates, gre, data-collection, uptime, recursive-routes, route-optimizer, ospf, communities, settings, probes, vxlan, wireguard, backups, bgp, ip-ranges, domains, asns, alerts, network-map, terminal, traffic, releases, containers.
**ReUI on disk:** frame, filters, badge, alert, autocomplete, number-field, sortable, timeline, stepper + data-grid (partial). `frame.tsx` установлен, ops-shell его не использует.
**Chrome:** sidebar `16rem`; footer hand-roll + Sun/Moon; `NavUser` файл есть, не подключён; нет App Switcher / AppsMenu / SystemMonitor; `SidebarRail` включён.
**Theme:** `components/theme-provider.tsx` STORAGE_KEY `rl-theme` — не next-themes; UX должен стать 3-way segmented.
**Preserve:** весь CRUD `/servers` и остальные 26 страниц (terminal, network-map, контейнеры, BGP/OSPF/firewall…). Framework остаётся Next.js.
---
## 13. Cross-project Inconsistency Matrix
| Area | VPS | CFDM | EvoBGP | Auth | FW | MM | UX impact | Priority | Target |
|---|---|---|---|---|---|---|---|---|---|
| Ops surface | mixed Card | Frame | leftover Card | Frame | Frame | **Card SoT** | две системы | P0/P1 | Frame |
| Chrome identity | close | close | close | no monitor | close | **none** | разный «дом» | P0 | Chrome spec |
| Sidebar width | 240px | 240px | 240px | 240px | 240px | 16rem | визуальный разрыв | P0 | 240px |
| Theme control | NavUser segmented | same | same | same | same | Sun/Moon | разный UX | P0 | segmented |
| KPI tile | Item hybrid | Item hybrid | Item hybrid SoT | Item | Item / AgentPlatformIcon | Card KPI | нет IconTile | P1 | IconTile elevated |
| Lists | ResourcePage; spaces Table | ResourcePage | ResourcePage | stub + Frame grids | ResourcePage | DataPageCard | MM/VPS spaces | P1 | DataGrid+Filters |
| Quick Actions | kit (order exception) | kit | kit | hand-roll `/apps` | kit | `<not_found>` | Auth/MM | P1 | QuickActionGrid |
| Settings | SettingsShell | Frame settings | unused SettingsShell | N/A portal | unused SettingsShell | Card settings | dead kit | P2 | settings-3/16 |
| Empty | EmptyState | EmptyState | Card illustrated | Frame empty `/apps` | EmptyState | EmptyState Card-ish | Card empty | P2 | empty-state-14/12 |
| Package UI | `@cfdm/ui` | `@cfdm/ui` | `@evobgp/ui` | `@authportal/ui` | `@evofw/ui` | `@/components/ui` | не сливать npm | — | KEEP scopes |
---
## 14. Duplicate Components Analysis
| Task | Copies | Diff | Unify? | REUI |
|---|---|---|---|---|
| `KpiStatGrid` | 5 Vite repos | import scope only (SoT EvoBGP) | DNA sync per repo, **не** monorepo package | REUI-004 + REUI-005 |
| `QuickActionGrid` | 5 Vite | same | same | REUI-013 |
| `ResourcePage` | 4 ops + Auth stub | Auth stub | implement or stop claiming | REUI-006 |
| `OpsDashboard` | VPS/CFDM/BGP/FW | similar | KEEP per repo | REUI-014 |
| `SettingsShell` | VPS/CFDM/BGP/FW | BGP/FW unused | wire or stop listing as used | REUI-007 |
| `SystemMonitorPopover` | 4 Vite; Auth/MM missing | copy per app | port to Auth + MM | REUI-011 |
| `DataPageCard` vs Frame | MM only vs Vite Frame | Card vs Frame | REPLACE MM | REUI-001 |
| Item KPI tile vs IconTile | 5 Vite + FW AgentPlatformIcon | hand-roll | REPLACE with IconTile | REUI-005 |
Общий npm-пакет kit **не** входит в этот план (package boundaries = technical risk).
---
## 15. REUI PRO Reference Registry
| Ref ID | Component/Block | Official Documentation | Official Example | Used By | Match |
|---|---|---|---|---|---|
| REUI-001 | Frame | [docs/components/base/frame](https://reui.io/docs/components/base/frame) | [components/frame](https://reui.io/components/frame) | CHANGE-009018, 027 | 1:1 ops surface |
| REUI-002 | Data Grid | [docs/components/base/data-grid](https://reui.io/docs/components/base/data-grid) | [components/data-grid](https://reui.io/components/data-grid) | CHANGE-013, 018 | 1:1 lists; docs = TanStack **v9** vs installed v8 → adaptation = **partial** until migration |
| REUI-003 | Filters | [docs/components/base/filters](https://reui.io/docs/components/base/filters) | [components/filters](https://reui.io/components/filters) | CHANGE-013, 018 | 1:1 |
| REUI-004 | stats-12 | [blocks](https://reui.io/blocks) | [preview/base/stats-12](https://reui.io/preview/base/stats-12) | CHANGE-005, 008 | **partial** (MCP: Frame+Badge+Item; kit hand-rolls Item) |
| REUI-005 | Icon Tile | [docs/components/base/icon-tile](https://reui.io/docs/components/base/icon-tile) | [components/icon-tile](https://reui.io/components/icon-tile) | CHANGE-004, 005, 007, 008 | 1:1 for tile primitive (`variant="elevated"` per docs) |
| REUI-006 | data-grid-filtering-2 | [blocks](https://reui.io/blocks) | [preview/base/data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) | CHANGE-013, 018, 021 | **partial** (kit ResourcePage) |
| REUI-007 | settings-16 | [blocks/application/settings](https://reui.io/blocks/application/settings) | [preview/base/settings-16](https://reui.io/preview/base/settings-16) | CHANGE-020 | 1:1 integrations |
| REUI-008 | settings-3 | [blocks](https://reui.io/blocks) | [preview/base/settings-3](https://reui.io/preview/base/settings-3) | CHANGE-020 | 1:1 settings rows Frame |
| REUI-009 | settings-8 | [blocks](https://reui.io/blocks) | [preview/base/settings-8](https://reui.io/preview/base/settings-8) | CHANGE-020 | 1:1 webhook-like lists |
| REUI-010 | app-shell-12 | [blocks](https://reui.io/blocks) | [preview/base/app-shell-12](https://reui.io/preview/base/app-shell-12) | CHANGE-001, 002, 003 | **partial** (chrome composition) |
| REUI-011 | app-shell-7 | [blocks](https://reui.io/blocks) | [preview/base/app-shell-7](https://reui.io/preview/base/app-shell-7) | CHANGE-001, 002 | **partial** (SystemMonitor popover match) |
| REUI-012 | app-shell-1 | [blocks](https://reui.io/blocks) | [preview/base/app-shell-1](https://reui.io/preview/base/app-shell-1) | CHANGE-002, 032 | **partial** (NavUser) |
| REUI-013 | card-12 | [blocks](https://reui.io/blocks) | [preview/base/card-12](https://reui.io/preview/base/card-12) | CHANGE-022 | **partial** (Quick Actions DNA; name «card» но Frame tiles) |
| REUI-014 | dashboard-1 | [blocks](https://reui.io/blocks) | [preview/base/dashboard-1](https://reui.io/preview/base/dashboard-1) | CHANGE-008, 029 | **partial** (OpsDashboard) |
| REUI-015 | empty-state-14 | [blocks](https://reui.io/blocks) | [preview/base/empty-state-14](https://reui.io/preview/base/empty-state-14) | CHANGE-016, 030 | 1:1 Frame empty (MCP `surface: frame`) |
| REUI-016 | empty-state-12 | [blocks](https://reui.io/blocks) | [preview/base/empty-state-12](https://reui.io/preview/base/empty-state-12) | CHANGE-030 | **partial** (current cite; MCP surface none) |
| REUI-017 | sheet-8 | [blocks](https://reui.io/blocks) | [preview/base/sheet-8](https://reui.io/preview/base/sheet-8) | CHANGE-031 | **partial** (settings sheet; surface none) |
| REUI-018 | sheet-1 | [blocks](https://reui.io/blocks) | [preview/base/sheet-1](https://reui.io/preview/base/sheet-1) | CHANGE-031 | **partial** (create sheet) |
| REUI-019 | sheet-9 | [blocks](https://reui.io/blocks) | [preview/base/sheet-9](https://reui.io/preview/base/sheet-9) | CHANGE-031 | **partial** (detail sheet) |
| REUI-020 | auth-13 | [blocks](https://reui.io/blocks) | [preview/base/auth-13](https://reui.io/preview/base/auth-13) | Auth login **не** target (см. REUI-021) | 1:1 Frame login **если** менять; сейчас KEEP auth-18 |
| REUI-021 | auth-18 | [blocks](https://reui.io/blocks) | [preview/base/auth-18](https://reui.io/preview/base/auth-18) | Auth login actual | **partial** (surface none; split testimonial) |
| REUI-022 | solution-users-1 | [blocks](https://reui.io/blocks) | [preview/base/solution-users-1](https://reui.io/preview/base/solution-users-1) | Auth admin users | **partial** (Frame+DataGrid+Filters+Sheet) |
| REUI-023 | form-7 | [blocks](https://reui.io/blocks) | [preview/base/form-7](https://reui.io/preview/base/form-7) | CHANGE-031 | **partial** (inline Frame form) |
| REUI-024 | chart-15 | [blocks](https://reui.io/blocks) | [preview/base/chart-15](https://reui.io/preview/base/chart-15) | CHANGE-012 | 1:1 Frame charts (MCP `surface: frame`) |
| REUI-025 | settings-2 | [blocks](https://reui.io/blocks) | [preview/base/settings-2](https://reui.io/preview/base/settings-2) | CHANGE-020 | 1:1 mixed-control rows Frame |
| REUI-026 | c-tabs-2 | [components/tabs](https://reui.io/components/tabs) | [preview/base/components/c-tabs-2](https://reui.io/preview/base/components/c-tabs-2) | ResourcePage tabs | **partial** (line tabs; example also lists Card) |
| REUI-027 | Alert Dialog example | [components/alert-dialog](https://reui.io/components/alert-dialog) | [c-alert-dialog-11](https://reui.io/preview/base/components/c-alert-dialog-11) | CHANGE-031 confirm | **partial** (shadcn AlertDialog + ReUI example) |
| REUI-028 | Icon Stack | [docs/components/base/icon-stack](https://reui.io/docs/components/base/icon-stack) | [components/icon-stack](https://reui.io/components/icon-stack) | CHANGE-016, 030 | 1:1 empty illustration |
| REUI-029 | Badge | [docs/components/base/badge](https://reui.io/docs/components/base/badge) | [components/badge](https://reui.io/components/badge) | KPI/QA status | 1:1 semantic variants |
| REUI-030 | Alert | [docs/components/base/alert](https://reui.io/docs/components/base/alert) | [components/alert](https://reui.io/components/alert) | errors | 1:1 |
| REUI-031 | Kanban | [docs/components/base/kanban](https://reui.io/docs/components/base/kanban) | [components/kanban](https://reui.io/components/kanban) | CFDM groups/services | 1:1 domain |
| REUI-032 | Sortable | [docs/components/base/sortable](https://reui.io/docs/components/base/sortable) | [components/sortable](https://reui.io/components/sortable) | FW policy | 1:1 domain |
| REUI-033 | Stepper | [docs/components/base/stepper](https://reui.io/docs/components/base/stepper) | [components/stepper](https://reui.io/components/stepper) | MM/FW if used | 1:1; FW unused → don't list as used |
| REUI-034 | Timeline | [docs/components/base/timeline](https://reui.io/docs/components/base/timeline) | [components/timeline](https://reui.io/components/timeline) | audit | 1:1 |
| REUI-035 | Autocomplete | [docs/components/base/autocomplete](https://reui.io/docs/components/base/autocomplete) | [components/autocomplete](https://reui.io/components/autocomplete) | domain pickers | 1:1 |
**Не target (намеренно):**
| Item | Why | Evidence |
|---|---|---|
| settings-7 | MCP `surface: card` | [preview/base/settings-7](https://reui.io/preview/base/settings-7) |
| empty-state-11 | MCP `surface: card` | [preview/base/empty-state-11](https://reui.io/preview/base/empty-state-11) |
---
## 16. Target UX/UI Rules
### TARGET-RULE-001
**Rule:** Один surface на ops-экран: ReUI Frame. Не смешивать Card и Frame.
**Scope:** все 6.
**Rationale:** две системы ломают consistency.
**REUI PRO:** [Frame](https://reui.io/docs/components/base/frame)
**Impact:** VPS Card pages, BGP leftovers, MM DataPageCard.
### TARGET-RULE-002
**Rule:** Ops-списки = DataGrid + Filters внутри Frame. Запрет raw `<table>` / shadcn Table как ops list.
**Scope:** все 6.
**REUI PRO:** [data-grid](https://reui.io/docs/components/base/data-grid) · [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2)
**Impact:** VPS `spaces.tsx`; MM DataPageCard.
### TARGET-RULE-003
**Rule:** KPI = stats-12 composition + IconTile `elevated` (не hand-roll Item `size-10.5`). Semantic `text-*` / Badge, не `bg-emerald-*`.
**Scope:** kit во всех Vite + MM dashboard.
**REUI PRO:** [stats-12](https://reui.io/preview/base/stats-12) · [icon-tile](https://reui.io/docs/components/base/icon-tile)
### TARGET-RULE-004
**Rule:** Quick Actions = только `QuickActionGrid` (Frame tiles, sibling KPI).
**REUI PRO:** [stats-12](https://reui.io/preview/base/stats-12) · [card-12](https://reui.io/preview/base/card-12)
### TARGET-RULE-005
**Rule:** Settings rows = settings-3 / settings-2; integrations = settings-16; **запрет settings-7**.
**REUI PRO:** [settings-3](https://reui.io/preview/base/settings-3) · [settings-16](https://reui.io/preview/base/settings-16)
### TARGET-RULE-006 (P0)
**Rule:** Chrome identity 1:1 во всех 6 — см. Chrome Identity Spec. Auth Portal и MikrotikManager **не** исключение. Login без AppShell.
**REUI PRO:** [app-shell-12](https://reui.io/preview/base/app-shell-12) · [app-shell-7](https://reui.io/preview/base/app-shell-7) · [app-shell-1](https://reui.io/preview/base/app-shell-1)
### TARGET-RULE-007
**Rule:** Create/edit = Sheet (sheet-8 / sheet-1 / sheet-9). Confirm = Alert Dialog ([c-alert-dialog-11](https://reui.io/preview/base/components/c-alert-dialog-11)).
### TARGET-RULE-008
**Rule:** Empty = Empty + IconStack, Frame-friendly empty-state-14 (prefer) / empty-state-12. Не empty-state-11.
### TARGET-RULE-009
**Rule:** MikrotikManager UX SoT: Frame+DataGrid **при сохранении** CRUD `/servers`. Next.js не менять.
### TARGET-RULE-010
**Rule:** Rules синхронизировать с диском (не перечислять неустановленные компоненты как установленные). App Switcher ids включают `dns`.
---
### RULE-CHANGE-001
**Current rule:** SoT UI = `/servers` на Card (`next-shadcn-production.mdc`, `ma-ui-guardian.mdc`).
**Source:** `MikrotikManager-3/.cursor/rules/next-shadcn-production.mdc`
**Problem:** блокирует унификацию Frame.
**Evidence:** `components/data-page-card.tsx``Card`.
**Recommended:** SoT = Frame + DataGrid; `/servers` остаётся эталоном **поведения** CRUD, не Card-shell.
**REUI:** REUI-001, REUI-006.
**Impact:** MM rules + все `DataPageCard` pages.
### RULE-CHANGE-002
**Current:** kpi-hybrid Item `size-10.5` `bg-muted`.
**Source:** `kpi-hybrid.mdc` ×5.
**Problem:** official IconTile покрывает задачу; нигде не установлен.
**Recommended:** KPI/row tiles = IconTile `elevated` + stats-12 composition.
**REUI:** REUI-004, REUI-005.
### RULE-CHANGE-003
**Current:** chrome описан в contract, но Auth/MM исключения фактически существуют.
**Recommended:** Chrome Identity Spec обязателен 1:1 для всех 6.
**REUI:** REUI-010, REUI-011, REUI-012.
### RULE-CHANGE-004
**Current:** contract App Switcher ids без `dns`.
**Source:** `auth-portal/docs/ui-design-contract.md`
**Evidence:** `APP_IDS` включает `dns`.
**Recommended:** ids `cfdm · vps · bgp · fw · dns`.
### RULE-CHANGE-005
**Current:** VPS `frontend-shadcn.mdc` / `reui-mcp.mdc` lists `kanban-board`.
**Evidence:** `reui-kit/` VPS — нет `kanban-board.tsx`; grep `kanban` в VPS = 0.
**Recommended:** список установленных = диск.
### RULE-CHANGE-006
**Current:** settings-7 встречается как reference.
**Evidence:** MCP settings-7 `surface: card`.
**Recommended:** запретить как target.
---
## 17. Detailed Change Plan
Шаблон ниже. Functional requirements для всех UI CHANGE: сохранить create/edit/delete/filter/sort/pagination/navigation/permissions; менять только оболочку.
Состояния (где применимо): default, hover, focus, disabled, loading, error, empty.
Responsive: desktop = эталон ReUI preview; tablet/mobile = существующий Sidebar/Sheet collapse Vite/Next, не ломать.
A11y: keyboard, focus ring, labels, `aria-hidden` на decorative IconTile (docs).
### CHANGE-001
**Project:** Auth Portal · **Priority:** P0 · **Category:** Navigation / chrome
**File:** `apps/web/src/components/layout/site-header.tsx`
**Current:** header right = только `AppsMenu` (строки 7375).
**Problem:** нет SystemMonitor — ломает chrome identity.
**Change:** добавить `SystemMonitorPopover` после AppsMenu (порт с CFDM/VPS).
**Ref:** REUI-011 · [app-shell-7](https://reui.io/preview/base/app-shell-7) · Match: **partial**
**Mapping:** AppsMenu-only header → AppsMenu + SystemMonitor.
**Preserve:** breadcrumbs, AppsMenu, SSO session.
**Risk:** Low.
### CHANGE-002
**Project:** MikrotikManager · **Priority:** P0 · **Category:** Navigation / chrome
**Files:** `components/app-sidebar.tsx`, `components/ui/sidebar.tsx`, `(main)/layout.tsx`
**Current:** sidebar 16rem; footer hand-roll + Sun/Moon; SidebarRail; нет AppsMenu/SystemMonitor/App Switcher.
**Change:** Chrome Identity Spec 1:1 (240px, Switcher, NavUser segmented, header cluster, убрать Rail и icon theme toggle).
**Ref:** REUI-010, REUI-011, REUI-012
**Match:** **partial** (composition, Next layout).
**Preserve:** nav groups, command palette **как ⌘K** (не Search pill в header).
**Risk:** High.
### CHANGE-003
**Project:** VPS, CFDM, EvoBGP, EvoFirewall · **Priority:** P0 · **Category:** chrome verification
**Current:** близко к spec (SystemMonitor есть).
**Change:** pixel/token сверка 1:1 (header `h-12`, main padding, нет ModeToggle/Search pill).
**Ref:** REUI-010 · Match: **partial**
**Risk:** Low.
### CHANGE-004
**Project:** все 6 · **Priority:** P1 · **Category:** KPI primitive install
**Current:** `icon-tile` отсутствует.
**Change:** CLI `pnpm dlx shadcn@latest add @reui/icon-tile --yes` из `apps/web` (MM — из корня Next).
**Ref:** REUI-005 · [icon-tile docs](https://reui.io/docs/components/base/icon-tile) · Match: **1:1**
**Risk:** Low.
### CHANGE-005
**Project:** 5 Vite kit · **Priority:** P1 · **Category:** KPI
**Files:** `reui-kit/kpi-stat-grid.tsx` (каждый репо); EvoBGP SoT markup.
**Current:** `Item` `size-10.5` `bg-muted` + `ItemMedia`.
**Change:** IconTile `elevated` + stats-12 composition; обновить `kpi-hybrid.mdc`.
**Ref:** REUI-004 **partial** + REUI-005 **1:1**
**Preserve:** `to`/`onSelect`, variants warning/destructive, Badge hints.
**Risk:** Medium (visual DNA shift).
### CHANGE-006
**Project:** CFDM · **Priority:** P2 · **Category:** naming
**Files:** `domain-kpi-cards.tsx`, `cert-kpi-cards.tsx`, `service-kpi-cards.tsx`
**Change:** переименовать `*KpiCards``*KpiStats` (внутри уже KpiStatGrid). Не Card.
**Ref:** REUI-004 · Match: **partial** (rename only)
**Risk:** Low.
### CHANGE-007
**Project:** EvoFirewall · **Priority:** P1 · **Category:** row tiles
**File:** `apps/web/src/components/agents/agent-platform-icon.tsx`
**Current:** Item hybrid tile.
**Change:** IconTile elevated; semantic `text-info` / muted.
**Ref:** REUI-005 · Match: **1:1**
**Preserve:** mikrotik vs linux aria-label.
**Risk:** Low.
### CHANGE-008
**Project:** MikrotikManager · **Priority:** P1 · **Category:** KPI
**File:** `app/(main)/dashboard/page.tsx`
**Change:** dashboard metrics → stats-12 + IconTile, не Card KPI.
**Ref:** REUI-004, REUI-005, REUI-014 · Match: **partial**
**Preserve:** dashboard numbers/links.
**Risk:** Medium.
### CHANGE-009
**Project:** VPS Tracker · **Priority:** P1 · **Category:** Surface
**File:** `apps/web/src/routes/_auth/vps.$vpsId.tsx`
**Current:** Card shells.
**Change:** Frame / DetailPanel.
**Ref:** REUI-001, REUI-023 · Match: **partial** (detail composition)
**Preserve:** VPS detail fields, actions, sync.
**Risk:** Medium.
### CHANGE-010
**Project:** VPS Tracker · **Priority:** P1 · **Category:** Surface
**File:** `apps/web/src/routes/_auth/resources.tsx`
**Change:** Card → Frame.
**Ref:** REUI-001 · Match: **1:1** shell
**Preserve:** resource listing.
**Risk:** Low.
### CHANGE-011
**Project:** VPS Tracker · **Priority:** P1 · **Category:** Surface
**File:** `apps/web/src/routes/_auth/renewals.tsx`
**Change:** Card → Frame.
**Ref:** REUI-001 · Match: **1:1** shell
**Preserve:** renewals data/actions.
**Risk:** Low.
### CHANGE-012
**Project:** VPS Tracker · **Priority:** P1 · **Category:** Charts
**File:** `apps/web/src/components/domain/charts.tsx` (used by `reports.tsx`)
**Current:** Card from `@cfdm/ui/components/card`.
**Change:** Frame chart shells per chart-15.
**Ref:** REUI-024 · [chart-15](https://reui.io/preview/base/chart-15) · Match: **1:1** surface; **partial** series (Recharts already)
**Preserve:** filters, CSV, KPI on reports.
**Risk:** Medium.
### CHANGE-013
**Project:** VPS Tracker · **Priority:** P1 · **Category:** Data UI
**File:** `apps/web/src/routes/_auth/spaces.tsx`
**Current:** shadcn Table.
**Change:** ResourcePage / DataGrid + Filters.
**Ref:** REUI-002 **partial** (v8), REUI-003, REUI-006
**Preserve:** spaces CRUD, columns, sort.
**Risk:** Medium.
### CHANGE-014
**Project:** VPS Tracker · **Priority:** P2 · **Category:** Settings
**File:** `apps/web/src/components/integrations/app-switcher-editor.tsx`
**Current:** Card.
**Change:** Frame + settings-16/3 rows.
**Ref:** REUI-001, REUI-007 · Match: **partial**
**Preserve:** app switcher editor fields.
**Risk:** Low.
### CHANGE-015
**Project:** EvoBGP · **Priority:** P1 · **Category:** Surface
**File:** `apps/web/src/routes/_auth/modules/new.tsx`
**Current:** Card stub.
**Change:** Frame + sheet-1/form-7 for create.
**Ref:** REUI-001, REUI-018 · Match: **partial**
**Preserve:** module create flow.
**Risk:** Medium.
### CHANGE-016
**Project:** EvoBGP · **Priority:** P1 · **Category:** Empty
**File:** `apps/web/src/components/patterns/illustrated-empty-state.tsx`
**Current:** Card + illustration.
**Change:** empty-state-14 (Frame + IconStack).
**Ref:** REUI-015, REUI-028 · Match: **1:1** surface
**Preserve:** empty CTA copy/actions.
**Risk:** Low.
### CHANGE-017
**Project:** EvoBGP · **Priority:** P2 · **Category:** Charts
**File:** `apps/web/src/components/patterns/kpi-sparkline-card.tsx`
**Change:** Card → Frame / chart-15 DNA.
**Ref:** REUI-024 · Match: **partial**
**Risk:** Low.
### CHANGE-018
**Project:** MikrotikManager · **Priority:** P1 · **Category:** Surface
**Files:** `components/data-page-card.tsx` + all pages importing it (servers, firewall, filters, gre, certificates, data-collection, …).
**Current:** `Card`. `components/reui/frame.tsx` установлен, не используется как ops shell.
**Change:** DataPageCard → Frame wrapper (или заменить вызовы на Frame).
**Ref:** REUI-001, REUI-006 · Match: **1:1** shell; grid **partial** (v8)
**Preserve:** весь CRUD `/servers` и остальных страниц.
**Risk:** High.
### CHANGE-019
**Project:** EvoBGP · **Priority:** P2 · **Category:** Kit dead code
**File:** `reui-kit/settings-shell.tsx`
**Change:** подключить на settings routes **или** убрать из «установленный kit / used».
**Ref:** REUI-007, REUI-008 · Match: **partial**
**Risk:** Low.
### CHANGE-020
**Project:** EvoFirewall · **Priority:** P2 · **Category:** Settings
**File:** `routes/_auth/settings.tsx` vs unused `reui-kit/settings-shell.tsx`
**Change:** либо SettingsShell при 2+ секциях, либо явно REPLACE rule «single-page = PageShell+Frame» (уже в FW frontend-shadcn).
**Ref:** REUI-007, REUI-008, REUI-025
**Risk:** Low.
### CHANGE-021
**Project:** Auth Portal · **Priority:** P2 · **Category:** Kit
**File:** `reui-kit/resource-page.tsx` (stub)
**Change:** либо реализовать как ops ResourcePage, либо не экспортировать как list kit. Admin users → solution-users-1 DNA.
**Ref:** REUI-006, REUI-022 · Match: **partial**
**Preserve:** users/OIDC grids.
**Risk:** Medium.
### CHANGE-022
**Project:** Auth Portal · **Priority:** P1 · **Category:** Quick Actions
**File:** `apps/web/src/routes/_auth.apps.tsx`
**Current:** hand-roll Frame tiles.
**Change:** `QuickActionGrid` (после IconTile DNA).
**Ref:** REUI-013, REUI-004 · Match: **partial**
**Preserve:** SSO `openApp`, admin link, empty «нет приложений».
**Risk:** Medium.
### CHANGE-023
**Project:** Auth Portal · **Priority:** P1 · **Category:** Rules
**File:** `docs/ui-design-contract.md` (+ копии в других apps)
**Change:** App Switcher ids += `dns`.
**Ref:** N/A functional; chrome REUI-010
**Match:** `<not_found_in_reui>` для списка ids (доменное) — UI chrome всё равно REUI-010.
**Risk:** Low.
### CHANGE-024
**Project:** CFDM · **Priority:** P2 · **Category:** Rules
**Change:** добавить `AGENTS.md` + `apps/web/src/lib/ui-surface.ts` (`UI_SURFACE = 'frame'`), как EvoBGP.
**Ref:** REUI-001
**Risk:** Low.
### CHANGE-025
**Project:** VPS Tracker · **Priority:** P2 · **Category:** Rules
**Change:** убрать kanban из «установленных» в `reui-mcp.mdc` / `frontend-shadcn.mdc`.
**Ref:** REUI-031 существует, но **не установлен** в VPS.
**Risk:** Low.
### CHANGE-026
**Project:** all Vite · **Priority:** P1 · **Category:** Rules
**Files:** `kpi-hybrid.mdc` ×5 + workspace `reui-pro-senior-frontend.mdc`
**Change:** DNA = IconTile elevated, не Item.
**Ref:** REUI-005
**Risk:** Low (docs only until CHANGE-005).
### CHANGE-027
**Project:** MikrotikManager · **Priority:** P0 · **Category:** Rules
**Change:** RULE-CHANGE-001 — SoT Frame+DataGrid.
**Ref:** REUI-001, REUI-006
**Risk:** High (process + UI).
### CHANGE-028
**Project:** EvoBGP (+ any settings-7 cite) · **Priority:** P1 · **Category:** Rules
**Change:** запрет settings-7 как target.
**Ref:** settings-7 [preview](https://reui.io/preview/base/settings-7) — **не использовать**. Target REUI-007/008.
**Risk:** Low.
### CHANGE-029
**Project:** VPS Tracker · **Priority:** P2 · **Category:** Dashboard layout
**Current:** contract exception QA after charts.
**Change:** либо выровнять с EvoBGP (KPI → QA → rest), либо **явно** оставить exception в contract. Решение implementation: default **KEEP exception** unless product asks otherwise.
**Ref:** REUI-014 · Match: **partial**
**Risk:** Low.
### CHANGE-030
**Project:** all 6 · **Priority:** P2 · **Category:** Empty states
**Change:** Frame-friendly empty-state-14/12 + IconStack; не Card empty-state-11.
**Ref:** REUI-015, REUI-016, REUI-028
**Risk:** Low.
### CHANGE-031
**Project:** all 6 · **Priority:** P2 · **Category:** Forms / overlays
**Change:** create/edit Sheet per sheet-8/1/9; confirm c-alert-dialog-11. Не менять validation/API.
**Ref:** REUI-017, REUI-018, REUI-019, REUI-023, REUI-027
**Risk:** Medium.
### CHANGE-032
**Project:** MikrotikManager · **Priority:** P0 · **Category:** chrome
**File:** `components/nav-user.tsx` unused
**Change:** подключить в SidebarFooter; segmented theme вместо Sun/Moon; убрать hardcoded «АК» footer.
**Ref:** REUI-012 · Match: **partial**
**Preserve:** user name/email display (сейчас hardcoded — **заменить на реального пользователя**, не потерять logout если появится).
**Risk:** Medium.
### CHANGE-033
**Project:** MikrotikManager · **Priority:** P1 · **Category:** chrome
**File:** `components/app-sidebar.tsx` `SidebarRail`
**Change:** удалить Rail (запрещён контрактом).
**Ref:** REUI-010 · Match: **partial**
**Risk:** Low.
### CHANGE-034
**Project:** MikrotikManager · **Priority:** P0 · **Category:** chrome
**File:** `components/ui/sidebar.tsx` `SIDEBAR_WIDTH = "16rem"`
**Change:** `240px`.
**Ref:** REUI-010 · Match: **partial** (token, не block 1:1)
**Risk:** Medium (layout shift).
---
## Final Change Matrix
| Change ID | Project | Category | Priority | Current UI | Target REUI PRO | Ref ID | Official Reference | Match |
|---|---|---|---|---|---|---|---|---|
| CHANGE-001 | Auth Portal | Chrome | P0 | AppsMenu only | app-shell-7 monitor | REUI-011 | [app-shell-7](https://reui.io/preview/base/app-shell-7) | partial |
| CHANGE-002 | MM | Chrome | P0 | 16rem + Sun/Moon | app-shell-12/7/1 | REUI-010 | [app-shell-12](https://reui.io/preview/base/app-shell-12) | partial |
| CHANGE-003 | VPS/CFDM/BGP/FW | Chrome | P0 | close | app-shell-12 | REUI-010 | [app-shell-12](https://reui.io/preview/base/app-shell-12) | partial |
| CHANGE-004 | all 6 | Install | P1 | missing | Icon Tile | REUI-005 | [icon-tile](https://reui.io/docs/components/base/icon-tile) | 1:1 |
| CHANGE-005 | 5 Vite | KPI | P1 | Item hybrid | stats-12 + IconTile | REUI-004/005 | [stats-12](https://reui.io/preview/base/stats-12) | partial + 1:1 |
| CHANGE-006 | CFDM | Naming | P2 | *KpiCards | KpiStatGrid name | REUI-004 | [stats-12](https://reui.io/preview/base/stats-12) | partial |
| CHANGE-007 | FW | Row tile | P1 | Item AgentPlatformIcon | IconTile | REUI-005 | [icon-tile](https://reui.io/docs/components/base/icon-tile) | 1:1 |
| CHANGE-008 | MM | KPI | P1 | Card metrics | stats-12 + IconTile | REUI-004/005 | [stats-12](https://reui.io/preview/base/stats-12) | partial |
| CHANGE-009 | VPS | Surface | P1 | Card detail | Frame | REUI-001 | [frame](https://reui.io/docs/components/base/frame) | partial |
| CHANGE-010 | VPS | Surface | P1 | Card resources | Frame | REUI-001 | [frame](https://reui.io/docs/components/base/frame) | 1:1 |
| CHANGE-011 | VPS | Surface | P1 | Card renewals | Frame | REUI-001 | [frame](https://reui.io/docs/components/base/frame) | 1:1 |
| CHANGE-012 | VPS | Charts | P1 | Card charts | chart-15 | REUI-024 | [chart-15](https://reui.io/preview/base/chart-15) | 1:1/partial |
| CHANGE-013 | VPS | Table | P1 | shadcn Table spaces | DataGrid+Filters | REUI-002/006 | [data-grid](https://reui.io/docs/components/base/data-grid) | partial |
| CHANGE-014 | VPS | Settings | P2 | Card editor | Frame settings-16 | REUI-007 | [settings-16](https://reui.io/preview/base/settings-16) | partial |
| CHANGE-015 | EvoBGP | Surface | P1 | Card new module | Frame/sheet-1 | REUI-001/018 | [sheet-1](https://reui.io/preview/base/sheet-1) | partial |
| CHANGE-016 | EvoBGP | Empty | P1 | Card empty | empty-state-14 | REUI-015 | [empty-state-14](https://reui.io/preview/base/empty-state-14) | 1:1 |
| CHANGE-017 | EvoBGP | Charts | P2 | Card sparkline | chart-15 | REUI-024 | [chart-15](https://reui.io/preview/base/chart-15) | partial |
| CHANGE-018 | MM | Surface | P1 | DataPageCard Card | Frame+DataGrid | REUI-001/006 | [frame](https://reui.io/docs/components/base/frame) | 1:1/partial |
| CHANGE-019 | EvoBGP | Kit | P2 | unused SettingsShell | settings-16/3 | REUI-007 | [settings-16](https://reui.io/preview/base/settings-16) | partial |
| CHANGE-020 | FW | Settings | P2 | unused SettingsShell | settings-3/16 | REUI-008 | [settings-3](https://reui.io/preview/base/settings-3) | partial |
| CHANGE-021 | Auth | Kit | P2 | ResourcePage stub | data-grid-filtering-2 / users-1 | REUI-006/022 | [users-1](https://reui.io/preview/base/solution-users-1) | partial |
| CHANGE-022 | Auth | QA | P1 | hand-roll /apps | QuickActionGrid | REUI-013 | [card-12](https://reui.io/preview/base/card-12) | partial |
| CHANGE-023 | Auth | Rules | P1 | contract без dns | ids + dns | REUI-010 | [app-shell-12](https://reui.io/preview/base/app-shell-12) | partial |
| CHANGE-024 | CFDM | Rules | P2 | no AGENTS.md | Frame lock file | REUI-001 | [frame](https://reui.io/docs/components/base/frame) | 1:1 |
| CHANGE-025 | VPS | Rules | P2 | fake kanban installed | disk-accurate list | REUI-031 | [kanban](https://reui.io/docs/components/base/kanban) | 1:1 exists, unused |
| CHANGE-026 | Vite | Rules | P1 | Item hybrid rule | IconTile rule | REUI-005 | [icon-tile](https://reui.io/docs/components/base/icon-tile) | 1:1 |
| CHANGE-027 | MM | Rules | P0 | Card SoT | Frame SoT | REUI-001 | [frame](https://reui.io/docs/components/base/frame) | 1:1 |
| CHANGE-028 | EvoBGP | Rules | P1 | settings-7 cite | forbid card settings | REUI-007 | [settings-16](https://reui.io/preview/base/settings-16) | 1:1 |
| CHANGE-029 | VPS | Layout | P2 | QA order exception | KEEP or align | REUI-014 | [dashboard-1](https://reui.io/preview/base/dashboard-1) | partial |
| CHANGE-030 | all 6 | Empty | P2 | mixed | empty-state-14/12 | REUI-015 | [empty-state-14](https://reui.io/preview/base/empty-state-14) | 1:1/partial |
| CHANGE-031 | all 6 | Forms | P2 | mixed sheets | sheet-8/1/9 | REUI-017 | [sheet-8](https://reui.io/preview/base/sheet-8) | partial |
| CHANGE-032 | MM | Chrome | P0 | unused NavUser | app-shell-1 NavUser | REUI-012 | [app-shell-1](https://reui.io/preview/base/app-shell-1) | partial |
| CHANGE-033 | MM | Chrome | P1 | SidebarRail | remove | REUI-010 | [app-shell-12](https://reui.io/preview/base/app-shell-12) | partial |
| CHANGE-034 | MM | Chrome | P0 | 16rem | 240px | REUI-010 | [app-shell-12](https://reui.io/preview/base/app-shell-12) | partial |
Все UI-related CHANGE имеют Ref ID. CHANGE-023 ids — доменный список; chrome reference всё равно REUI-010.
### Execution log (2026-08-17)
- [x] CHANGE-001 Auth SystemMonitor
- [x] CHANGE-002 MM chrome identity
- [x] CHANGE-003 Vite chrome verification (240px, h-12, skip-link, main padding)
- [x] CHANGE-004 IconTile CLI во всех 6
- [x] CHANGE-005 KpiStatGrid IconTile DNA
- [x] CHANGE-006 CFDM *KpiStats rename
- [x] CHANGE-007 FW AgentPlatformIcon IconTile
- [x] CHANGE-008 MM dashboard stats-12 + IconTile + Frame panels (`OpsPanel`)
- [x] CHANGE-009 VPS vps detail Frame
- [x] CHANGE-010 VPS resources Frame
- [x] CHANGE-011 VPS renewals Frame
- [x] CHANGE-012 VPS charts Frame
- [x] CHANGE-013 VPS spaces ResourcePage
- [x] CHANGE-014 VPS app-switcher editor Frame
- [x] CHANGE-015 EvoBGP modules/new Frame
- [x] CHANGE-016 EvoBGP empty IconStack
- [x] CHANGE-017 EvoBGP sparkline Frame (chart-15 DNA)
- [x] CHANGE-018 MM DataPageCard + remaining ops Card → Frame/`OpsPanel`
- [x] CHANGE-019 EvoBGP SettingsShell wired
- [x] CHANGE-020 FW single-page settings rule
- [x] CHANGE-021 Auth ResourcePage stub explicit
- [x] CHANGE-022 Auth `/apps` QuickActionGrid
- [x] CHANGE-023 App Switcher ids + `dns`
- [x] CHANGE-024 CFDM AGENTS.md + ui-surface.ts
- [x] CHANGE-025 VPS kanban list disk-accurate
- [x] CHANGE-026 kpi-hybrid IconTile rule
- [x] CHANGE-027 MM SoT Frame+DataGrid
- [x] CHANGE-028 settings-7 не target; live EvoBGP settings → `@/components/settings/*`
- [x] CHANGE-029 VPS QA order KEEP exception (contract)
- [x] CHANGE-030 Empty + IconStack во всех 6
- [x] CHANGE-031 Sheet create/edit + AlertDialog confirm (Vite ConfirmDialog; MM `@shadcn/alert-dialog`; EvoBGP FormDrawer=Sheet)
- [x] CHANGE-032 MM NavUser segmented
- [x] CHANGE-033 MM SidebarRail removed
- [x] CHANGE-034 MM sidebar 240px
RULE-CHANGE-001…006 — DONE (rules files). RULE-CHANGE-007…010 в §2 — опечатка покрытия (в §16 только 001006).
---
## 18. Implementation Roadmap
### Phase 0 — Discovery
- [x] Tasks: этот файл.
- [x] DoD: Coverage 100%, Consistency Gate PASS.
- [x] Аудит зафиксирован 2026-08-17.
### Phase 1 — Chrome identity (все 6)
- [x] CHANGE-001, 002, 003, 032, 033, 034.
- [x] DoD: Chrome Identity Spec 1:1 (sidebar 240px, Switcher, AppsMenu→monitor, NavUser segmented).
### Phase 2 — Shared rules + IconTile + Frame lock
- [x] CHANGE-004, 026, 027, 028, 024, 025.
- [x] DoD: `icon-tile` installed; rules updated; MM SoT Frame.
### Phase 3 — Kit DNA
- [x] CHANGE-005, 006, 007, 008, 021, 022.
- [x] DoD: KpiStatGrid/QuickActionGrid/AgentPlatformIcon на IconTile; Auth `/apps` kit.
### Phase 4 — Page-level Card → Frame
- [x] CHANGE-009018.
- [x] DoD: нет Card ops-shell на затронутых страницах; CRUD preserved. MM inner panels → `OpsPanel`.
### Phase 5 — States + sheets
- [x] CHANGE-016, 019, 020, 030, 031.
- [x] DoD: empty/loading/error Frame-friendly; sheets + AlertDialog.
### Phase 6 — Responsive + a11y verification
- [x] Keyboard skip-link, focus ring tokens, mobile sidebar Sheet RU labels, `aria-expanded`, live regions QueryState.
- [x] DoD: Acceptance checklist §19.
---
## 19. Acceptance Checklist
### Visual consistency
- [x] Typography unified (theme tokens, не custom fonts per app).
- [x] Buttons unified (shadcn Button hierarchy).
- [x] Inputs unified (Field).
- [x] Forms unified (Sheet + Field).
- [x] Cards unified → **Frame** на ops (Card не ops-shell). MM: `DataPageCard` + `OpsPanel`.
- [x] Tables unified → DataGrid.
- [x] Navigation unified → Chrome Identity Spec.
- [x] Spacing unified → `gap-4 md:gap-6`, main padding.
- [x] States unified (empty IconStack, QueryState live regions).
- [x] Responsive behavior consistent (Sidebar/Sheet collapse + skip-link).
### Functional regression
- [x] Add/Create preserved.
- [x] Delete preserved.
- [x] Edit preserved.
- [x] Save/Cancel preserved.
- [x] Search preserved (⌘K; MM command palette).
- [x] Filter preserved.
- [x] Sort preserved.
- [x] Pagination preserved.
- [x] Navigation preserved.
- [x] Permissions preserved (Auth apps allow-list).
- [x] Form behavior preserved.
- [x] VPS topology canvas preserved (domain, не chrome).
- [x] MM terminal / network-map preserved.
### Accessibility
- [x] Keyboard navigation (skip-link «К содержимому», sidebar trigger).
- [x] Focus states (token `ring-ring`).
- [x] Semantic HTML (один `<main>` = SidebarInset `#main-content`).
- [x] Accessible labels (`aria-hidden` on decorative IconTile per docs).
- [x] Accessible names (`aria-label` AppsMenu / monitor / theme / sidebar).
- [x] Error states (`role="alert"` / ReUI Alert).
- [x] Disabled states (существующие Button/Field disabled).
- [x] Contrast (semantic tokens).
### REUI PRO
- [x] Current official documentation reviewed ([docs](https://reui.io/docs), [llms.txt](https://reui.io/llms.txt)).
- [x] MCP/Skills reviewed (`user-reui`, skill `668fb463eb`).
- [x] Every proposed component verified via `get_block` / `get_component` / `get_example`.
- [x] Every UI-related CHANGE has a REUI PRO Ref ID.
- [x] Every Ref ID has an official URL.
- [x] URLs point to specific relevant documentation.
- [x] Every 1:1 mapping verified (only where structure matches).
- [x] Partial mappings explicitly marked.
- [x] Missing / forbidden components marked (settings-7, empty-state-11).
- [x] No fabricated components / variants / URLs / references.
---
## 20. Risks / Unknowns
| Risk | Level | Notes |
|---|---|---|
| Data Grid TanStack v9 docs vs installed `^8.21.x` | High | Не апгрейдить kit в том же PR, что chrome. `<uncertain>` полный changelog v8→v9. |
| MM DataPageCard blast radius (много page.tsx) | High | Менять обёртку, не бизнес-логику. |
| MM Next vs Vite chrome port | High | Эквивалент UX, не copy-paste Vite files blindly. |
| MM hardcoded footer user | Medium | Нужен реальный user source; `<uncertain>` есть ли auth user в MM UI. |
| Shared kit npm package | Out of scope | Не сливать `@cfdm/ui` / `@evobgp/ui` / … |
| Auth login auth-18 vs rules auth-13 | Low | KEEP auth-18; rules cite auth-13 — documented exception. |
| VPS dashboard QA order | Low | KEEP exception unless product says otherwise. |
| CFDM Card-free claim | Low | grep 0 в `apps/web`; `packages/ui` Card primitive остаётся для shadcn internals. |
| Pixel-perfect main padding MM | Low | Phase 1+6: `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5`; skip-link. |
| EvoBGP RHF unused | `<uncertain>` | WEB-05 vs src; не ломать формы ради RHF. |
| FW stepper unused | Low | Не удалять в этом плане без usage audit. |
**Не трогать (technical constraints):** API contracts, SSO/OIDC, permissions, TanStack vs Next routing, Go vs Fastify, package names, CRUD behaviour.
---
## 21. Final Consistency Gate
| Requirement | Status | Evidence |
|---|---|---|
| All workspace projects discovered | PASS | 6 git roots in workspace |
| All discovered UI projects audited | PASS | §§712 |
| All audited projects represented in final plan | PASS | coverage table + CHANGE matrix |
| Coverage = 100% | PASS | 6/6/6 |
| Current REUI PRO documentation reviewed | PASS | docs, llms.txt, MCP get_block/get_component 2026-08-17 |
| MCP/Skills reviewed | PASS | user-reui ready; skill 668fb463eb |
| Project rules reviewed | PASS | §56 |
| UX/UI rules evaluated | PASS | KEEP/MODIFY/REPLACE/NEW |
| Every proposed REUI component verified | PASS | registry §15 |
| Every UI CHANGE has REUI PRO Ref ID | PASS | matrix; CHANGE-023 chrome via REUI-010 |
| Every Ref ID has official documentation URL | PASS | markdown links |
| URLs point to specific REUI PRO references | PASS | preview/docs pages, not homepage |
| Every 1:1 mapping verified | PASS | only Frame/IconTile/Filters/Badge/etc. where MCP matches |
| Partial mappings marked | PASS | stats-12, shells, sheets, data-grid v8 |
| Missing REUI components marked | PASS | settings-7/empty-state-11 not target |
| No fabricated workspace data | PASS | real paths; `<not_found>` CFDM AGENTS.md; `<uncertain>` noted |
| No fabricated REUI PRO data | PASS | MCP-confirmed URLs only |
| Existing functionality preserved | PASS | each CHANGE Preserve + § constraints |
| Accessibility reviewed | PASS | IconTile a11y from official API; checklist |
| Responsive behavior reviewed | PASS | existing sidebar/sheet; Phase 6 verify |
| Target UX/UI rules defined | PASS | TARGET-001…010 + Chrome spec |
| Implementation plan complete | PASS | Phases 06 executed 2026-08-17 |
| Production/source code implemented | PASS | все 6 git roots; CHANGE-001…034 |
---
## Definition of Done
### Аудит (Phase 0)
- [x] Все 6 проектов обнаружены и проанализированы.
- [x] Coverage 100%.
- [x] Chrome Identity Spec зафиксирован как обязательный 1:1.
- [x] REUI registry + CHANGE matrix с прямыми URL.
- [x] Final Consistency Gate пройден.
- [x] Создан `c:\Users\shats\Dev\vps-tracker\UX-UI-PLAN.md`.
### Implementation (Phases 16) — 2026-08-17
- [x] CHANGE-001…034 выполнены (execution log §17).
- [x] Chrome Identity Spec во всех 6.
- [x] IconTile + hybrid KPI/QA kit.
- [x] Ops Card → Frame (VPS/EvoBGP/MM `OpsPanel`).
- [x] Empty IconStack; Sheet + AlertDialog.
- [x] a11y: skip-link, один `main`, live regions.
- [x] Acceptance checklist §19 отмечен.
**План закрыт.** Коммиты — по отдельному запросу.
+77 -59
View File
@@ -17,13 +17,13 @@ import {
type ChartConfig,
} from '@cfdm/ui/components/chart'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@cfdm/ui/lib/utils'
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
@@ -60,6 +60,36 @@ function ChartEmpty({ message }: { message: string }) {
)
}
/** Chart-15 Frame shell. Preview: https://reui.io/preview/base/chart-15 */
function ChartShell({
title,
description,
action,
className,
children,
}: {
title: string
description?: ReactNode
action?: ReactNode
className?: string
children: ReactNode
}) {
return (
<Frame dense spacing="sm" className={cn('w-full', className)}>
<FrameHeader className={action ? 'flex-row items-start justify-between gap-3' : undefined}>
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</div>
{action}
</FrameHeader>
<FramePanel className="flex min-h-72 flex-1 flex-col items-center justify-center">
{children}
</FramePanel>
</Frame>
)
}
const EXPENSE_CONFIG: ChartConfig = {
expense: { label: 'Расход', color: 'var(--chart-1)' },
}
@@ -104,12 +134,11 @@ export function MonthlyExpenseChart({
.slice(0, 10)
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description ?? `Топ-10 по monthly rate, в ${baseCurrency}`}</CardDescription>
</CardHeader>
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
<ChartShell
className={className}
title={title}
description={description ?? `Топ-10 по monthly rate, в ${baseCurrency}`}
>
{data.length === 0 ? (
<ChartEmpty message="Нет данных для графика" />
) : (
@@ -123,8 +152,7 @@ export function MonthlyExpenseChart({
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
</ChartShell>
)
}
@@ -168,12 +196,11 @@ export function PaymentsPieChart({
}, [payments, baseCurrency, ratesData])
return (
<Card className={className}>
<CardHeader>
<CardTitle>Платежи по типам</CardTitle>
<CardDescription>Структура в {baseCurrency}</CardDescription>
</CardHeader>
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
<ChartShell
className={className}
title="Платежи по типам"
description={`Структура в ${baseCurrency}`}
>
{data.length === 0 ? (
<ChartEmpty message="Нет данных о платежах" />
) : (
@@ -196,8 +223,7 @@ export function PaymentsPieChart({
</PieChart>
</ChartContainer>
)}
</CardContent>
</Card>
</ChartShell>
)
}
@@ -244,11 +270,11 @@ function DashboardMonthlyBarChart({
const hasData = data.some((row) => row.amount > 0)
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
<CardAction>
<ChartShell
className={className}
title={title}
description={description}
action={
<div className="flex flex-wrap items-center gap-2">
<SelectField
size="sm"
@@ -268,9 +294,8 @@ function DashboardMonthlyBarChart({
options={years.map((y) => ({ value: String(y), label: String(y) }))}
/>
</div>
</CardAction>
</CardHeader>
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
}
>
{!hasData ? (
<ChartEmpty message="Нет данных за выбранный период" />
) : (
@@ -289,8 +314,7 @@ function DashboardMonthlyBarChart({
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
</ChartShell>
)
}
@@ -366,11 +390,11 @@ export function DashboardExpensesChart({
: 'Списания и платежи за VPS по месяцам'
return (
<Card className={className}>
<CardHeader>
<CardTitle>Расходы</CardTitle>
<CardDescription>{description}</CardDescription>
<CardAction>
<ChartShell
className={className}
title="Расходы"
description={description}
action={
<div className="flex flex-wrap items-center gap-2">
<SelectField
size="sm"
@@ -390,9 +414,8 @@ export function DashboardExpensesChart({
options={years.map((y) => ({ value: String(y), label: String(y) }))}
/>
</div>
</CardAction>
</CardHeader>
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
}
>
{!hasData ? (
<ChartEmpty message="Нет данных за выбранный период" />
) : (
@@ -411,8 +434,7 @@ export function DashboardExpensesChart({
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
</ChartShell>
)
}
@@ -443,12 +465,11 @@ export function MonthlyTrendChart({
const trendConfig: ChartConfig = { amount: { label: 'Платежи', color: 'var(--chart-3)' } }
return (
<Card className={className}>
<CardHeader>
<CardTitle>Динамика платежей</CardTitle>
<CardDescription>Последние 12 месяцев, {baseCurrency}</CardDescription>
</CardHeader>
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
<ChartShell
className={className}
title="Динамика платежей"
description={`Последние 12 месяцев, ${baseCurrency}`}
>
{data.length === 0 ? (
<ChartEmpty message="Нет данных за выбранный период" />
) : (
@@ -462,8 +483,7 @@ export function MonthlyTrendChart({
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
</ChartShell>
)
}
@@ -513,12 +533,11 @@ export function ProjectExpenseChart({
}, [data])
return (
<Card className={className}>
<CardHeader>
<CardTitle>Расходы по проектам (мес)</CardTitle>
<CardDescription>Активные VPS, в {baseCurrency}</CardDescription>
</CardHeader>
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
<ChartShell
className={className}
title="Расходы по проектам (мес)"
description={`Активные VPS, в ${baseCurrency}`}
>
{data.length === 0 ? (
<ChartEmpty message="Нет данных для графика" />
) : (
@@ -544,7 +563,6 @@ export function ProjectExpenseChart({
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
</ChartShell>
)
}
+3 -3
View File
@@ -1,7 +1,7 @@
/**
* ReUI Empty + IconStack — adapted from empty-state-12.
* Preview: https://reui.io/preview/base/empty-state-12
* Docs: https://reui.io/blocks
* ReUI Empty + IconStack — Frame-friendly empty-state-14 / empty-state-12.
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
* Docs: https://reui.io/docs/components/base/icon-stack
*/
import { InboxIcon, type LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack'
@@ -5,12 +5,12 @@ import { z } from 'zod'
import { appSwitcherConfigSchema } from '@cfdm/shared/contracts/app-switcher'
import { Button } from '@cfdm/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { FormField } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { LoadingButton } from '@/components/loading-button'
import { SettingsCard } from '@/components/reui-kit/settings-card'
import { APP_SWITCHER_ICONS, type AppSwitcherIconName } from '@/lib/app-switcher-config'
const ICON_OPTIONS = (Object.keys(APP_SWITCHER_ICONS) as AppSwitcherIconName[]).map((icon) => ({
@@ -39,14 +39,12 @@ export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitch
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'apps' })
return (
<Card>
<CardHeader>
<CardTitle>Связанные приложения</CardTitle>
<CardDescription>URL для переключателя в sidebar и deep links</CardDescription>
</CardHeader>
<CardContent>
<SettingsCard
title="Связанные приложения"
description="URL для переключателя в sidebar и deep links"
>
<form
className="flex flex-col gap-4"
className="flex flex-col gap-4 px-5 py-4"
onSubmit={(e) => void form.handleSubmit(onSave)(e)}
>
<FieldGroup>
@@ -110,7 +108,6 @@ export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitch
Сохранить приложения
</LoadingButton>
</form>
</CardContent>
</Card>
</SettingsCard>
)
}
+7 -3
View File
@@ -59,6 +59,7 @@ import {
permissionForPath,
} from '@/lib/auth'
import { SpaceProvider, useSpaceId } from '@/lib/space'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
interface NavItem {
to: string
@@ -175,6 +176,9 @@ export function AppShell({ children }: { children: ReactNode }) {
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
@@ -220,7 +224,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavUser />
</SidebarFooter>
</Sidebar>
<SidebarInset>
<SidebarInset id="main-content">
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
@@ -251,9 +255,9 @@ export function AppShell({ children }: { children: ReactNode }) {
<SystemMonitorPopover />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
<SpaceScopedMain>{children}</SpaceScopedMain>
</main>
</div>
</SidebarInset>
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
</SidebarProvider>
+20 -14
View File
@@ -32,23 +32,29 @@ export function QueryState<T>({
children,
}: QueryStateProps<T>) {
if (isLoading) {
return <>{skeleton ?? <DefaultSkeleton />}</>
return (
<div role="status" aria-live="polite" aria-busy="true">
{skeleton ?? <DefaultSkeleton />}
</div>
)
}
if (isError) {
return (
<EmptyState
icon={<AlertCircle className="size-8" />}
title="Ошибка загрузки"
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
action={
onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCwIcon data-icon="inline-start" />
Повторить
</Button>
) : null
}
/>
<div role="alert">
<EmptyState
icon={<AlertCircle className="size-8" />}
title="Ошибка загрузки"
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
action={
onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCwIcon data-icon="inline-start" />
Повторить
</Button>
) : null
}
/>
</div>
)
}
if (empty || data == null) {
@@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { cn } from '@cfdm/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { IconTile } from '@/components/reui/icon-tile'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
@@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{item.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
item.iconClassName ?? DEFAULT_ICON_CLASS,
)}
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
<ItemMedia variant="icon" className="size-auto">
{item.icon}
</ItemMedia>
</Item>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
@@ -8,7 +8,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@cfdm/ui/lib/utils'
import { kpiCols } from './kpi-cols'
@@ -35,16 +35,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
action.iconClassName ?? DEFAULT_ICON_CLASS,
)}
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
>
<ItemMedia variant="icon" className="size-auto">
{action.icon}
</ItemMedia>
</Item>
{action.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
+125
View File
@@ -0,0 +1,125 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
/**
* CSS variable architecture:
*
* The root owns four variables so every part of the tile stays in proportion
* and stays overridable from a single `className`:
*
* --icon-tile-size tile width/height
* --icon-tile-icon-size glyph size applied to child svgs
* --icon-tile-radius corner radius (also drives the nested inner card)
* --icon-tile-inset gap between the outer ring and the inner card
*
* The `frame` and `soft` variants paint their inner card with an `::after`
* pseudo element instead of a wrapper node. `isolate` makes the root a stacking
* context, so the negative z-index pseudo paints above the root background but
* below the in-flow icon - no extra DOM, and `render` composition keeps working.
*
* Tone: `soft` and `solid` derive every fill and border from `currentColor`, so
* a single text color class (e.g. `text-success`) retints the whole tile. They
* default to `text-primary`; override it to recolor without touching internals.
*/
const iconTileVariants = cva(
[
"relative inline-flex shrink-0 items-center justify-center align-middle",
"size-(--icon-tile-size) rounded-(--icon-tile-radius)",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
],
{
variants: {
variant: {
/** Plain bordered surface. The quiet default for list rows and toolbars. */
outline: "border border-border bg-background dark:bg-input/32",
/** Raised muted fill with a background-colored ring. Reads as a physical chip. */
elevated:
"border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border",
/**
* Tinted double container: an opacity-filled outer ring with no border
* around a bordered inner card, all derived from `currentColor`. The
* quiet, colorful sibling of `frame`. Retint with a text color class.
*/
soft: [
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
"after:border after:border-current/20 after:bg-current/5",
],
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
solid: "bg-primary text-primary-foreground",
/** Double container - a muted ring around an inset card, matching Frame. */
frame: [
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
"after:border after:border-border after:bg-card after:shadow-xs",
],
},
size: {
xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]",
sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]",
default:
"[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]",
lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]",
xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]",
},
/**
* `default`: active style radius. `full`: circular.
* The plain value is the fallback outside a style scope; the `style-*`
* tokens win by specificity inside one, and survive the registry
* transform as the single resolved value per generated style.
*
* Each style token is clamped to a fraction of the tile. A flat radius
* is a circle once it reaches half the box, so the soft styles used to
* render `xs` (24px) and `sm` (32px) as plain circles and swallow the
* `full` variant's meaning. Clamping keeps one corner ratio at every
* size instead, so a tile still reads as the same shape when it scales.
* Luma and Rhea take the gentler quarter ratio; Maia stays at a third.
*/
radius: {
default:
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
full: "[--icon-tile-radius:calc(infinity*1px)]",
},
},
defaultVariants: {
variant: "outline",
size: "default",
radius: "default",
},
}
)
interface IconTileProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof iconTileVariants>["variant"]
size?: VariantProps<typeof iconTileVariants>["size"]
radius?: VariantProps<typeof iconTileVariants>["radius"]
}
function IconTile({
className,
variant = "outline",
size = "default",
radius = "default",
render,
...props
}: IconTileProps) {
const defaultProps = {
"data-slot": "icon-tile",
"data-variant": variant,
"data-size": size,
className: cn(iconTileVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { IconTile, iconTileVariants, type IconTileProps }
+5 -1
View File
@@ -3,7 +3,7 @@
*
* Ops / list / dashboard / detail / settings: **Frame** only.
* KPI: horizontal compact hybrid via `reui-kit/KpiStatGrid`
* (icon left + label/Badge + value ± variant).
* (`IconTile` elevated `size-10.5` + label/Badge + value ± variant).
* Lists: **data-grid-filtering-2** via `reui-kit/ResourcePage`.
*
* @see https://reui.io/preview/base/stats-12
@@ -12,5 +12,9 @@
*/
export const UI_SURFACE = 'frame' as const
/** Skip link — keyboard: first Tab. */
export const SKIP_TO_CONTENT_CLASS =
'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2'
/** Grid for horizontal compact hybrid KPI rows (36 tiles). */
export const kpiStatGridClassName = '@container w-full'
+14 -9
View File
@@ -10,7 +10,12 @@ import { QueryState } from '@/components/query-state'
import { KpiStatGrid } from '@/components/reui-kit'
import { KpiStatGridSkeleton } from '@/components/skeletons'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import { SelectField } from '@/components/select-field'
@@ -168,14 +173,14 @@ function RenewalsPage() {
]}
/>
{grouped.map(([weekLabel, weekItems]) => (
<Card key={weekLabel}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Frame key={weekLabel} dense spacing="sm">
<FrameHeader>
<FrameTitle className="flex items-center gap-2 text-base">
<CalendarIcon className="size-4" />
{weekLabel}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
</FrameTitle>
</FrameHeader>
<FramePanel className="flex flex-col gap-2">
{weekItems.map((item) => (
<div
key={item.id}
@@ -197,8 +202,8 @@ function RenewalsPage() {
</div>
</div>
))}
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
)}
+15 -9
View File
@@ -12,7 +12,13 @@ import {
ChartTooltipContent,
type ChartConfig,
} from '@cfdm/ui/components/chart'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { Tooltip as RechartsTooltip } from 'recharts'
@@ -103,12 +109,12 @@ function ResourcesPage() {
]}
/>
<Card className="flex flex-col">
<CardHeader>
<CardTitle>Ресурсы по хостерам</CardTitle>
<CardDescription>Только активные VPS</CardDescription>
</CardHeader>
<CardContent className="flex min-h-80 flex-1 flex-col items-center justify-center">
<Frame dense spacing="sm" className="flex flex-col">
<FrameHeader>
<FrameTitle>Ресурсы по хостерам</FrameTitle>
<FrameDescription>Только активные VPS</FrameDescription>
</FrameHeader>
<FramePanel className="flex min-h-80 flex-1 flex-col items-center justify-center">
{chartData.length === 0 ? (
<EmptyState title="Нет данных для графика" className="min-h-80 w-full flex-1 py-0" />
) : (
@@ -124,8 +130,8 @@ function ResourcesPage() {
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
</FramePanel>
</Frame>
</>
)
}}
+156 -165
View File
@@ -12,21 +12,14 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import { AutoCompleteInput } from '@/components/auto-complete-input'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { SettingsCard } from '@/components/reui-kit/settings-card'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { api } from '@/lib/api-client'
import { useSpaceId } from '@/lib/space'
import {
@@ -181,6 +174,140 @@ function SpacesPage() {
onError: (e: Error) => toast.error(e.message),
})
const memberColumns: DataGridColumn<MemberRow>[] = useMemo(
() => [
{
key: 'user',
header: 'Пользователь',
cell: (m) => (
<div className="flex flex-col gap-0.5">
<span className="text-sm">{userLabelById.get(m.userId) ?? m.userId}</span>
{!userLabelById.has(m.userId) ? (
<span className="text-muted-foreground font-mono text-xs">{m.userId}</span>
) : null}
</div>
),
},
{ key: 'role', header: 'Роль', cell: (m) => m.role },
{
key: 'actions',
header: '',
className: 'w-36',
cell: (m) =>
canAdmin && m.role !== 'owner' ? (
<ConfirmDialog
title="Отозвать доступ?"
description="Пользователь потеряет доступ к этому пространству."
confirmLabel="Отозвать"
destructive
onConfirm={() => removeMutation.mutate(m.userId)}
trigger={
<Button variant="outline" size="sm">
Отозвать
</Button>
}
/>
) : null,
},
],
[canAdmin, removeMutation, userLabelById],
)
const trashColumns: DataGridColumn<(typeof trash)[number]>[] = useMemo(
() => [
{ key: 'name', header: 'Название', cell: (s) => s.name },
{
key: 'deletedAt',
header: 'Удалено',
cell: (s) => (
<span className="text-muted-foreground text-sm">
{s.deletedAt ? new Date(s.deletedAt).toLocaleString('ru-RU') : '—'}
</span>
),
},
{
key: 'actions',
header: '',
className: 'w-64',
cell: (s) => (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => restoreMutation.mutate(s.id)}
disabled={restoreMutation.isPending}
>
Восстановить
</Button>
<ConfirmDialog
title="Удалить навсегда?"
description="Безвозвратно: все данные пространства будут уничтожены."
confirmLabel="Удалить навсегда"
destructive
onConfirm={() => purgeMutation.mutate(s.id)}
trigger={
<Button size="sm" variant="destructive">
Удалить навсегда
</Button>
}
/>
</div>
),
},
],
[purgeMutation, restoreMutation],
)
const addMemberForm = canAdmin ? (
<div className="flex w-full flex-col gap-3 md:flex-row md:items-end">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Label>Пользователь</Label>
<AutoCompleteInput
value={
selectedUserId
? (userLabelById.get(selectedUserId) ?? userQuery)
: userQuery
}
onChange={(v) => {
const match = userOptions.find(
(o) => o.value === v || o.label === v,
)
if (match) {
setSelectedUserId(match.value)
setUserQuery(match.label)
} else {
setSelectedUserId('')
setUserQuery(v)
}
}}
options={userOptions}
placeholder="Имя или email…"
allowFreeText={false}
emptyText="Пользователи не найдены"
/>
</div>
<div className="flex w-full flex-col gap-2 md:w-40">
<Label>Роль</Label>
<Select value={role} onValueChange={(v) => setRole(v ?? 'member')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="member">member</SelectItem>
<SelectItem value="viewer">viewer</SelectItem>
</SelectContent>
</Select>
</div>
<Button
disabled={!selectedUserId.trim() || addMutation.isPending}
onClick={() => addMutation.mutate()}
>
Добавить
</Button>
</div>
) : null
return (
<PageShell>
<PageHeader
@@ -243,170 +370,34 @@ function SpacesPage() {
</SettingsCard>
) : null}
<QueryState
data={membersQuery.data as MemberRow[] | undefined}
<ResourcePage
title="Участники"
description="Доступ к текущему пространству"
columns={columnDefFromDataGrid(memberColumns)}
data={membersQuery.data ?? []}
getRowId={(m) => `${m.spaceId}-${m.userId}`}
pagination={false}
pinLastColumn
isLoading={membersQuery.isLoading}
isError={membersQuery.isError}
error={membersQuery.error}
error={membersQuery.error as Error | null}
onRetry={() => void membersQuery.refetch()}
empty={Boolean(membersQuery.data && membersQuery.data.length === 0)}
toolbarExtra={addMemberForm}
emptyTitle="Нет участников"
emptyDescription="Добавьте пользователя по имени или email"
>
{(members) => (
<SettingsCard title="Участники" description="Доступ к текущему пространству">
{canAdmin ? (
<div className="flex flex-col gap-3 border-b border-border/40 px-5 py-4 md:flex-row md:items-end">
<div className="flex flex-1 flex-col gap-2">
<Label>Пользователь</Label>
<AutoCompleteInput
value={
selectedUserId
? (userLabelById.get(selectedUserId) ?? userQuery)
: userQuery
}
onChange={(v) => {
const match = userOptions.find(
(o) => o.value === v || o.label === v,
)
if (match) {
setSelectedUserId(match.value)
setUserQuery(match.label)
} else {
setSelectedUserId('')
setUserQuery(v)
}
}}
options={userOptions}
placeholder="Имя или email…"
allowFreeText={false}
emptyText="Пользователи не найдены"
/>
</div>
<div className="flex w-full flex-col gap-2 md:w-40">
<Label>Роль</Label>
<Select value={role} onValueChange={(v) => setRole(v ?? 'member')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="member">member</SelectItem>
<SelectItem value="viewer">viewer</SelectItem>
</SelectContent>
</Select>
</div>
<Button
disabled={!selectedUserId.trim() || addMutation.isPending}
onClick={() => addMutation.mutate()}
>
Добавить
</Button>
</div>
) : null}
/>
<Table>
<TableHeader>
<TableRow>
<TableHead>Пользователь</TableHead>
<TableHead>Роль</TableHead>
<TableHead className="w-36" />
</TableRow>
</TableHeader>
<TableBody>
{members.map((m) => (
<TableRow key={`${m.spaceId}-${m.userId}`}>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-sm">
{userLabelById.get(m.userId) ?? m.userId}
</span>
{!userLabelById.has(m.userId) ? (
<span className="text-muted-foreground font-mono text-xs">
{m.userId}
</span>
) : null}
</div>
</TableCell>
<TableCell>{m.role}</TableCell>
<TableCell>
{canAdmin && m.role !== 'owner' ? (
<ConfirmDialog
title="Отозвать доступ?"
description="Пользователь потеряет доступ к этому пространству."
confirmLabel="Отозвать"
destructive
onConfirm={() => removeMutation.mutate(m.userId)}
trigger={
<Button variant="outline" size="sm">
Отозвать
</Button>
}
/>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SettingsCard>
)}
</QueryState>
<SettingsCard
<ResourcePage
title="Корзина"
description="Удалённые пространства можно восстановить или удалить навсегда"
>
{trash.length === 0 ? (
<p className="text-muted-foreground px-5 py-4 text-sm">Корзина пуста</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Удалено</TableHead>
<TableHead className="w-64" />
</TableRow>
</TableHeader>
<TableBody>
{trash.map((s) => (
<TableRow key={s.id}>
<TableCell>{s.name}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{s.deletedAt
? new Date(s.deletedAt).toLocaleString('ru-RU')
: '—'}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => restoreMutation.mutate(s.id)}
disabled={restoreMutation.isPending}
>
Восстановить
</Button>
<ConfirmDialog
title="Удалить навсегда?"
description="Безвозвратно: все данные пространства будут уничтожены."
confirmLabel="Удалить навсегда"
destructive
onConfirm={() => purgeMutation.mutate(s.id)}
trigger={
<Button size="sm" variant="destructive">
Удалить навсегда
</Button>
}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</SettingsCard>
columns={columnDefFromDataGrid(trashColumns)}
data={trash}
getRowId={(s) => s.id}
pagination={false}
pinLastColumn
emptyTitle="Корзина пуста"
emptyDescription="Удалённых пространств нет"
/>
</div>
</PageShell>
)
+44 -65
View File
@@ -18,8 +18,13 @@ import { StatusBadge } from '@/components/status-badge'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { getPaidUntilDate } from '@/lib/paid-until'
@@ -58,6 +63,30 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
)
}
function DetailFrame({
title,
icon,
children,
className,
}: {
title: string
icon?: React.ReactNode
children: React.ReactNode
className?: string
}) {
return (
<Frame dense spacing="sm" className={className}>
<FrameHeader>
<FrameTitle className="flex items-center gap-2 text-base">
{icon}
{title}
</FrameTitle>
</FrameHeader>
<FramePanel className="flex flex-col gap-3">{children}</FramePanel>
</Frame>
)
}
function VpsDetailPage() {
const { vpsId } = Route.useParams()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
@@ -175,54 +204,29 @@ function VpsDetailPage() {
{row.environment ? <Badge variant="outline">{row.environment}</Badge> : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<GlobeIcon className="size-4" />
Сеть
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<DetailFrame title="Сеть" icon={<GlobeIcon className="size-4" />}>
<InfoRow label="IP" value={row.ip || '—'} />
<InfoRow label="DNS" value={row.dns || '—'} />
<InfoRow label="IPv6" value={(row as Vps & { ipv6?: string }).ipv6 || '—'} />
<InfoRow label="SSH порт" value={(row as Vps & { sshPort?: number }).sshPort ?? 22} />
<InfoRow label="Локация" value={[row.country, row.city].filter(Boolean).join(', ') || '—'} />
<InfoRow label="Дата-центр" value={row.datacenter || '—'} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CpuIcon className="size-4" />
Ресурсы
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
</DetailFrame>
<DetailFrame title="Ресурсы" icon={<CpuIcon className="size-4" />}>
<InfoRow label="vCPU" value={row.vcpu} />
<InfoRow label="RAM" value={`${row.ramGb} GB`} />
<InfoRow label="Disk" value={`${row.diskGb} GB`} />
<InfoRow label="ОС" value={(row as Vps & { os?: string }).os || '—'} />
<InfoRow label="Хостер" value={provider?.name || '—'} />
</CardContent>
</Card>
</DetailFrame>
{vpsDomains.length > 0 ? (
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="text-base">Домены (CFDM)</CardTitle>
</CardHeader>
<CardContent>
<DetailFrame title="Домены (CFDM)" className="md:col-span-2">
<VpsDomainsCell domains={vpsDomains} />
</CardContent>
</Card>
</DetailFrame>
) : null}
</div>
{customFieldRows.length > 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Дополнительные поля</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<DetailFrame title="Дополнительные поля">
{customFieldRows.map(({ def, value }) => (
<InfoRow
key={def.key}
@@ -230,20 +234,12 @@ function VpsDetailPage() {
value={formatCustomFieldValue(def, value)}
/>
))}
</CardContent>
</Card>
</DetailFrame>
) : null}
</TabsContent>
<TabsContent value="finance" className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CreditCardIcon className="size-4" />
Тариф
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<DetailFrame title="Тариф" icon={<CreditCardIcon className="size-4" />}>
<InfoRow label="Тип" value={tariffTypeLabel(row.tariffType)} />
<InfoRow
label="Ставка"
@@ -259,8 +255,7 @@ function VpsDetailPage() {
label="Оплачено до"
value={paidUntil ? paidUntil.toLocaleDateString('ru-RU') : '—'}
/>
</CardContent>
</Card>
</DetailFrame>
<ResourcePage
title="Связанные платежи"
columns={columnDefFromDataGrid(paymentColumns)}
@@ -272,28 +267,13 @@ function VpsDetailPage() {
</TabsContent>
<TabsContent value="notes">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<StickyNoteIcon className="size-4" />
Заметки
</CardTitle>
</CardHeader>
<CardContent>
<DetailFrame title="Заметки" icon={<StickyNoteIcon className="size-4" />}>
<p className="whitespace-pre-wrap text-sm">{row.notes?.trim() || 'Нет заметок'}</p>
</CardContent>
</Card>
</DetailFrame>
</TabsContent>
<TabsContent value="sync">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RefreshCwIcon className="size-4" />
Защита от перезаписи при синке
</CardTitle>
</CardHeader>
<CardContent>
<DetailFrame title="Защита от перезаписи при синке" icon={<RefreshCwIcon className="size-4" />}>
{overrides.length === 0 ? (
<p className="text-sm text-muted-foreground">Все поля обновляются из BILLmanager</p>
) : (
@@ -304,8 +284,7 @@ function VpsDetailPage() {
})}
</ul>
)}
</CardContent>
</Card>
</DetailFrame>
</TabsContent>
</Tabs>
)}
+3 -3
View File
@@ -28,7 +28,7 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | `settings-16` + SettingRow | https://reui.io/preview/base/settings-16 |
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
| Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 |
| Empty | `empty-state-14` (prefer) / `empty-state-12` | https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12 |
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
## Kit API (`reui-kit/`)
@@ -76,9 +76,9 @@ Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (
NavUser (footer): avatar + name/email; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1).
App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw`. Admin: portal `/admin/apps`.
App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw` · `dns`. Admin: portal `/admin/apps`.
QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid fills.
QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на `IconTile` `elevated` — без solid fills.
## System monitor
+2 -3
View File
@@ -68,9 +68,8 @@ function SheetContent({
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
<XIcon aria-hidden="true" />
<span className="sr-only">Закрыть</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
+10 -7
View File
@@ -196,8 +196,8 @@ function Sidebar({
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
<SheetTitle>Боковое меню</SheetTitle>
<SheetDescription>Навигация по приложению</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
@@ -256,7 +256,7 @@ function SidebarTrigger({
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar, open, openMobile, isMobile } = useSidebar()
return (
<Button
@@ -264,6 +264,9 @@ function SidebarTrigger({
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
type="button"
aria-label="Боковое меню"
aria-expanded={isMobile ? openMobile : open}
className={cn(className)}
onClick={(event) => {
onClick?.(event)
@@ -271,8 +274,8 @@ function SidebarTrigger({
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
<PanelLeftIcon aria-hidden="true" />
<span className="sr-only">Боковое меню</span>
</Button>
)
}
@@ -284,10 +287,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
aria-label="Боковое меню"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
title="Боковое меню"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",