Compare commits

...
12 Commits
Author SHA1 Message Date
Denozordec 653bc6cc91 fix(web): update Russian translations for various components
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 56s
CI / release (push) Successful in 4m4s
Обновлены переводы на русский язык для компонентов, включая карточки мониторинга, сетевые панели и настройки. Исправлены описания и метки для улучшения пользовательского интерфейса, а также добавлены новые элементы для поддержки локализации в компонентах, таких как DataGrid и ResourcePage.
2026-08-18 17:00:15 +07:00
Denozordec e0d695f2a4 refactor(web): unify settings navigation and update tab structure
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 40s
CI / web (push) Successful in 55s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m6s
Refactored the settings components to unify navigation between UI and BIRD settings. Updated tab structures to streamline access and improve user experience. Adjusted routing and search parameters to reflect the new tab organization, ensuring a cohesive interface. Removed legacy tenant settings references and enhanced the settings page layout for clarity and usability.
2026-08-18 16:30:10 +07:00
Denozordec dfc33dcb99 refactor(web): streamline dashboard and tab components for improved layout
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 40s
CI / web (push) Successful in 57s
CI / release (push) Successful in 4m24s
Refactored various dashboard components and tab structures to enhance layout and responsiveness. Adjusted class names to include 'min-w-0' for better overflow handling and improved alignment. Updated the ChartDonutMetric to include a tooltip for better data representation. Simplified the DashboardRecentJobsGrid and DashboardRecentRevisionsGrid by replacing DataGridSection with DataGridShell for consistency. Enhanced the OpsDashboard layout for better clarity and usability.
2026-08-18 16:03:52 +07:00
Denozordec 44f70443c3 refactor(web): improve layout and responsiveness of settings components
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 53s
CI / web (push) Successful in 1m16s
CI / release (push) Successful in 4m24s
Enhanced various settings components by adding 'min-w-0' class for better overflow handling and adjusting flex properties for improved alignment. Updated card and grid structures to ensure a cohesive user experience across the settings interface. Refined descriptions and layout in several settings tabs for better clarity and usability.
2026-08-18 01:19:42 +07:00
DenozordecandCursor 357ed4ce7b refactor(web): enhance layout and styling for dashboard components
CI / changes (push) Successful in 12s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 1m18s
CI / web (push) Successful in 2m0s
CI / release (push) Successful in 6m51s
Updated various dashboard components to improve layout and responsiveness. Adjusted class names to include 'min-w-0' for better handling of overflow and added flex properties to ensure proper alignment. Refined grid structures and skeleton loading states for a more cohesive user experience.

Co-authored-by: Cursor <[email protected]>
2026-08-18 00:51:10 +07:00
DenozordecandCursor 35e262b5ae fix(web): update Russian translations and improve UI components
CI / changes (push) Successful in 9s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 47s
CI / web (push) Successful in 1m4s
CI / release (push) Successful in 5m19s
Обновлены переводы на русский язык для различных компонентов, включая KPI, карточки сети и панели управления. Исправлены описания и метки для улучшения пользовательского интерфейса. Также добавлены новые элементы для поддержки локализации в компонентах, таких как DataGrid и ResourcePage.

Co-authored-by: Cursor <[email protected]>
2026-08-18 00:32:00 +07:00
DenozordecandCursor 8267141136 ci(gitea): point registry and remotes at git.shx.one
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 55s
CI / go (push) Successful in 1m4s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m17s
Перевести CI, semantic-release, bake и compose с git.shts.su на git.shx.one.

Co-authored-by: Cursor <[email protected]>
2026-08-17 16:43:39 +07:00
DenozordecandCursor 34a29ad337 ci(gitea): reuse HEAD tag when retrying a failed release
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 58s
CI / go (push) Successful in 1m6s
CI / bird2 (push) Successful in 14s
CI / release (push) Failing after 4m0s
При перезапуске job после уже созданного тега semantic-release не пишет .release-version — версию берём из v* на HEAD и публикуем образы.

Co-authored-by: Cursor <[email protected]>
2026-08-17 16:30:41 +07:00
DenozordecandCursor b04b27c215 refactor(web): align Frame KPI settings chrome and a11y
CI / changes (push) Successful in 5s
CI / openapi (push) Successful in 39s
CI / web (push) Successful in 55s
CI / go (push) Successful in 2m40s
CI / bird2 (push) Successful in 16s
CI / commitlint (push) Skipped
CI / release (push) Successful in 18s
Выровнять KPI на IconTile, вынести settings из settings-7, Sheet/AlertDialog и skip-link в общем chrome.

Co-authored-by: Cursor <[email protected]>
2026-08-17 15:36:54 +07:00
DenozordecandCursor 6a25a3d137 fix(web): parse string readiness checks on monitoring System tab
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 54s
CI / release (push) Successful in 3m59s
Исправляет ложные «Ошибки проверок»: GET /v1/ready отдаёт строки ok/memory, а не boolean. Уплотнён ReUI Frame/donut layout на вкладке Система.

Co-authored-by: Cursor <[email protected]>
2026-08-12 13:41:58 +07:00
DenozordecandCursor 32d9dc6acb feat(directories): edit communities and DoH via row actions menu
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 36s
CI / web (push) Successful in 55s
CI / release (push) Successful in 4m28s
В data-grid справочников добавлено меню «⋯» с пунктом «Редактировать»
и FormDrawer на PATCH /v1/communities/{id} и /v1/doh-profiles/{id}.

Co-authored-by: Cursor <[email protected]>
2026-08-12 12:42:01 +07:00
DenozordecandCursor fc4f234cc5 feat(directories): add create dialogs for communities and DoH profiles
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 41s
CI / web (push) Successful in 1m13s
CI / release (push) Successful in 4m35s
На странице Справочники добавлены кнопки «Добавить» и FormDrawer
для POST /v1/communities и /v1/doh-profiles. Доступ ограничен
bgp:directories:write (sessionCanWriteDirectories).

Co-authored-by: Cursor <[email protected]>
2026-08-12 12:24:10 +07:00
133 changed files with 2618 additions and 1617 deletions
+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 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), [`web-shadcn.mdc`](web-shadcn.mdc). Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc).
@@ -14,16 +14,17 @@ Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Ma
| Зона | Компонент / DNA | | Зона | Компонент / DNA |
|------|-----------------| |------|-----------------|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) | | 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` | | 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-*` на `bg-muted` | | Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) | | Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
Импорты UI: `@evobgp/ui/components/*`. Импорты UI: `@evobgp/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
## NEVER ## NEVER
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI - 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 - `card-35` как замена stats-12 hybrid KPI
- Копипаст ReUI block в route — adapt через `reui-kit/` - Копипаст ReUI block в route — adapt через `reui-kit/`
- Голый lucide `size-4` в name-cell без hybrid tile - Голый lucide `size-4` в name-cell без IconTile
+3 -2
View File
@@ -75,9 +75,9 @@ alwaysApply: true
## Установленные ReUI (apps/web) ## Установленные 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`, `stepper`
**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`, … **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`, …
@@ -110,6 +110,7 @@ REUI_LICENSE_KEY=
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit - Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
- Смешивать Card и Frame на ops-экране - Смешивать Card и Frame на ops-экране
- Ставить shadcn/ui blocks выше ReUI PRO - Ставить shadcn/ui blocks выше ReUI PRO
- **settings-7** как target (`surface: card`) — только [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16)
## Чеклист ## Чеклист
+3
View File
@@ -101,6 +101,9 @@ pnpm --filter @evobgp/web run build
**WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код. **WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код.
*Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`. *Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`.
**WEB-23** | NEVER | **settings-7** как target ([preview](https://reui.io/preview/base/settings-7) — `surface: card`). Новые settings-экраны — [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16) (`surface: frame`). Существующий `apps/web/src/components/settings/*` на DNA settings-7 **не** копировать как эталон; миграция — отдельный CHANGE.
*Проверка:* review MCP `get_block` / cite; нет новых экранов с settings-7.
--- ---
## Documentation Sync (Web) ## Documentation Sync (Web)
+8 -6
View File
@@ -28,6 +28,8 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются. Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
Повтор упавшего **release** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-release).
### Секреты ### Секреты
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**. **`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
@@ -35,11 +37,11 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
### Теги образов ### Теги образов
```text ```text
git.shts.su/<owner>/<имя>:latest git.shx.one/<owner>/<имя>:latest
git.shts.su/<owner>/<имя>:v1.2.3 git.shx.one/<owner>/<имя>:v1.2.3
git.shts.su/<owner>/<имя>:1.2.3 git.shx.one/<owner>/<имя>:1.2.3
git.shts.su/<owner>/<имя>:<short-sha> git.shx.one/<owner>/<имя>:<short-sha>
git.shts.su/<owner>/<имя>:sha-<full-sha> git.shx.one/<owner>/<имя>:sha-<full-sha>
``` ```
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`. Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
@@ -49,7 +51,7 @@ git.shts.su/<owner>/<имя>:sha-<full-sha>
Пример: Пример:
```bash ```bash
docker pull git.shts.su/myuser/evobgp-api:1.2.3 docker pull git.shx.one/myuser/evobgp-api:1.2.3
``` ```
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md). См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
+21 -6
View File
@@ -303,6 +303,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true
token: ${{ secrets.ACTIONS_PAT || gitea.token }} token: ${{ secrets.ACTIONS_PAT || gitea.token }}
persist-credentials: true persist-credentials: true
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@@ -317,14 +318,28 @@ jobs:
- name: Semantic release - name: Semantic release
run: npx semantic-release run: npx semantic-release
env: env:
GITEA_URL: https://git.shts.su GITEA_URL: https://git.shx.one
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }} GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Detect new release - name: Detect new release
id: rel id: rel
run: | run: |
set -euo pipefail set -euo pipefail
version=""
if [ -f .release-version ]; then if [ -f .release-version ]; then
echo "version=$(tr -d '[:space:]' < .release-version)" >> "$GITHUB_OUTPUT" version="$(tr -d '[:space:]' < .release-version)"
echo "New release from semantic-release: $version"
else
# Re-run after a failed docker step: tag already exists, successCmd
# did not write .release-version (semantic-release is a no-op).
git fetch --tags --force origin || true
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
if [ -n "${tag:-}" ]; then
version="${tag#v}"
echo "Reuse existing tag $tag on HEAD (release retry)"
fi
fi
if [ -n "${version:-}" ]; then
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "released=true" >> "$GITHUB_OUTPUT" echo "released=true" >> "$GITHUB_OUTPUT"
else else
echo "released=false" >> "$GITHUB_OUTPUT" echo "released=false" >> "$GITHUB_OUTPUT"
@@ -348,20 +363,20 @@ jobs:
if: steps.rel.outputs.released == 'true' if: steps.rel.outputs.released == 'true'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.shts.su registry: git.shx.one
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT || gitea.token }} password: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Build and push images (bake) - name: Build and push images (bake)
if: steps.rel.outputs.released == 'true' if: steps.rel.outputs.released == 'true'
env: env:
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }} REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest IMAGE_TAG: latest
VERSION: ${{ steps.meta.outputs.version }} VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }} SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }} SHA_FULL: ${{ github.sha }}
BUILD_TIME: ${{ steps.meta.outputs.build_time }} BUILD_TIME: ${{ steps.meta.outputs.build_time }}
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
BUILDX_BAKE_ENTITLEMENTS_FS: "0" BUILDX_BAKE_ENTITLEMENTS_FS: "0"
run: | run: |
set -euxo pipefail set -euxo pipefail
+1 -1
View File
@@ -37,7 +37,7 @@
[ [
"@markwylde/semantic-release-gitea", "@markwylde/semantic-release-gitea",
{ {
"giteaUrl": "https://git.shts.su", "giteaUrl": "https://git.shx.one",
"assets": [ "assets": [
{ {
"path": "CHANGELOG.md", "path": "CHANGELOG.md",
+2 -2
View File
@@ -22,8 +22,8 @@ Control plane для управления префиксами, модулями
```powershell ```powershell
cd deploy\compose cd deploy\compose
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shts.su/<owner> Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shx.one/<owner>
docker login git.shts.su docker login git.shx.one
docker compose --profile microvps pull docker compose --profile microvps pull
docker compose --profile microvps up -d docker compose --profile microvps up -d
``` ```
@@ -49,7 +49,8 @@ export function AccessApiKeysCard({
<> <>
<FrameDataGrid <FrameDataGrid
title="API-ключи" title="API-ключи"
description="Управление ключами tenant. Полный токен показывается только при создании и ротации." description="Полный токен показывается только при создании и ротации."
className="min-w-0"
actions={ actions={
<> <>
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}> <Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
@@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatApiKeyDate } from '@/lib/access/api-key-labels' import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ApiKey } from '@/types/api' import type { ApiKey } from '@/types/api'
@@ -34,7 +35,14 @@ export function AccessApiKeysGrid({
{ {
accessorKey: 'name', accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />, cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name}
accent="primary"
subtitle={`${row.original.prefix}`}
/>
),
minSize: 160,
meta: { headerTitle: 'Имя' }, meta: { headerTitle: 'Имя' },
}, },
{ {
@@ -45,14 +53,6 @@ export function AccessApiKeysGrid({
), ),
meta: { headerTitle: 'Роль' }, meta: { headerTitle: 'Роль' },
}, },
{
accessorKey: 'prefix',
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}</span>
),
meta: { headerTitle: 'Префикс' },
},
{ {
id: 'status', id: 'status',
enableSorting: false, enableSorting: false,
@@ -77,11 +77,11 @@ export function AccessApiKeysGrid({
{ {
id: 'last_used_at', id: 'last_used_at',
accessorFn: (row) => row.last_used_at ?? '', accessorFn: (row) => row.last_used_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell> <DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
), ),
meta: { headerTitle: 'Последнее использование' }, meta: { headerTitle: 'Использован' },
}, },
{ {
id: 'actions', id: 'actions',
@@ -90,7 +90,7 @@ export function AccessApiKeysGrid({
cell: ({ row }) => { cell: ({ row }) => {
const k = row.original const k = row.original
return ( return (
<div className="flex gap-1"> <div className="flex shrink-0 gap-1">
<ConfirmDialog <ConfirmDialog
trigger={ trigger={
<Button <Button
@@ -149,6 +149,7 @@ export function AccessApiKeysGrid({
recordCount={filteredCount} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет ключей" emptyMessage="Нет ключей"
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter} searchValue={globalFilter}
onSearchChange={setGlobalFilter} onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск API-ключей…" searchPlaceholder="Поиск API-ключей…"
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
import { import {
ChartContainer, ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig, type ChartConfig,
} from '@evobgp/ui/components/chart' } from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
@@ -40,9 +42,10 @@ export function ChartDonutMetric({
} }
return ( return (
<div className={cn('flex items-center gap-6', className)}> <div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0"> <ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
<PieChart> <PieChart>
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
<Pie <Pie
data={data} data={data}
dataKey="count" dataKey="count"
@@ -75,7 +78,7 @@ export function ChartDonutMetric({
</PieChart> </PieChart>
</ChartContainer> </ChartContainer>
<ul className="min-w-0 flex-1 space-y-3"> <ul className="flex w-full min-w-0 max-w-xs flex-col gap-3 sm:w-auto sm:min-w-[10rem]">
{slices.map((slice) => { {slices.map((slice) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0' const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return ( return (
@@ -27,10 +27,10 @@ export function MonitoringHealthCard({
return ( return (
<DonutBreakdownCard <DonutBreakdownCard
title="Доступность системы" title="Доступность системы"
description="GET /v1/health · GET /v1/ready" description="Проверки живучести и готовности"
slices={slices} slices={slices}
centerLabel="Проверки" centerLabel="Проверки"
badge={healthOk ? 'API OK' : undefined} badge={healthOk ? 'API в норме' : undefined}
/> />
) )
} }
@@ -40,18 +40,18 @@ export function NetworkOverviewAnalyticsCard({
/> />
<SegmentedProgressCard <SegmentedProgressCard
title="Спикеры" title="Спикеры"
description="Доступность live-агентов" description="Доступность агентов"
primary={{ primary={{
value: `${net.speakersOnline}/${net.speakersTotal}`, value: `${net.speakersOnline}/${net.speakersTotal}`,
label: 'Online', label: 'В сети',
percent: speakersPct, percent: speakersPct,
}} }}
secondary={{ secondary={{
value: net.speakersTotal - net.speakersOnline, value: net.speakersTotal - net.speakersOnline,
label: 'Offline', label: 'Не в сети',
percent: 100 - speakersPct, percent: 100 - speakersPct,
}} }}
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`} footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
/> />
</div> </div>
) )
+6 -3
View File
@@ -41,14 +41,17 @@ export function BadgeTabs({
defaultValue={defaultValue} defaultValue={defaultValue}
onValueChange={onValueChange} onValueChange={onValueChange}
orientation="horizontal" orientation="horizontal"
className={cn('w-full', className)} className={cn('w-full min-w-0', className)}
> >
<TabsList <TabsList
variant="line" variant="line"
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)} className={cn(
'mb-3.5 w-full min-w-0 justify-start gap-4',
listClassName,
)}
> >
{items.map((item) => ( {items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2"> <TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
{item.icon} {item.icon}
{item.label} {item.label}
{item.count !== undefined ? ( {item.count !== undefined ? (
@@ -1,62 +1,2 @@
import { type ReactNode } from "react" /** @deprecated Import from `@/components/settings/setting-row` — settings-7 is not a target. */
export { SettingRow } from '@/components/settings/setting-row'
import { cn } from "@evobgp/ui/lib/utils"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from "@evobgp/ui/components/field"
interface SettingRowProps {
title: string
description?: ReactNode
children: ReactNode
last?: boolean
labelFor?: string
contentClassName?: string
titleAddon?: ReactNode
}
export function SettingRow({
title,
description,
children,
last,
labelFor,
contentClassName,
titleAddon,
}: SettingRowProps) {
return (
<>
<Field orientation="responsive" className="gap-4 px-5 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{titleAddon}
</div>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : null}
</div>
<FieldContent
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
>
<div className="flex w-full justify-start @md/field-group:justify-end">
{children}
</div>
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
)
}
@@ -1,53 +1,2 @@
import { type ReactNode } from 'react' /** @deprecated Import from `@/components/settings/settings-card` — settings-7 is not a target. */
export { SettingsCard } from '@/components/settings/settings-card'
import { cn } from '@evobgp/ui/lib/utils'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
interface SettingsCardProps {
title: string
description?: string
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
footerClassName?: string
}
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
export function SettingsCard({
title,
description,
children,
footer,
className,
contentClassName,
footerClassName,
}: SettingsCardProps) {
return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
<FrameHeader className="gap-0 border-b px-5 py-3">
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
<div className={cn('min-w-0', contentClassName)}>{children}</div>
{footer ? (
<FrameFooter
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
@@ -1,37 +1,2 @@
import { type ReactNode } from "react" /** @deprecated Import from `@/components/settings/settings-field-group` — settings-7 is not a target. */
export { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { cn } from "@evobgp/ui/lib/utils"
import {
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
interface SettingsFieldGroupProps {
legend: string
description: string
children: ReactNode
className?: string
fieldGroupClassName?: string
}
export function SettingsFieldGroup({
legend,
description,
children,
className,
fieldGroupClassName,
}: SettingsFieldGroupProps) {
return (
<FieldSet className={cn("gap-0", className)}>
<FieldLegend className="sr-only">{legend}</FieldLegend>
{/* Description */}
<FieldDescription className="sr-only">{description}</FieldDescription>
{/* List */}
<FieldGroup className={cn("gap-0", fieldGroupClassName)}>
{children}
</FieldGroup>
</FieldSet>
)
}
+52 -93
View File
@@ -1,19 +1,16 @@
import { Button } from '@evobgp/ui/components/button' import { useState, type ReactElement, type ReactNode } from 'react'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@evobgp/ui/components/drawer'
import type { ReactElement, ReactNode } from 'react'
import { import {
confirmDrawerContentClassName, AlertDialog,
DrawerActionsFooter, AlertDialogAction,
} from '@/components/drawer-layout' AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@evobgp/ui/components/alert-dialog'
type ConfirmDialogBaseProps = { type ConfirmDialogBaseProps = {
title: string title: string
@@ -41,90 +38,52 @@ type ConfirmDialogControlled = ConfirmDialogBaseProps & {
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
function ConfirmDrawerBody({ /** Confirm overlay — Alert Dialog. Preview: https://reui.io/preview/base/components/c-alert-dialog-11 */
title,
description,
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
destructive,
onConfirm,
confirmDisabled,
confirmLoading,
confirmLoadingLabel,
controlled,
}: ConfirmDialogBaseProps & { controlled?: boolean }) {
const confirmText =
confirmLoading && confirmLoadingLabel
? confirmLoadingLabel
: confirmLoading
? `${confirmLabel}`
: confirmLabel
return (
<>
<DrawerHeader className="shrink-0 border-b border-border pb-4">
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<DrawerActionsFooter>
<DrawerClose render={<Button variant="outline" disabled={confirmLoading} />}>
{cancelLabel}
</DrawerClose>
{controlled ? (
<Button
variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
onClick={onConfirm}
>
{confirmText}
</Button>
) : (
<DrawerClose
render={
<Button
variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
/>
}
onClick={onConfirm}
>
{confirmText}
</DrawerClose>
)}
</DrawerActionsFooter>
</>
)
}
export function ConfirmDialog(props: ConfirmDialogProps) { export function ConfirmDialog(props: ConfirmDialogProps) {
const bodyProps: ConfirmDialogBaseProps = { const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
title: props.title, const isControlled = props.open !== undefined
description: props.description, const open = isControlled ? props.open : uncontrolledOpen
confirmLabel: props.confirmLabel,
cancelLabel: props.cancelLabel, function setOpen(next: boolean) {
destructive: props.destructive, if (!isControlled) setUncontrolledOpen(next)
onConfirm: props.onConfirm, if (isControlled) props.onOpenChange?.(next)
confirmDisabled: props.confirmDisabled,
confirmLoading: props.confirmLoading,
confirmLoadingLabel: props.confirmLoadingLabel,
} }
if (props.trigger) { const confirmText =
return ( props.confirmLoading && props.confirmLoadingLabel
<Drawer swipeDirection="right"> ? props.confirmLoadingLabel
<DrawerTrigger render={props.trigger} /> : props.confirmLoading
<DrawerContent className={confirmDrawerContentClassName}> ? `${props.confirmLabel ?? 'Подтвердить'}`
<ConfirmDrawerBody {...bodyProps} /> : (props.confirmLabel ?? 'Подтвердить')
</DrawerContent>
</Drawer> function handleConfirm() {
) props.onConfirm()
if (!props.confirmLoading && !isControlled) setOpen(false)
} }
return ( return (
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right"> <AlertDialog open={open} onOpenChange={setOpen}>
<DrawerContent className={confirmDrawerContentClassName}> {props.trigger ? <AlertDialogTrigger render={props.trigger} /> : null}
<ConfirmDrawerBody {...bodyProps} controlled /> <AlertDialogContent>
</DrawerContent> <AlertDialogHeader>
</Drawer> <AlertDialogTitle>{props.title}</AlertDialogTitle>
{props.description ? (
<AlertDialogDescription>{props.description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={props.confirmLoading}>
{props.cancelLabel ?? 'Отмена'}
</AlertDialogCancel>
<AlertDialogAction
variant={props.destructive ? 'destructive' : 'default'}
disabled={props.confirmDisabled || props.confirmLoading}
onClick={handleConfirm}
>
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) )
} }
@@ -50,7 +50,7 @@ export function DashboardActivityTimeline({
<DashboardFramePanel <DashboardFramePanel
title="Недавняя активность" title="Недавняя активность"
description="Задачи, ревизии и сетевые события" description="Задачи, ревизии и сетевые события"
className="h-full" className="h-full min-w-0"
> >
{loading ? ( {loading ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка</p> <p className="text-muted-foreground px-4 py-6 text-sm">Загрузка</p>
@@ -82,7 +82,7 @@ export function DashboardActivityTimeline({
</TimelineIndicator> </TimelineIndicator>
</TimelineHeader> </TimelineHeader>
<TimelineContent className="min-w-0 pb-1 text-foreground"> <TimelineContent className="min-w-0 pb-1 text-foreground">
<TimelineTitle className="text-sm leading-snug font-normal"> <TimelineTitle className="text-sm leading-snug font-normal break-words">
{item.message} {item.message}
</TimelineTitle> </TimelineTitle>
<div className="mt-2"> <div className="mt-2">
@@ -82,7 +82,7 @@ function buildKpis({
icon: <Share2 aria-hidden />, icon: <Share2 aria-hidden />,
iconClassName: 'text-success', iconClassName: 'text-success',
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`, value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
label: 'Пиры Established', label: 'Пиры установлены',
footer: ( footer: (
<Badge variant="success-light" size="sm"> <Badge variant="success-light" size="sm">
{loading ? '…' : `${network.peersTotal} в каталоге`} {loading ? '…' : `${network.peersTotal} в каталоге`}
@@ -94,13 +94,13 @@ function buildKpis({
icon: <ServerCog aria-hidden />, icon: <ServerCog aria-hidden />,
iconClassName: 'text-warning', iconClassName: 'text-warning',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`, value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
label: 'Спикеры online', label: 'Спикеры в сети',
footer: ( footer: (
<Badge <Badge
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'} variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
size="sm" size="sm"
> >
{loading ? '…' : 'live-снимок'} {loading ? '…' : 'в сети'}
</Badge> </Badge>
), ),
}, },
@@ -40,7 +40,7 @@ function buildMetrics({
{ {
id: 'bgp', id: 'bgp',
title: 'BGP готовность', title: 'BGP готовность',
label: 'Established / включённые', label: 'Установлено / включено',
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`, value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание', delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light', deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
@@ -62,13 +62,13 @@ function buildMetrics({
{ {
id: 'speakers', id: 'speakers',
title: 'Спикеры', title: 'Спикеры',
label: 'Online / всего', label: 'В сети / всего',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`, value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
delta: delta:
loading || network.speakersTotal === 0 loading || network.speakersTotal === 0
? '…' ? '…'
: network.speakersOnline === network.speakersTotal : network.speakersOnline === network.speakersTotal
? 'все online' ? 'все в сети'
: 'частично', : 'частично',
deltaVariant: deltaVariant:
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light', network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
@@ -11,8 +11,8 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { CategoryBadge, ModeBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel' import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid' import { DataGrid } from '@/components/reui/data-grid/data-grid'
@@ -40,6 +40,7 @@ import {
InputGroupButton, InputGroupButton,
InputGroupInput, InputGroupInput,
} from '@evobgp/ui/components/input-group' } from '@evobgp/ui/components/input-group'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { moduleTypeRu } from '@/lib/ui-labels' import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api' import type { ModuleRow } from '@/types/api'
import { import {
@@ -51,20 +52,19 @@ import {
type SortingState, type SortingState,
} from '@tanstack/react-table' } from '@tanstack/react-table'
type ModuleSort = 'name' | 'type' | 'priority' | 'last_refreshed_at' type ModuleSort = 'name' | 'type' | 'priority'
type EnabledFilter = 'all' | 'enabled' | 'disabled' type EnabledFilter = 'all' | 'enabled' | 'disabled'
const sortLabels: Record<ModuleSort, string> = { const sortLabels: Record<ModuleSort, string> = {
name: 'Название', name: 'Название',
type: 'Тип', type: 'Тип',
priority: 'Приоритет', priority: 'Приоритет',
last_refreshed_at: 'Обновлено',
} }
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.' const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
function buildSorting(sortBy: ModuleSort): SortingState { function buildSorting(sortBy: ModuleSort): SortingState {
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }] return [{ id: sortBy, desc: false }]
} }
export function DashboardModulesGrid({ export function DashboardModulesGrid({
@@ -119,9 +119,10 @@ export function DashboardModulesGrid({
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden /> <BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" /> <DataGridPrimaryCell title={row.original.name} accent="primary" />
</div> </div>
), ),
minSize: 180,
meta: { headerTitle: 'Модуль' }, meta: { headerTitle: 'Модуль' },
}, },
{ {
@@ -138,30 +139,9 @@ export function DashboardModulesGrid({
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span> <span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
), ),
size: 88,
meta: { headerTitle: 'Приоритет' }, meta: { headerTitle: 'Приоритет' },
}, },
{
id: 'enabled',
accessorFn: (row) => (row.enabled !== false ? 'enabled' : 'disabled'),
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => <ModeBadge enabled={row.original.enabled !== false} />,
meta: { headerTitle: 'Состояние' },
},
{
id: 'last_refreshed_at',
accessorFn: (row) => row.last_refreshed_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.last_refreshed_at
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
sortingFn: (a, b) =>
(a.original.last_refreshed_at ?? '').localeCompare(b.original.last_refreshed_at ?? ''),
meta: { headerTitle: 'Обновлено' },
},
], ],
[], [],
) )
@@ -185,6 +165,7 @@ export function DashboardModulesGrid({
<DashboardFramePanel <DashboardFramePanel
title="Модули" title="Модули"
description="Поиск, сортировка и быстрый переход к настройке" description="Поиск, сортировка и быстрый переход к настройке"
className="min-w-0"
actions={ actions={
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}> <Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
<PlusIcon /> <PlusIcon />
@@ -204,14 +185,14 @@ export function DashboardModulesGrid({
columnsVisibility: false, columnsVisibility: false,
columnsResizable: false, columnsResizable: false,
columnsMovable: false, columnsMovable: false,
width: 'fixed', width: 'auto',
}} }}
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }} tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })} onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
> >
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 border-b px-4 py-3 @3xl:flex-row @3xl:items-center @3xl:justify-between">
<InputGroup className="w-full min-w-40 sm:max-w-xs"> <InputGroup className="w-full min-w-0 @3xl:max-w-xs">
<InputGroupAddon align="inline-start"> <InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden /> <SearchIcon className="text-muted-foreground size-4" aria-hidden />
</InputGroupAddon> </InputGroupAddon>
@@ -337,8 +318,8 @@ export function DashboardModulesGrid({
<div className="border-t px-4 py-3"> <div className="border-t px-4 py-3">
{filteredModules.length > 0 ? ( {filteredModules.length > 0 ? (
<DataGridPagination <DataGridPagination
{...DATA_GRID_PAGINATION_RU}
sizes={[10, 15, 20]} sizes={[10, 15, 20]}
info="{from}{to} из {count}"
className="py-0" className="py-0"
/> />
) : ( ) : (
@@ -57,7 +57,7 @@ export function DashboardNetworkHealth({
} }
primary={{ primary={{
value: loading ? '—' : `${utilization}%`, value: loading ? '—' : `${utilization}%`,
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры online', label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
percent: loading ? 0 : utilization, percent: loading ? 0 : utilization,
badge: ( badge: (
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]"> <Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
@@ -67,7 +67,7 @@ export function DashboardNetworkHealth({
}} }}
secondary={{ secondary={{
value: loading ? '—' : offline, value: loading ? '—' : offline,
label: mode === 'peers' ? 'Не Established' : 'Offline', label: mode === 'peers' ? 'Не установлены' : 'Не в сети',
percent: total > 0 ? Math.round((offline / total) * 100) : 0, percent: total > 0 ? Math.round((offline / total) * 100) : 0,
}} }}
footer={ footer={
@@ -3,13 +3,16 @@ import { useMemo, useState } from 'react'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric' import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control' import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { PanelCard } from '@/components/panel-card' import { PanelCard } from '@/components/panel-card'
import { Separator } from '@evobgp/ui/components/separator'
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics' import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
import type { JobRow, ModuleRow } from '@/types/api' import type { JobRow, ModuleRow } from '@/types/api'
type FlowMode = 'jobs' | 'modules' type FlowMode = 'jobs' | 'modules'
/** chart-13 inspired donut breakdown (Card surface). */ /**
* Donut distribution — chart-27 DNA: Frame + one legend (inside ChartDonutMetric).
* @see https://reui.io/preview/base/chart-27
* @see https://reui.io/docs/components/base/frame
*/
export function DashboardOperationsBreakdown({ export function DashboardOperationsBreakdown({
jobs, jobs,
modules, modules,
@@ -32,7 +35,7 @@ export function DashboardOperationsBreakdown({
return ( return (
<PanelCard <PanelCard
title="Поток операций" title="Поток операций"
description="Распределение задач и типов модулей" description="Доли статусов задач или типов модулей"
actions={ actions={
<AnalyticsSegmentControl <AnalyticsSegmentControl
value={mode} value={mode}
@@ -43,45 +46,16 @@ export function DashboardOperationsBreakdown({
]} ]}
/> />
} }
className="h-full" className="min-w-0"
contentClassName="px-5 py-4"
> >
<div className="p-4"> {loading ? (
{loading ? ( <div className="text-muted-foreground flex h-44 items-center justify-center text-sm">
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm"> Загрузка
Загрузка </div>
</div> ) : (
) : ( <ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
<> )}
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
{slices.length > 0 ? (
<ul className="mt-4 flex min-w-0 flex-col">
{slices.map((slice, index) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
<li key={slice.key}>
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2">
<div className="flex min-w-0 items-center gap-2.5">
<span
aria-hidden
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
style={{ backgroundColor: slice.color }}
/>
<span className="truncate text-sm font-medium">{slice.label}</span>
</div>
<span className="text-sm font-medium tabular-nums">{slice.count}</span>
<span className="text-muted-foreground/70 w-10 text-right text-xs tabular-nums">
{pct}%
</span>
</div>
{index < slices.length - 1 ? <Separator className="w-auto" /> : null}
</li>
)
})}
</ul>
) : null}
</>
)}
</div>
</PanelCard> </PanelCard>
) )
} }
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
{ {
id: 'lookup', id: 'lookup',
title: 'Проверка IP/домена', title: 'Проверка IP/домена',
description: 'Membership в списках и community (entry + snapshot).', description: 'Проверка IP в списках и BGP community.',
to: '/lookup', to: '/lookup',
icon: <Search aria-hidden />, icon: <Search aria-hidden />,
iconClassName: 'text-primary', iconClassName: 'text-primary',
@@ -22,8 +22,8 @@ const ACTIONS: QuickActionItem[] = [
}, },
{ {
id: 'communities', id: 'communities',
title: 'BGP-сообщества', title: 'BGP community',
description: 'Справочник communities для политик экспорта.', description: 'Справочник BGP community для политик экспорта.',
to: '/directories', to: '/directories',
icon: <Tags aria-hidden />, icon: <Tags aria-hidden />,
iconClassName: 'text-info', iconClassName: 'text-info',
@@ -31,7 +31,7 @@ const ACTIONS: QuickActionItem[] = [
{ {
id: 'network', id: 'network',
title: 'Сеть', title: 'Сеть',
description: 'Обзор пиров, спикеров и live-сессий BGP.', description: 'Обзор пиров, спикеров и живых BGP-сессий.',
to: '/network', to: '/network',
search: { tab: 'overview' }, search: { tab: 'overview' },
icon: <Network aria-hidden />, icon: <Network aria-hidden />,
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridShell } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults' import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
@@ -49,7 +49,7 @@ export function DashboardRecentJobsGrid({
[nameById], [nameById],
) )
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ const { table, filteredCount } = useClientDataGrid({
data, data,
columns, columns,
getSearchText: (row) => { getSearchText: (row) => {
@@ -63,16 +63,13 @@ export function DashboardRecentJobsGrid({
}) })
return ( return (
<DataGridSection <DataGridShell
table={table} table={table}
recordCount={filteredCount} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет задач" emptyMessage="Нет задач"
showPagination={false} showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT} tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/> />
) )
} }
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults' import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
@@ -42,7 +42,7 @@ export function DashboardRecentRevisionsGrid({
[], [],
) )
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ const { table, filteredCount } = useClientDataGrid({
data, data,
columns, columns,
getSearchText: (row) => row.id, getSearchText: (row) => row.id,
@@ -51,16 +51,13 @@ export function DashboardRecentRevisionsGrid({
}) })
return ( return (
<DataGridSection <DataGridShell
table={table} table={table}
recordCount={filteredCount} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет ревизий" emptyMessage="Нет ревизий"
showPagination={false} showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT} tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
/> />
) )
} }
+1 -1
View File
@@ -50,7 +50,7 @@ export function DataGridShell<TData extends object>({
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage} loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
tableLayout={tableLayout} tableLayout={tableLayout}
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES} tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
className={className} className={cn('min-w-0', className)}
onRowClick={onRowClick} onRowClick={onRowClick}
> >
<DataGridContainer border={false}> <DataGridContainer border={false}>
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import {
useCreateCommunityMutation,
useUpdateCommunityMutation,
} from '@/queries/directories'
import type { BgpCommunity, BgpCommunityCreate, BgpCommunityPatch } from '@/types/api'
interface CommunityFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editTarget?: BgpCommunity | null
}
/** @see https://reui.io/preview/base/form-7 */
export function CommunityFormDialog({
open,
onOpenChange,
editTarget = null,
}: CommunityFormDialogProps) {
const createMutation = useCreateCommunityMutation()
const updateMutation = useUpdateCommunityMutation()
const saving = createMutation.isPending || updateMutation.isPending
const [community, setCommunity] = useState('')
const [title, setTitle] = useState('')
useEffect(() => {
if (!open) return
if (editTarget) {
setCommunity(editTarget.community ?? '')
setTitle(editTarget.title ?? '')
} else {
setCommunity('')
setTitle('')
}
}, [editTarget, open])
async function save() {
const value = community.trim()
if (!value) {
toast.error('Укажите community')
return
}
const titleTrimmed = title.trim()
try {
if (editTarget) {
const body: BgpCommunityPatch = {
community: value,
title: titleTrimmed || '',
}
await updateMutation.mutateAsync({ id: editTarget.id, body })
} else {
const body: BgpCommunityCreate = { community: value }
if (titleTrimmed) body.title = titleTrimmed
await createMutation.mutateAsync(body)
}
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={editTarget ? 'Редактировать community' : 'Новое BGP community'}
description="Тег для префиксов в фильтрах BIRD"
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={saving} onClick={() => void save()}>
{editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="comm-value">Community</Label>
<Input
id="comm-value"
placeholder="65000:100"
value={community}
onChange={(e) => setCommunity(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="comm-title">Название (опционально)</Label>
<Input
id="comm-title"
placeholder="Отображаемое имя"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</FormDrawer>
)
}
/** @deprecated Use CommunityFormDialog */
export const CommunityCreateDialog = CommunityFormDialog
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { BgpCommunity } from '@/types/api' import type { BgpCommunity } from '@/types/api'
@@ -11,12 +12,16 @@ import type { BgpCommunity } from '@/types/api'
export function DirectoriesCommunitiesGrid({ export function DirectoriesCommunitiesGrid({
items, items,
isLoading = false, isLoading = false,
canWrite = false,
onEdit,
}: { }: {
items: BgpCommunity[] items: BgpCommunity[]
isLoading?: boolean isLoading?: boolean
canWrite?: boolean
onEdit?: (row: BgpCommunity) => void
}) { }) {
const columns = useMemo<ColumnDef<BgpCommunity>[]>( const columns = useMemo<ColumnDef<BgpCommunity>[]>(() => {
() => [ const cols: ColumnDef<BgpCommunity>[] = [
{ {
accessorKey: 'title', accessorKey: 'title',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
@@ -38,9 +43,23 @@ export function DirectoriesCommunitiesGrid({
cell: () => <CategoryBadge>community</CategoryBadge>, cell: () => <CategoryBadge>community</CategoryBadge>,
meta: { headerTitle: 'Тип' }, meta: { headerTitle: 'Тип' },
}, },
], ]
[],
) if (canWrite && onEdit) {
cols.push({
id: 'actions',
enableSorting: false,
enableHiding: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
),
meta: { headerTitle: 'Действия' },
})
}
return cols
}, [canWrite, onEdit])
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
@@ -54,10 +73,10 @@ export function DirectoriesCommunitiesGrid({
table={table} table={table}
recordCount={filteredCount} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет сообществ" emptyMessage="Нет community"
searchValue={globalFilter} searchValue={globalFilter}
onSearchChange={setGlobalFilter} onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск сообществ…" searchPlaceholder="Поиск community…"
/> />
) )
} }
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { DohProfile } from '@/types/api' import type { DohProfile } from '@/types/api'
@@ -11,12 +12,16 @@ import type { DohProfile } from '@/types/api'
export function DirectoriesDohGrid({ export function DirectoriesDohGrid({
items, items,
isLoading = false, isLoading = false,
canWrite = false,
onEdit,
}: { }: {
items: DohProfile[] items: DohProfile[]
isLoading?: boolean isLoading?: boolean
canWrite?: boolean
onEdit?: (row: DohProfile) => void
}) { }) {
const columns = useMemo<ColumnDef<DohProfile>[]>( const columns = useMemo<ColumnDef<DohProfile>[]>(() => {
() => [ const cols: ColumnDef<DohProfile>[] = [
{ {
id: 'name', id: 'name',
accessorFn: (row) => row.name ?? row.url, accessorFn: (row) => row.name ?? row.url,
@@ -43,9 +48,23 @@ export function DirectoriesDohGrid({
cell: () => <CategoryBadge></CategoryBadge>, cell: () => <CategoryBadge></CategoryBadge>,
meta: { headerTitle: 'По умолчанию' }, meta: { headerTitle: 'По умолчанию' },
}, },
], ]
[],
) if (canWrite && onEdit) {
cols.push({
id: 'actions',
enableSorting: false,
enableHiding: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
),
meta: { headerTitle: 'Действия' },
})
}
return cols
}, [canWrite, onEdit])
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
@@ -0,0 +1,36 @@
import { MoreHorizontalIcon, PencilIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
/**
* Data-grid row actions — ⋯ menu (ReUI / shadcn DropdownMenu).
* @see https://reui.io/preview/base/components/c-dropdown-menu-12
* @see https://reui.io/docs/components/base/dropdown-menu
*/
export function DirectoriesRowActions({ onEdit }: { onEdit: () => void }) {
return (
<div className="flex justify-end">
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="ghost" size="icon-sm" aria-label="Действия">
<MoreHorizontalIcon className="size-4" aria-hidden />
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem onClick={onEdit}>
<PencilIcon aria-hidden />
Редактировать
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import {
useCreateDohProfileMutation,
useUpdateDohProfileMutation,
} from '@/queries/directories'
import type { DohProfile, DohProfileCreate, DohProfilePatch } from '@/types/api'
interface DohProfileFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editTarget?: DohProfile | null
}
/** @see https://reui.io/preview/base/form-7 */
export function DohProfileFormDialog({
open,
onOpenChange,
editTarget = null,
}: DohProfileFormDialogProps) {
const createMutation = useCreateDohProfileMutation()
const updateMutation = useUpdateDohProfileMutation()
const saving = createMutation.isPending || updateMutation.isPending
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const [timeoutMs, setTimeoutMs] = useState('')
useEffect(() => {
if (!open) return
if (editTarget) {
setName(editTarget.name ?? '')
setUrl(editTarget.url ?? '')
setTimeoutMs(
editTarget.timeout_ms === null || editTarget.timeout_ms === undefined
? ''
: String(editTarget.timeout_ms),
)
} else {
setName('')
setUrl('')
setTimeoutMs('')
}
}, [editTarget, open])
async function save() {
const trimmedUrl = url.trim()
if (!trimmedUrl) {
toast.error('Укажите URL DoH')
return
}
let urlOk = true
try {
new URL(trimmedUrl)
} catch {
urlOk = false
}
if (!urlOk) {
toast.error('Некорректный URL')
return
}
let timeout: number | null = null
if (timeoutMs.trim() !== '') {
const ms = Number(timeoutMs)
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
toast.error('Таймаут должен быть целым числом больше 0')
return
}
timeout = ms
}
const nameTrimmed = name.trim()
try {
if (editTarget) {
const body: DohProfilePatch = {
url: trimmedUrl,
name: nameTrimmed || undefined,
timeout_ms: timeout,
}
await updateMutation.mutateAsync({ id: editTarget.id, body })
} else {
const body: DohProfileCreate = { url: trimmedUrl }
if (nameTrimmed) body.name = nameTrimmed
if (timeout !== null) body.timeout_ms = timeout
await createMutation.mutateAsync(body)
}
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={editTarget ? 'Редактировать DoH-профиль' : 'Новый DoH-профиль'}
description="Резолвер DNS-over-HTTPS для доменных модулей"
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={saving} onClick={() => void save()}>
{editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="doh-name">Имя (опционально)</Label>
<Input
id="doh-name"
placeholder="Control D / AdGuard"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="doh-url">URL</Label>
<Input
id="doh-url"
type="url"
placeholder="https://dns.example/dns-query"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="doh-timeout">Таймаут, мс (необязательно)</Label>
<Input
id="doh-timeout"
type="number"
min={1}
placeholder="5000"
value={timeoutMs}
onChange={(e) => setTimeoutMs(e.target.value)}
/>
</div>
</FormDrawer>
)
}
/** @deprecated Use DohProfileFormDialog */
export const DohProfileCreateDialog = DohProfileFormDialog
-31
View File
@@ -1,31 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import { DrawerFooter } from '@evobgp/ui/components/drawer'
/** Shared footer layout for right-side form and confirm drawers. */
export function DrawerActionsFooter({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<DrawerFooter
className={cn(
'mt-0 shrink-0 border-t border-border bg-muted/50 p-4',
className,
)}
>
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{children}
</div>
</DrawerFooter>
)
}
export const formDrawerContentClassName =
'flex h-full max-h-dvh flex-col sm:max-w-lg'
export const confirmDrawerContentClassName = 'flex h-auto max-h-dvh flex-col sm:max-w-sm'
+84 -9
View File
@@ -1,28 +1,103 @@
/**
* 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 type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@evobgp/ui/components/empty'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
interface EmptyStateProps { interface EmptyStateProps {
icon?: LucideIcon | ReactNode
title: string title: string
description?: string description?: string
icon?: ReactNode
action?: ReactNode action?: ReactNode
className?: string className?: string
stackedIcon?: boolean
centered?: boolean
} }
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) { function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
if (typeof icon === 'function') return true
if (icon != null && typeof icon === 'object' && '$$typeof' in icon) {
if ('props' in icon) return false
return true
}
return false
}
export function EmptyState({
icon,
title,
description,
action,
className,
stackedIcon = true,
centered = true,
}: EmptyStateProps) {
const Icon = isLucideIcon(icon) ? icon : InboxIcon
const customIcon = icon && !isLucideIcon(icon) ? icon : null
const body = (
<Empty
className={cn(
'max-w-md flex-none border-0 bg-transparent p-0',
!centered && className,
)}
>
<EmptyHeader className="gap-5 text-center">
<EmptyMedia className="mb-0">
{customIcon ? (
stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
{customIcon}
</IconStack>
) : (
<div className="text-muted-foreground">{customIcon}</div>
)
) : stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
</IconStack>
) : (
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
<Icon aria-hidden="true" />
</span>
)}
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
{description ? (
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
) : null}
</div>
</EmptyHeader>
{action ? (
<EmptyContent className="mt-1 items-center justify-center">{action}</EmptyContent>
) : null}
</Empty>
)
if (!centered) return body
return ( return (
<div <div
className={cn( className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center', 'flex w-full flex-1 items-center justify-center self-stretch py-14 sm:py-16',
className, className,
)} )}
> >
{icon ? <div className="text-muted-foreground">{icon}</div> : null} {body}
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div> </div>
) )
} }
+25 -24
View File
@@ -2,18 +2,13 @@ import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
import { import {
Drawer, Sheet,
DrawerContent, SheetContent,
DrawerDescription, SheetDescription,
DrawerHeader, SheetFooter,
DrawerTitle, SheetHeader,
} from '@evobgp/ui/components/drawer' SheetTitle,
import { ScrollArea } from '@evobgp/ui/components/scroll-area' } from '@evobgp/ui/components/sheet'
import {
DrawerActionsFooter,
formDrawerContentClassName,
} from '@/components/drawer-layout'
interface FormDrawerProps { interface FormDrawerProps {
open: boolean open: boolean
@@ -25,6 +20,7 @@ interface FormDrawerProps {
className?: string className?: string
} }
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
export function FormDrawer({ export function FormDrawer({
open, open,
onOpenChange, onOpenChange,
@@ -35,17 +31,22 @@ export function FormDrawer({
className, className,
}: FormDrawerProps) { }: FormDrawerProps) {
return ( return (
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right"> <Sheet open={open} onOpenChange={onOpenChange}>
<DrawerContent className={cn(formDrawerContentClassName, className)}> <SheetContent
<DrawerHeader className="shrink-0 border-b border-border pb-4"> side="right"
<DrawerTitle>{title}</DrawerTitle> className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
{description ? <DrawerDescription>{description}</DrawerDescription> : null} >
</DrawerHeader> <SheetHeader className="shrink-0 border-b border-border/50">
<ScrollArea className="min-h-0 flex-1"> <SheetTitle>{title}</SheetTitle>
<div className="space-y-4 px-4 py-4">{children}</div> {description ? <SheetDescription>{description}</SheetDescription> : null}
</ScrollArea> </SheetHeader>
<DrawerActionsFooter>{footer}</DrawerActionsFooter> <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
</DrawerContent> <SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
</Drawer> <div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{footer}
</div>
</SheetFooter>
</SheetContent>
</Sheet>
) )
} }
+13 -11
View File
@@ -8,7 +8,6 @@ import {
Settings, Settings,
BookText, BookText,
KeyRound, KeyRound,
ServerCog,
Search, Search,
} from 'lucide-react' } from 'lucide-react'
@@ -47,6 +46,7 @@ import { CommandPalette, type CommandPaletteItem } from '@/components/layout/com
import { NavUser } from '@/components/layout/nav-user' import { NavUser } from '@/components/layout/nav-user'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth' import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
interface NavItem { interface NavItem {
to: string to: string
@@ -71,7 +71,7 @@ const NAV_GROUPS: NavGroup[] = [
to: '/dashboard', to: '/dashboard',
label: 'Панель', label: 'Панель',
icon: LayoutDashboard, icon: LayoutDashboard,
description: 'KPI, модули и активность', description: 'Метрики, модули и активность',
}, },
], ],
}, },
@@ -81,23 +81,22 @@ const NAV_GROUPS: NavGroup[] = [
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' }, { to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' }, { to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } }, { to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' }, { to: '/directories', label: 'Справочники', icon: BookText, description: 'BGP community и DoH' },
], ],
}, },
{ {
label: 'Операции', label: 'Операции',
items: [ items: [
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } }, { to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' }, { to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } }, { to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
], ],
}, },
{ {
label: 'Система', label: 'Система',
items: [ items: [
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' }, { to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' }, { to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
], ],
}, },
] ]
@@ -161,6 +160,9 @@ export function AppShell({ children }: { children: ReactNode }) {
} as CSSProperties } as CSSProperties
} }
> >
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader> <SidebarHeader>
<AppSwitcher /> <AppSwitcher />
@@ -196,7 +198,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavUser /> <NavUser />
</SidebarFooter> </SidebarFooter>
</Sidebar> </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"> <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" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" /> <Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
@@ -220,9 +222,9 @@ export function AppShell({ children }: { children: ReactNode }) {
<SystemMonitorPopover /> <SystemMonitorPopover />
</div> </div>
</header> </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">
{children} {children}
</main> </div>
</SidebarInset> </SidebarInset>
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly /> <CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
</SidebarProvider> </SidebarProvider>
+1 -1
View File
@@ -91,7 +91,7 @@ export function AppsMenu() {
) : ( ) : (
<DropdownMenuItem <DropdownMenuItem
nativeButton={false} nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />} render={<Link to="/settings" search={{ tab: 'ui' }} />}
className="justify-center text-sm font-medium" className="justify-center text-sm font-medium"
> >
Настройки Настройки
+3 -3
View File
@@ -136,7 +136,7 @@ export function NavUser() {
setApiToken(null) setApiToken(null)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.localStorage.removeItem(TOKEN_STORAGE_KEY) window.localStorage.removeItem(TOKEN_STORAGE_KEY)
window.location.assign('/settings?tab=connection&reason=token-required') window.location.assign('/settings?tab=ui&reason=token-required')
} }
} }
@@ -190,10 +190,10 @@ export function NavUser() {
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
nativeButton={false} nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />} render={<Link to="/settings" search={{ tab: 'ui' }} />}
> >
<SettingsIcon aria-hidden /> <SettingsIcon aria-hidden />
Настройки UI Настройки
</DropdownMenuItem> </DropdownMenuItem>
{authOn ? ( {authOn ? (
<DropdownMenuItem <DropdownMenuItem
@@ -94,8 +94,8 @@ export function SystemMonitorPopover() {
() => [ () => [
{ {
id: 'api', id: 'api',
label: 'API health', label: 'Состояние API',
value: healthOk ? 'OK' : '—', value: healthOk ? 'норма' : '—',
unit: '', unit: '',
percent: healthOk ? 100 : 0, percent: healthOk ? 100 : 0,
icon: <HeartPulse aria-hidden />, icon: <HeartPulse aria-hidden />,
@@ -14,8 +14,8 @@ import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api' import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
const CDN_KIND_ITEMS = [ const CDN_KIND_ITEMS = [
{ value: 'plaintext', label: 'plaintext' }, { value: 'plaintext', label: 'Текст' },
{ value: 'json', label: 'json' }, { value: 'json', label: 'JSON' },
] as const ] as const
interface ModuleCdnSourceDialogProps { interface ModuleCdnSourceDialogProps {
@@ -160,7 +160,7 @@ export function ModuleEditDialog({
<div className="grid min-w-0 flex-1 gap-1 pr-2"> <div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="mod-enabled">Включён</Label> <Label htmlFor="mod-enabled">Включён</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Выключенный модуль не участвует в refresh и apply. Выключенный модуль не участвует в обновлении и применении.
</p> </p>
</div> </div>
<Checkbox <Checkbox
@@ -10,6 +10,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatDateTime } from '@/lib/modules/display' import { formatDateTime } from '@/lib/modules/display'
import { communityLabel } from '@/lib/modules/helpers' import { communityLabel } from '@/lib/modules/helpers'
import { cdnSourceKindRu } from '@/lib/ui-labels'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { import type {
AsEntry, AsEntry,
@@ -145,7 +146,7 @@ export function ModuleEntriesGrid({
accessorKey: 'source_kind', accessorKey: 'source_kind',
header: 'Тип', header: 'Тип',
cell: ({ row }: { row: { original: CdnSource } }) => ( cell: ({ row }: { row: { original: CdnSource } }) => (
<CategoryBadge>{row.original.source_kind}</CategoryBadge> <CategoryBadge>{cdnSourceKindRu(row.original.source_kind)}</CategoryBadge>
), ),
}, },
{ {
@@ -7,12 +7,20 @@ import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { jobStatusRu, readyCheckRu } from '@/lib/ui-labels' import {
isReadyCheckOk,
isSystemReady,
readyCheckStatusLabel,
} from '@/lib/metrics'
import { readyCheckRu } from '@/lib/ui-labels'
import type { ReadyStatus } from '@/queries/monitoring' import type { ReadyStatus } from '@/queries/monitoring'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { cn } from '@evobgp/ui/lib/utils'
const READY_CHECK_ICONS: Record<string, typeof Database> = { const READY_CHECK_ICONS: Record<string, typeof Database> = {
postgres: Database, postgres: Database,
store: HardDrive, store: HardDrive,
store_backend: HardDrive,
jobs: ListTodo, jobs: ListTodo,
} }
@@ -21,6 +29,7 @@ interface ReadyCheckRow {
label: string label: string
subtitle?: string subtitle?: string
icon: typeof Database icon: typeof Database
iconClassName?: string
status: string status: string
statusLabel: string statusLabel: string
} }
@@ -34,12 +43,14 @@ export function MonitoringReadyGrid({
}) { }) {
const data = useMemo<ReadyCheckRow[]>(() => { const data = useMemo<ReadyCheckRow[]>(() => {
const checks = ready.checks ?? {} const checks = ready.checks ?? {}
const systemReady = isSystemReady(ready.status)
const rows: ReadyCheckRow[] = [ const rows: ReadyCheckRow[] = [
{ {
id: 'liveness', id: 'liveness',
label: 'Живучесть', label: 'Живучесть',
subtitle: '/v1/health', subtitle: '/v1/health',
icon: HeartPulse, icon: HeartPulse,
iconClassName: health?.ok ? 'text-success' : 'text-destructive',
status: health?.ok ? 'ok' : 'error', status: health?.ok ? 'ok' : 'error',
statusLabel: health?.ok ? 'В норме' : 'Ошибка', statusLabel: health?.ok ? 'В норме' : 'Ошибка',
}, },
@@ -48,19 +59,21 @@ export function MonitoringReadyGrid({
label: 'Готовность', label: 'Готовность',
subtitle: '/v1/ready', subtitle: '/v1/ready',
icon: ShieldCheck, icon: ShieldCheck,
status: ready.status === 'ok' ? 'ok' : 'warning', iconClassName: systemReady ? 'text-success' : 'text-warning',
statusLabel: ready.status === 'ok' ? 'Готов' : jobStatusRu(ready.status ?? 'pending'), status: systemReady ? 'ok' : 'warning',
statusLabel: systemReady ? 'Готов' : 'Не готов',
}, },
] ]
for (const key of Object.keys(checks)) { for (const key of Object.keys(checks)) {
const value = checks[key] const value = checks[key]
const ok = typeof value === 'boolean' ? value : value?.ok !== false const ok = isReadyCheckOk(value)
rows.push({ rows.push({
id: key, id: key,
label: readyCheckRu(key), label: readyCheckRu(key),
icon: READY_CHECK_ICONS[key] ?? ListTodo, icon: READY_CHECK_ICONS[key] ?? ListTodo,
iconClassName: ok ? 'text-success' : 'text-destructive',
status: ok ? 'ok' : 'error', status: ok ? 'ok' : 'error',
statusLabel: ok ? 'В норме' : 'Ошибка', statusLabel: readyCheckStatusLabel(value, ok),
}) })
} }
return rows return rows
@@ -74,8 +87,17 @@ export function MonitoringReadyGrid({
cell: ({ row }) => { cell: ({ row }) => {
const Icon = row.original.icon const Icon = row.original.icon
return ( return (
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2.5 py-0.5">
<Icon className="size-4 shrink-0 text-muted-foreground" /> <Item
className={cn(
'border-background bg-muted flex size-8 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-3.5',
row.original.iconClassName,
)}
>
<ItemMedia variant="icon" className="size-auto">
<Icon />
</ItemMedia>
</Item>
<DataGridPrimaryCell <DataGridPrimaryCell
title={row.original.label} title={row.original.label}
subtitle={row.original.subtitle} subtitle={row.original.subtitle}
@@ -90,7 +112,9 @@ export function MonitoringReadyGrid({
enableSorting: false, enableSorting: false,
header: 'Статус', header: 'Статус',
cell: ({ row }) => ( cell: ({ row }) => (
<StatusBadge status={row.original.status} label={row.original.statusLabel} /> <div className="flex justify-start">
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
</div>
), ),
meta: { headerTitle: 'Статус' }, meta: { headerTitle: 'Статус' },
}, },
@@ -35,7 +35,7 @@ import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api'
function speakerLabel(s: SpeakerRow): string { function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') { if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)' return host ? `Плоскость · ${host}` : 'Плоскость управления'
} }
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}` return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
} }
@@ -51,7 +51,7 @@ const filterFields: FilterFieldConfig[] = [
icon: <SearchIcon className="size-3.5" aria-hidden />, icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text', type: 'text',
className: 'w-48', className: 'w-48',
placeholder: 'IP или Neighbor ID…', placeholder: 'IP или ID соседа…',
}, },
] ]
@@ -94,7 +94,7 @@ export function NetworkDiscoveredPeersCard({
id: 'neighbor_id', id: 'neighbor_id',
accessorFn: (row) => row.neighbor_id || row.neighbor, accessorFn: (row) => row.neighbor_id || row.neighbor,
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Neighbor ID" /> <DataGridColumnHeader column={column} title="ID соседа" />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell <DataGridPrimaryCell
@@ -103,7 +103,7 @@ export function NetworkDiscoveredPeersCard({
accent="mono" accent="mono"
/> />
), ),
meta: { headerTitle: 'Neighbor ID' }, meta: { headerTitle: 'ID соседа' },
}, },
{ {
accessorKey: 'remote_asn', accessorKey: 'remote_asn',
@@ -212,7 +212,7 @@ export function NetworkDiscoveredPeersCard({
title="Одобрить пира" title="Одобрить пира"
description={ description={
approveTarget approveTarget
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}` ? `ID соседа ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
: undefined : undefined
} }
className="sm:max-w-sm" className="sm:max-w-sm"
@@ -30,7 +30,7 @@ export function NetworkKpi({
id: 'peers-established', id: 'peers-established',
icon: <Share2 aria-hidden />, icon: <Share2 aria-hidden />,
iconClassName: 'text-success', iconClassName: 'text-success',
label: 'Пиры Established', label: 'Пиры установлены',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`, value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
footer: ( footer: (
<Badge variant="success-light" size="sm"> <Badge variant="success-light" size="sm">
@@ -44,7 +44,7 @@ export function NetworkKpi({
id: 'speakers-online', id: 'speakers-online',
icon: <ServerCog aria-hidden />, icon: <ServerCog aria-hidden />,
iconClassName: 'text-info', iconClassName: 'text-info',
label: 'Спикеры online', label: 'Спикеры в сети',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`, value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
footer: ( footer: (
<Badge <Badge
@@ -55,7 +55,7 @@ export function NetworkKpi({
} }
size="sm" size="sm"
> >
{loading ? '…' : 'live'} {loading ? '…' : 'в сети'}
</Badge> </Badge>
), ),
to: '/network', to: '/network',
@@ -32,18 +32,18 @@ function peerDeleteLabel(p: PeerRow): string {
const PEER_TABS = [ const PEER_TABS = [
{ id: 'all', label: 'Все' }, { id: 'all', label: 'Все' },
{ id: 'established', label: 'Established' }, { id: 'established', label: 'Установлены' },
{ id: 'pending', label: 'Ожидание' }, { id: 'pending', label: 'Ожидание' },
{ id: 'disabled', label: 'Выключены' }, { id: 'disabled', label: 'Выключены' },
] ]
const SESSION_STATE_OPTIONS = [ const SESSION_STATE_OPTIONS = [
{ value: 'Established', label: 'Established' }, { value: 'Established', label: 'Установлена' },
{ value: 'Idle', label: 'Idle' }, { value: 'Idle', label: 'Простой' },
{ value: 'Active', label: 'Active' }, { value: 'Active', label: 'Поиск' },
{ value: 'Connect', label: 'Connect' }, { value: 'Connect', label: 'Соединение' },
{ value: 'OpenSent', label: 'OpenSent' }, { value: 'OpenSent', label: 'Open отправлен' },
{ value: 'OpenConfirm', label: 'OpenConfirm' }, { value: 'OpenConfirm', label: 'Open подтверждён' },
] ]
function createDefaultPeerFilters(): Filter[] { function createDefaultPeerFilters(): Filter[] {
@@ -31,14 +31,14 @@ function speakerDeleteLabel(s: SpeakerRow): string {
const SPEAKER_TABS = [ const SPEAKER_TABS = [
{ id: 'all', label: 'Все' }, { id: 'all', label: 'Все' },
{ id: 'online', label: 'Online' }, { id: 'online', label: 'В сети' },
{ id: 'offline', label: 'Offline' }, { id: 'offline', label: 'Не в сети' },
] ]
const ROLE_OPTIONS = [ const ROLE_OPTIONS = [
{ value: 'primary', label: 'primary' }, { value: 'primary', label: 'Основной' },
{ value: 'secondary', label: 'secondary' }, { value: 'secondary', label: 'Резервный' },
{ value: 'speaker', label: 'speaker' }, { value: 'speaker', label: 'Спикер' },
] ]
function createDefaultSpeakerFilters(): Filter[] { function createDefaultSpeakerFilters(): Filter[] {
@@ -52,7 +52,7 @@ const speakerFilterFields: FilterFieldConfig[] = [
icon: <SearchIcon className="size-3.5" aria-hidden />, icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text', type: 'text',
className: 'w-52', className: 'w-52',
placeholder: 'endpoint…', placeholder: 'Адрес агента…',
}, },
{ {
key: 'role', key: 'role',
@@ -128,7 +128,7 @@ export function NetworkSpeakersCard({
<> <>
<ResourcePage <ResourcePage
title="Спикеры" title="Спикеры"
description="BIRD-агенты на нодах tenant" description="BIRD-агенты на нодах арендатора"
tabs={SPEAKER_TABS} tabs={SPEAKER_TABS}
tabFilter={speakerTabFilter} tabFilter={speakerTabFilter}
filterFields={speakerFilterFields} filterFields={speakerFilterFields}
@@ -5,7 +5,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { speakerOnlineLabel } from '@/lib/ui-labels' import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api' import type { SpeakerRow } from '@/types/api'
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
@@ -20,7 +20,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
{ {
accessorKey: 'role', accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>, cell: ({ row }) => <CategoryBadge>{speakerRoleRu(row.original.role)}</CategoryBadge>,
meta: { headerTitle: 'Роль' }, meta: { headerTitle: 'Роль' },
}, },
{ {
@@ -22,7 +22,7 @@ interface PeerFormDialogProps {
function speakerLabel(s: SpeakerRow): string { function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') { if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)' return host ? `Плоскость · ${host}` : 'Плоскость управления'
} }
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}` return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
} }
@@ -72,7 +72,7 @@ export function PeerFormDialog({
} }
const asn = Number(remoteAsn) const asn = Number(remoteAsn)
if (!asn || asn <= 0) { if (!asn || asn <= 0) {
toast.error('Remote ASN должен быть больше 0') toast.error('ASN соседа должен быть больше 0')
return return
} }
const body: BgpPeerCreate = { const body: BgpPeerCreate = {
@@ -132,7 +132,7 @@ export function PeerFormDialog({
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="peer-asn">Remote ASN</Label> <Label htmlFor="peer-asn">ASN соседа</Label>
<Input <Input
id="peer-asn" id="peer-asn"
type="number" type="number"
@@ -74,7 +74,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
const ep = const ep =
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '') endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
if (!ep) { if (!ep) {
toast.error('Укажите endpoint или agent domain') toast.error('Укажите конечную точку или домен агента')
return return
} }
const body: BgpSpeakerCreate = { const body: BgpSpeakerCreate = {
@@ -95,7 +95,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title="Новый спикер" title="Новый спикер"
description="BIRD-агент на ноде реплики или control plane" description="BIRD-агент на ноде реплики или плоскости управления"
className="sm:max-w-md" className="sm:max-w-md"
footer={ footer={
<> <>
@@ -122,7 +122,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
label="Роль" label="Роль"
items={[ items={[
{ value: 'replica', label: 'Реплика' }, { value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер (CP)' }, { value: 'master', label: 'Мастер (плоскость)' },
]} ]}
value={role} value={role}
onValueChange={(v) => setRole(v ?? 'replica')} onValueChange={(v) => setRole(v ?? 'replica')}
@@ -146,7 +146,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label> <Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
<Input <Input
id="speaker-bgp-source" id="speaker-bgp-source"
placeholder="203.0.113.10" placeholder="203.0.113.10"
@@ -77,7 +77,7 @@ export function OperationsRevisionsGrid({
</Button> </Button>
} }
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`} title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator." description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
confirmLabel="Откатить" confirmLabel="Откатить"
destructive destructive
onConfirm={() => rollbackMutation.mutate(row.original.id)} onConfirm={() => rollbackMutation.mutate(row.original.id)}
+8 -4
View File
@@ -8,12 +8,16 @@ interface PageHeaderProps {
export function PageHeader({ title, description, actions }: PageHeaderProps) { export function PageHeader({ title, description, actions }: PageHeaderProps) {
return ( return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="flex flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1> <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null} {description ? (
<p className="text-muted-foreground max-w-prose text-sm break-words">{description}</p>
) : null}
</div> </div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null} {actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null}
</div> </div>
) )
} }
+1 -1
View File
@@ -54,7 +54,7 @@ export function PanelCard({
<Frame <Frame
dense dense
spacing={spacing} spacing={spacing}
className={cn(panelCardClassName, className)} className={cn(panelCardClassName, 'min-w-0', className)}
> >
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs"> <FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? ( {hasHeader ? (
@@ -8,7 +8,7 @@ import {
} from '@evobgp/ui/components/chart' } from '@evobgp/ui/components/chart'
import type { BreakdownSlice } from '@/lib/metrics' import type { BreakdownSlice } from '@/lib/metrics'
/** chart-27 / chart-13 inspired donut in PanelCard. */ /** chart-27 / chart-13 inspired donut in PanelCard (Frame surface). */
export function DonutBreakdownCard({ export function DonutBreakdownCard({
title, title,
description, description,
@@ -30,13 +30,24 @@ export function DonutBreakdownCard({
const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count })) const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count }))
return ( return (
<PanelCard title={title} description={description} className="h-full"> <PanelCard
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center"> title={title}
description={description}
className="h-full"
actions={
badge ? (
<Badge variant="success-light" className="hidden sm:inline-flex">
{badge}
</Badge>
) : undefined
}
>
<div className="flex flex-col items-center justify-start gap-4 p-4 sm:flex-row sm:items-center sm:justify-start sm:gap-6">
{total === 0 ? ( {total === 0 ? (
<p className="text-muted-foreground w-full py-8 text-center text-sm">Нет данных</p> <p className="text-muted-foreground w-full py-8 text-center text-sm">Нет данных</p>
) : ( ) : (
<> <>
<div className="relative mx-auto size-36 shrink-0"> <div className="relative mx-auto size-36 shrink-0 sm:mx-0">
<ChartContainer config={chartConfig} className="aspect-square size-36"> <ChartContainer config={chartConfig} className="aspect-square size-36">
<PieChart> <PieChart>
<Pie <Pie
@@ -59,11 +70,11 @@ export function DonutBreakdownCard({
<span className="text-lg font-semibold tabular-nums">{total}</span> <span className="text-lg font-semibold tabular-nums">{total}</span>
</div> </div>
</div> </div>
<ul className="min-w-0 flex-1 space-y-2"> <ul className="flex w-full min-w-0 max-w-xs flex-col gap-2 sm:w-auto sm:min-w-[10rem]">
{slices.map((slice) => { {slices.map((slice) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0' const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return ( return (
<li key={slice.key} className="flex items-center justify-between gap-2 text-sm"> <li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span <span
className="size-2.5 shrink-0 rounded-full" className="size-2.5 shrink-0 rounded-full"
@@ -81,11 +92,6 @@ export function DonutBreakdownCard({
</ul> </ul>
</> </>
)} )}
{badge ? (
<Badge variant="success-light" className="absolute top-4 right-4 hidden sm:flex">
{badge}
</Badge>
) : null}
</div> </div>
</PanelCard> </PanelCard>
) )
@@ -2,7 +2,10 @@ import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack' import { IconStack } from '@/components/reui/icon-stack'
import { Card, CardContent } from '@evobgp/ui/components/card' import {
Frame,
FramePanel,
} from '@/components/reui/frame'
import { import {
Empty, Empty,
EmptyDescription, EmptyDescription,
@@ -11,7 +14,7 @@ import {
EmptyTitle, EmptyTitle,
} from '@evobgp/ui/components/empty' } from '@evobgp/ui/components/empty'
/** empty-state-11 pattern adapted to Card surface. */ /** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
export function IllustratedEmptyState({ export function IllustratedEmptyState({
title, title,
description, description,
@@ -24,8 +27,8 @@ export function IllustratedEmptyState({
action?: ReactNode action?: ReactNode
}) { }) {
return ( return (
<Card className="bg-muted min-h-[240px] p-0 shadow-none"> <Frame dense spacing="sm" className="w-full">
<CardContent className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10"> <FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
{action ? <div className="w-full self-end">{action}</div> : null} {action ? <div className="w-full self-end">{action}</div> : null}
<Empty className="max-w-md gap-5 bg-transparent p-0"> <Empty className="max-w-md gap-5 bg-transparent p-0">
<EmptyHeader className="items-center gap-5 text-center"> <EmptyHeader className="items-center gap-5 text-center">
@@ -40,7 +43,7 @@ export function IllustratedEmptyState({
</div> </div>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
</CardContent> </FramePanel>
</Card> </Frame>
) )
} }
@@ -4,7 +4,7 @@ import { Badge } from '@/components/reui/badge'
import { CardDotField } from '@/components/dashboard/card-dot-field' import { CardDotField } from '@/components/dashboard/card-dot-field'
import { PanelCorners } from '@/components/patterns/panel-corners' import { PanelCorners } from '@/components/patterns/panel-corners'
import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles' import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles'
import { Card, CardContent } from '@evobgp/ui/components/card' import { Frame, FramePanel } from '@/components/reui/frame'
export type KpiSparklineMetric = { export type KpiSparklineMetric = {
id: string id: string
@@ -44,21 +44,21 @@ function Sparkline({ values, color }: { values: readonly number[]; color: string
) )
} }
/** KPI card with sparkline (dashboard-5 / chart-15 pattern, Card surface). */ /** KPI sparkline tile — chart-15 Frame DNA. Preview: https://reui.io/preview/base/chart-15 */
export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) { export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
const tone = toneStyles[metric.tone] const tone = toneStyles[metric.tone]
return ( return (
<Card className="relative overflow-hidden p-0"> <Frame dense spacing="sm" className="relative overflow-hidden">
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" /> <FramePanel className="relative isolate flex min-h-[7.25rem] flex-col justify-between gap-5 overflow-hidden p-4">
<PanelCorners /> <CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4"> <PanelCorners />
<div className="flex min-w-0 flex-col gap-0.5"> <div className="relative z-10 flex min-w-0 flex-col gap-0.5">
<span className="text-foreground truncate text-sm leading-4 font-semibold">{metric.title}</span> <span className="text-foreground truncate text-sm leading-4 font-semibold">{metric.title}</span>
<span className="text-muted-foreground truncate text-xs leading-4">{metric.label}</span> <span className="text-muted-foreground truncate text-xs leading-4">{metric.label}</span>
</div> </div>
<div className="flex items-end justify-between gap-4"> <div className="relative z-10 flex items-end justify-between gap-4">
<div className="min-w-0 space-y-2.5"> <div className="flex min-w-0 flex-col gap-2.5">
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight tabular-nums"> <div className="text-foreground text-2xl leading-none font-semibold tracking-tight tabular-nums">
{metric.value} {metric.value}
</div> </div>
@@ -71,7 +71,7 @@ export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
</div> </div>
<Sparkline values={metric.sparkline} color={tone.stroke} /> <Sparkline values={metric.sparkline} color={tone.stroke} />
</div> </div>
</CardContent> </FramePanel>
</Card> </Frame>
) )
} }
+20 -14
View File
@@ -34,23 +34,29 @@ export function QueryState<T>({
children, children,
}: QueryStateProps<T>) { }: QueryStateProps<T>) {
if (isLoading) { if (isLoading) {
return <>{skeleton ?? <DefaultSkeleton />}</> return (
<div role="status" aria-live="polite" aria-busy="true">
{skeleton ?? <DefaultSkeleton />}
</div>
)
} }
if (isError) { if (isError) {
return ( return (
<EmptyState <div role="alert">
icon={<AlertCircle className="size-8" />} <EmptyState
title="Ошибка загрузки" icon={AlertCircle}
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'} title="Ошибка загрузки"
action={ description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
onRetry ? ( action={
<Button variant="outline" size="sm" onClick={onRetry}> onRetry ? (
<RefreshCwIcon data-icon="inline-start" /> <Button variant="outline" size="sm" onClick={onRetry}>
Повторить <RefreshCwIcon data-icon="inline-start" />
</Button> Повторить
) : null </Button>
} ) : null
/> }
/>
</div>
) )
} }
if (empty || data == null) { if (empty || data == null) {
@@ -29,10 +29,10 @@ export function FrameDataGrid({
}) { }) {
const hasHeader = Boolean(title || description || actions) const hasHeader = Boolean(title || description || actions)
return ( return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}> <Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs"> <FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? ( {hasHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3 border-b"> <FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-px"> <div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null} {title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? ( {description ? (
+1 -1
View File
@@ -24,4 +24,4 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard' export { OpsDashboard } from './ops-dashboard'
export { FrameDataGrid } from './frame-data-grid' export { FrameDataGrid } from './frame-data-grid'
export { DetailPanel, type DetailMetricCard } from './detail-panel' export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell' export { SettingsShell } from './settings-shell'
+7 -4
View File
@@ -1,10 +1,13 @@
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */ /**
* Shared grid column classes for hybrid KPI / Quick Actions tiles.
* Container queries only never 56 columns on laptop width (tiles overlap).
* @see https://reui.io/preview/base/stats-12
*/
export function kpiCols(count: number): string { export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1' if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2' if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3' if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4' if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5' if (count <= 6) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6' return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
} }
@@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
import { kpiCols } from './kpi-cols' import { kpiCols } from './kpi-cols'
import { Item, ItemMedia } from '@evobgp/ui/components/item' import { IconTile } from '@/components/reui/icon-tile'
import { Skeleton } from '@evobgp/ui/components/skeleton' import { Skeleton } from '@evobgp/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive' export type KpiStatVariant = 'default' | 'warning' | 'destructive'
@@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
return ( return (
<div className="relative z-10 flex h-full items-start gap-3"> <div className="relative z-10 flex h-full items-start gap-3">
{item.icon ? ( {item.icon ? (
<Item <IconTile
className={cn( variant="elevated"
'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', aria-hidden="true"
item.iconClassName ?? DEFAULT_ICON_CLASS, className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
)}
> >
<ItemMedia variant="icon" className="size-auto"> {item.icon}
{item.icon} </IconTile>
</ItemMedia>
</Item>
) : null} ) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">
@@ -308,7 +305,7 @@ export function KpiStatGrid({
} }
return ( return (
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}> <Frame className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}> <div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => ( {list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} /> <KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
@@ -1,11 +1,4 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@evobgp/ui/components/skeleton' import { Skeleton } from '@evobgp/ui/components/skeleton'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid' import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
@@ -27,7 +20,7 @@ interface OpsDashboardProps {
} }
const rootClassName = const rootClassName =
'text-foreground @container flex w-full flex-col gap-2 md:gap-3' 'text-foreground @container flex w-full min-w-0 flex-col gap-4 md:gap-6'
function OpsDashboardSkeleton() { function OpsDashboardSkeleton() {
return ( return (
@@ -37,7 +30,7 @@ function OpsDashboardSkeleton() {
<Skeleton className="mt-2 h-4 w-80 max-w-full" /> <Skeleton className="mt-2 h-4 w-80 max-w-full" />
</header> </header>
<KpiStatGrid cards={[]} isLoading skeletonCount={4} /> <KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<div className="grid gap-2 @3xl:grid-cols-2"> <div className="flex min-w-0 flex-col gap-4">
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-64 w-full rounded-xl" />
</div> </div>
@@ -47,9 +40,11 @@ function OpsDashboardSkeleton() {
} }
/** /**
* Ops dashboard: KPI optional Quick Actions charts attention queue. * Ops dashboard: KPI optional Quick Actions charts queue.
* Queue is a sibling Frame grid never wrap FrameDataGrid inside another Frame.
* @see https://reui.io/preview/base/dashboard-1 * @see https://reui.io/preview/base/dashboard-1
* @see https://reui.io/preview/base/stats-12 * @see https://reui.io/preview/base/stats-12
* @see https://reui.io/docs/components/base/frame
*/ */
export function OpsDashboard({ export function OpsDashboard({
title = 'Панель управления', title = 'Панель управления',
@@ -77,21 +72,18 @@ export function OpsDashboard({
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null} {afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
<section <section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
aria-label="Аналитика"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
{charts} {charts}
</section> </section>
<section aria-label={queueTitle}> <section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
<Frame dense spacing="sm" className="w-full"> <div className="flex min-w-0 flex-col gap-1">
<FrameHeader> <h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
<FrameTitle>{queueTitle}</FrameTitle> {queueDescription ? (
<FrameDescription>{queueDescription}</FrameDescription> <p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
</FrameHeader> ) : null}
<FramePanel>{queue}</FramePanel> </div>
</Frame> {queue}
</section> </section>
</div> </div>
) )
@@ -8,7 +8,7 @@ import {
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@evobgp/ui/components/item' import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
import { kpiCols } from './kpi-cols' import { kpiCols } from './kpi-cols'
@@ -54,16 +54,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
return ( return (
<div className="relative z-10 flex h-full items-start gap-3"> <div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? ( {action.icon ? (
<Item <IconTile
className={cn( variant="elevated"
'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', aria-hidden="true"
action.iconClassName ?? DEFAULT_ICON_CLASS, className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
)}
> >
<ItemMedia variant="icon" className="size-auto"> {action.icon}
{action.icon} </IconTile>
</ItemMedia>
</Item>
) : null} ) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">
@@ -39,6 +39,8 @@ import {
AlertTitle, AlertTitle,
} from '@/components/reui/alert' } from '@/components/reui/alert'
import { EmptyState } from '@/components/empty-state' import { EmptyState } from '@/components/empty-state'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { FILTERS_I18N_RU } from '@/lib/filters-i18n'
import { applyFiltersToData } from './filter-utils' import { applyFiltersToData } from './filter-utils'
export interface ResourcePageTab { export interface ResourcePageTab {
@@ -322,6 +324,7 @@ export function ResourcePage<T extends object>({
fields={filterFields} fields={filterFields}
onChange={handleFiltersChange} onChange={handleFiltersChange}
size="default" size="default"
i18n={FILTERS_I18N_RU}
trigger={ trigger={
<Button type="button" variant="outline" aria-label="Фильтры"> <Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" /> <FilterIcon className="size-4" aria-hidden="true" />
@@ -355,11 +358,8 @@ export function ResourcePage<T extends object>({
<FrameFooter> <FrameFooter>
<DataGridPagination <DataGridPagination
{...DATA_GRID_PAGINATION_RU}
sizes={[5, 10, 20, 50]} sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/> />
</FrameFooter> </FrameFooter>
</FramePanel> </FramePanel>
@@ -1,104 +1,17 @@
import type { ReactNode } from 'react' import { Outlet } from '@tanstack/react-router'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
import { cn } from '@evobgp/ui/lib/utils'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
export interface SettingsTabConfig {
id: string
to: string
label: string
icon?: ReactNode
}
/** Default nav for EvoBGP settings routes (`/settings`, `/tenant-settings`). */
const DEFAULT_TABS: SettingsTabConfig[] = [
{
id: 'ui',
to: '/settings',
label: 'Настройки UI',
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
},
{
id: 'tenant',
to: '/tenant-settings',
label: 'Настройки BIRD',
icon: <ServerCogIcon className="size-4" aria-hidden="true" />,
},
]
interface SettingsShellProps {
title?: string
description?: string
tabs?: SettingsTabConfig[]
}
/** /**
* Settings shell Frame surface, no hairline Separator under header. * Settings layout page chrome only.
* @see https://reui.io/preview/base/settings-16 * Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings).
* @see https://reui.io/preview/base/settings-3 * @see https://reui.io/preview/base/settings-7
* @see https://reui.io/blocks
*/ */
export function SettingsShell({ export function SettingsShell() {
title = 'Настройки',
description = 'Подключение UI и параметры плоскости управления',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
return ( return (
<PageShell> <PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5"> <Outlet />
<PageHeader title={title} description={description} />
<div
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? (
<nav
aria-label="Разделы настроек"
className={cn(
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
: 'w-44 shrink-0 flex-col',
)}
>
{tabs.map((tab) => {
const isActive = pathname.startsWith(tab.to)
return (
<Link
key={tab.id}
to={tab.to}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{tab.icon}
{tab.label}
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
<Outlet />
</div>
</div>
</div>
</PageShell> </PageShell>
) )
} }
@@ -61,7 +61,7 @@ function DataGridColumnFilter<TData, TValue>({
<div className="hidden space-x-1 lg:flex"> <div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? ( {selectedValues.size > 2 ? (
<Badge variant="secondary" className="px-1 font-normal"> <Badge variant="secondary" className="px-1 font-normal">
{selectedValues.size} selected {selectedValues.size} выбрано
</Badge> </Badge>
) : ( ) : (
options options
@@ -94,7 +94,7 @@ function DataGridColumnFilter<TData, TValue>({
<div className="max-h-[300px] overflow-y-auto"> <div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? ( {filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm"> <div className="text-muted-foreground py-6 text-center text-sm">
No results found. Ничего не найдено.
</div> </div>
) : ( ) : (
<div className="p-1"> <div className="p-1">
@@ -170,7 +170,7 @@ function DataGridColumnFilter<TData, TValue>({
}} }}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none" className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
> >
Clear filters Сбросить фильтры
</div> </div>
</div> </div>
</> </>
@@ -39,11 +39,11 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
sizes: [5, 10, 25, 50, 100], sizes: [5, 10, 25, 50, 100],
sizesSkeleton: <Skeleton className="h-8 w-44" />, sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5, moreLimit: 5,
info: "{from} - {to} of {count}", info: "{from}{to} из {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />, infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page", rowsPerPageLabel: "Строк на странице",
previousPageLabel: "Go to previous page", previousPageLabel: "Предыдущая страница",
nextPageLabel: "Go to next page", nextPageLabel: "Следующая страница",
ellipsisText: "...", ellipsisText: "...",
} }
@@ -73,7 +73,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing", "size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className className
)} )}
aria-label="Drag to reorder row" aria-label="Перетащить строку"
disabled disabled
> >
<GripHorizontalIcon aria-hidden="true" /> <GripHorizontalIcon aria-hidden="true" />
@@ -89,7 +89,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing", "size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className className
)} )}
aria-label="Drag to reorder row" aria-label="Перетащить строку"
{...context.attributes} {...context.attributes}
{...context.listeners} {...context.listeners}
> >
@@ -105,7 +105,7 @@ function DataGridTableDndHeader<TData>({
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`} className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes} {...attributes}
{...listeners} {...listeners}
aria-label="Drag to reorder" aria-label="Перетащить для изменения порядка"
> >
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" /> <GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button> </Button>
@@ -397,9 +397,9 @@ function DataGridTableVirtual<TData>({
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage = const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..." props.fetchingMoreMessage || props.loadingMessage || "Загрузка…"
const allRowsLoadedMessage = const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded" props.allRowsLoadedMessage || "Все записи загружены"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => { const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({ setViewportElements({
@@ -1241,7 +1241,7 @@ function DataGridTableEmpty() {
colSpan={Math.max(visibleColumnCount, 1)} colSpan={Math.max(visibleColumnCount, 1)}
className="text-muted-foreground py-6 text-center text-sm" className="text-muted-foreground py-6 text-center text-sm"
> >
{props.emptyMessage || "No data available"} {props.emptyMessage || "Нет данных"}
</td> </td>
</tr> </tr>
) )
@@ -1254,7 +1254,7 @@ function DataGridTableLoader() {
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium"> <div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium">
<Spinner className="size-5 opacity-60" /> <Spinner className="size-5 opacity-60" />
{props.loadingMessage || "Loading..."} {props.loadingMessage || "Загрузка…"}
</div> </div>
</div> </div>
) )
@@ -1266,7 +1266,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
return ( return (
<button <button
type="button" type="button"
aria-label={isPinned ? "Unpin row" : "Pin row"} aria-label={isPinned ? "Открепить строку" : "Закрепить строку"}
onClick={() => { onClick={() => {
if (isPinned) { if (isPinned) {
row.pin(false) row.pin(false)
@@ -1322,7 +1322,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
<Checkbox <Checkbox
checked={row.getIsSelected()} checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)} onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row" aria-label="Выбрать строку"
className="align-[inherit]" className="align-[inherit]"
/> />
</> </>
@@ -1341,7 +1341,7 @@ function DataGridTableRowSelectAll() {
indeterminate={isSomeSelected && !isAllSelected} indeterminate={isSomeSelected && !isAllSelected}
disabled={isLoading || recordCount === 0} disabled={isLoading || recordCount === 0}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all" aria-label="Выбрать все"
className="align-[inherit]" className="align-[inherit]"
/> />
) )
@@ -1408,7 +1408,7 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path> ></path>
</svg> </svg>
{props.loadingMessage || "Loading..."} {props.loadingMessage || "Загрузка…"}
</div> </div>
</td> </td>
</tr> </tr>
+49 -54
View File
@@ -123,75 +123,70 @@ export interface FilterI18nConfig {
// Default English i18n configuration // Default English i18n configuration
export const DEFAULT_I18N: FilterI18nConfig = { export const DEFAULT_I18N: FilterI18nConfig = {
// UI Labels addFilter: "Фильтр",
addFilter: "Filter", searchFields: "Фильтр…",
searchFields: "Filter...", noFieldsFound: "Поля не найдены.",
noFieldsFound: "No filters found.", noResultsFound: "Ничего не найдено.",
noResultsFound: "No results found.", select: "Выбрать…",
select: "Select...", true: "Да",
true: "True", false: "Нет",
false: "False", min: "Мин.",
min: "Min", max: "Макс.",
max: "Max", to: "",
to: "to", typeAndPressEnter: "Введите и нажмите Enter",
typeAndPressEnter: "Type and press Enter to add tag", selected: "выбрано",
selected: "selected", selectedCount: "выбрано",
selectedCount: "selected",
percent: "%", percent: "%",
defaultCurrency: "$", defaultCurrency: "$",
defaultColor: "#000000", defaultColor: "#000000",
addFilterTitle: "Add filter", addFilterTitle: "Добавить фильтр",
// Operators
operators: { operators: {
is: "is", is: "равно",
isNot: "is not", isNot: "не равно",
isAnyOf: "is any of", isAnyOf: "любое из",
isNotAnyOf: "is not any of", isNotAnyOf: "кроме",
includesAll: "includes all", includesAll: "включает все",
excludesAll: "excludes all", excludesAll: "исключает все",
before: "before", before: "до",
after: "after", after: "после",
between: "between", between: "между",
notBetween: "not between", notBetween: "вне диапазона",
contains: "contains", contains: "содержит",
notContains: "does not contain", notContains: "не содержит",
startsWith: "starts with", startsWith: "начинается с",
endsWith: "ends with", endsWith: "заканчивается на",
isExactly: "is exactly", isExactly: "точно",
equals: "equals", equals: "равно",
notEquals: "not equals", notEquals: "не равно",
greaterThan: "greater than", greaterThan: "больше",
lessThan: "less than", lessThan: "меньше",
overlaps: "overlaps", overlaps: "пересекается",
includes: "includes", includes: "включает",
excludes: "excludes", excludes: "исключает",
includesAllOf: "includes all of", includesAllOf: "включает все",
includesAnyOf: "includes any of", includesAnyOf: "включает любое",
empty: "is empty", empty: "пусто",
notEmpty: "is not empty", notEmpty: "не пусто",
}, },
// Placeholders
placeholders: { placeholders: {
enterField: (fieldType: string) => `Enter ${fieldType}...`, enterField: (fieldType: string) => `Введите ${fieldType}`,
selectField: "Select...", selectField: "Выбрать…",
searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`, searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}`,
enterKey: "Enter key...", enterKey: "Введите ключ…",
enterValue: "Enter value...", enterValue: "Введите значение…",
}, },
// Helper functions
helpers: { helpers: {
formatOperator: (operator: string) => operator.replace(/_/g, " "), formatOperator: (operator: string) => operator.replace(/_/g, " "),
}, },
// Validation
validation: { validation: {
invalidEmail: "Invalid email format", invalidEmail: "Некорректный email",
invalidUrl: "Invalid URL format", invalidUrl: "Некорректный URL",
invalidTel: "Invalid phone format", invalidTel: "Некорректный телефон",
invalid: "Invalid input format", invalid: "Некорректное значение",
}, },
} }
+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 "@evobgp/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 }
@@ -107,7 +107,7 @@ export function ScheduleAgendaPanel({
return ( return (
<PanelCard <PanelCard
title="Календарь задач" title="Календарь задач"
description="Задачи refresh и apply по дням" description="Задачи обновления и применения по дням"
contentClassName={cn(panelCardContentFlushClassName, 'p-0')} contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
> >
<div className="flex flex-col lg:flex-row"> <div className="flex flex-col lg:flex-row">
@@ -133,7 +133,9 @@ export function ScheduleAgendaPanel({
</div> </div>
<Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}> <Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}>
<SelectTrigger size="sm" className="w-full sm:w-44"> <SelectTrigger size="sm" className="w-full sm:w-44">
<SelectValue /> <SelectValue>
{FILTER_ITEMS.find((item) => item.value === filter)?.label}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FILTER_ITEMS.map((item) => ( {FILTER_ITEMS.map((item) => (
@@ -43,7 +43,7 @@ export function ScheduleModulesGrid({
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground"> <span className="font-mono text-xs text-muted-foreground">
{row.original.cron_expr ?? {row.original.cron_expr ??
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec}s` : '—')} (row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec} с` : '—')}
</span> </span>
), ),
meta: { headerTitle: 'Расписание' }, meta: { headerTitle: 'Расписание' },
@@ -3,9 +3,9 @@ import { useTheme } from 'next-themes'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row' import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card' import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group' import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { import {
ToggleGroup, ToggleGroup,
@@ -44,7 +44,7 @@ export function AppearanceSettingsTab() {
}) })
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-6">
<SettingsCard <SettingsCard
title="Тема интерфейса" title="Тема интерфейса"
description="Быстрый переключатель также доступен в боковой панели" description="Быстрый переключатель также доступен в боковой панели"
@@ -61,6 +61,7 @@ export function AppearanceSettingsTab() {
мгновенно мгновенно
</Badge> </Badge>
} }
stacked
last last
> >
<ToggleGroup <ToggleGroup
@@ -72,7 +73,7 @@ export function AppearanceSettingsTab() {
variant="outline" variant="outline"
size="sm" size="sm"
aria-label="Тема интерфейса" aria-label="Тема интерфейса"
className="w-full sm:w-auto" className="w-full min-w-0"
> >
{THEME_OPTIONS.map((option) => { {THEME_OPTIONS.map((option) => {
const Icon = option.icon const Icon = option.icon
@@ -89,16 +90,16 @@ export function AppearanceSettingsTab() {
</SettingsCard> </SettingsCard>
<SettingsCard <SettingsCard
title="Дашборд" title="Обзор"
description="Блоки на экране «Обзор»" description="Блоки на экране «Обзор»"
> >
<SettingsFieldGroup <SettingsFieldGroup
legend="Быстрые действия" legend="Быстрые действия"
description="Показывать KPI-like плитки быстрых переходов под метриками." description="Показывать плитки быстрых переходов под метриками."
> >
<SettingRow <SettingRow
title="Быстрые действия" title="Быстрые действия"
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде." description="Блок с частыми переходами (модули, сеть, деплой) на экране «Обзор»."
last last
> >
<Switch <Switch
@@ -0,0 +1,341 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Input } from '@evobgp/ui/components/input'
import { FrameDataGrid } from '@/components/reui-kit'
import { SelectMenu } from '@/components/select-field'
import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
import { QueryState } from '@/components/query-state'
import { LoadingButton } from '@/components/loading-button'
import {
BIRD_SETTING_KEYS,
REVISION_SETTING_KEYS,
RUNTIME_LOGS_SETTING_KEYS,
buildPayload,
partitionSettings,
settingsKeys,
settingsQueryOptions,
type BirdSettingKey,
} from '@/queries/settings'
import { apiMutate } from '@/lib/api-client'
const RUNTIME_LOGS_ENABLED_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const RUNTIME_LOGS_MODE_ITEMS = [
{ value: 'truncate', label: 'Обнулить файл' },
{ value: 'delete', label: 'Удалить файл' },
] as const
const BIRD_LABELS: Record<BirdSettingKey, string> = {
bird_router_id: 'Router ID',
bird_local_ipv4: 'Локальный IPv4',
bird_local_ipv6: 'Локальный IPv6',
bird_local_asn: 'Локальный ASN',
bird_bgp_source_ipv4: 'Исходный IPv4 BGP',
bird_bgp_source_ipv6: 'Исходный IPv6 BGP',
peer_discovery_enabled: 'Автообнаружение пиров',
peer_discovery_ranges_v4: 'CIDR автообнаружения IPv4',
peer_discovery_ranges_v6: 'CIDR автообнаружения IPv6',
peer_discovery_require_external: 'Только внешние ASN',
}
const BIRD_BOOL_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
peer_discovery_enabled:
'Динамический диапазон соседей в BIRD (карантин import/export none). Нужны CIDR.',
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
peer_discovery_require_external: 'Диапазон соседей с внешним (любым чужим) ASN',
}
export function BirdSettingsTab() {
const settingsQ = useQuery(settingsQueryOptions())
const qc = useQueryClient()
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
useEffect(() => {
if (partitioned) {
setBirdForm({ ...partitioned.bird })
setRevisionForm({ ...partitioned.revision })
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsQ.data])
const patchMutation = useMutation({
mutationFn: (payload: Record<string, string | number | boolean>) =>
apiMutate('/v1/settings', 'PATCH', payload),
onSuccess: () => {
toast.success('Параметры сохранены')
void qc.invalidateQueries({ queryKey: settingsKeys.all })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
function saveBird() {
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
}
function saveRevision() {
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
}
function saveRuntimeLogs() {
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
}
return (
<div className="flex flex-col gap-6">
<SettingsCard
title="Плоскость управления BIRD"
description="Глобальные параметры BIRD для обновления модулей и применения конфигурации. Сохранение доступно оператору."
footer={
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
}
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-64" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<SettingsFieldGroup
legend="BIRD"
description="Параметры демона и автообнаружения пиров."
>
{BIRD_SETTING_KEYS.map((key, index) => (
<SettingsSettingField
key={key}
title={BIRD_LABELS[key]}
description={BIRD_HINTS[key]}
labelFor={key}
badge={{ label: 'BIRD', variant: 'info-light' }}
stacked
last={index === BIRD_SETTING_KEYS.length - 1}
>
{key === 'peer_discovery_enabled' ||
key === 'peer_discovery_require_external' ? (
<SelectMenu
id={key}
items={[...BIRD_BOOL_ITEMS]}
value={birdForm[key] || 'false'}
onValueChange={(v) =>
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
}
placeholder="Выкл"
/>
) : (
<Input
id={key}
className="w-full min-w-0"
value={birdForm[key] ?? ''}
onChange={(e) =>
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
}
placeholder={BIRD_LABELS[key]}
/>
)}
</SettingsSettingField>
))}
</SettingsFieldGroup>
)}
</QueryState>
</SettingsCard>
<SettingsCard
title="Ревизии"
description="Время хранения ревизий в БД"
footer={
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
}
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<SettingsFieldGroup
legend="Хранение"
description="Срок хранения ревизий в минутах."
>
<SettingsSettingField
title="Срок хранения"
description="Сколько минут хранить ревизии конфигурации."
labelFor="revision_retention_minutes"
stacked
last
>
<Input
id="revision_retention_minutes"
type="number"
className="w-full min-w-0"
value={revisionForm.revision_retention_minutes ?? ''}
onChange={(e) =>
setRevisionForm((s) => ({
...s,
revision_retention_minutes: e.target.value,
}))
}
/>
</SettingsSettingField>
</SettingsFieldGroup>
)}
</QueryState>
</SettingsCard>
<SettingsCard
title="Файловые логи"
description="Автоматическая очистка логов"
footer={
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
}
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-48" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<SettingsFieldGroup
legend="Авто-очистка"
description="Расписание и лимиты файловых логов runtime."
>
<SettingsSettingField
title="Авто-очистка"
description="Включить периодическую очистку файловых логов."
stacked
>
<SelectMenu
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
placeholder="Выберите"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_enabled: v,
}))
}
/>
</SettingsSettingField>
<SettingsSettingField
title="Макс. размер файла"
description="Порог в мегабайтах, после которого срабатывает очистка."
labelFor="runtime_logs_max_file_mb"
stacked
>
<Input
id="runtime_logs_max_file_mb"
type="number"
className="w-full min-w-0"
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_max_file_mb: e.target.value,
}))
}
/>
</SettingsSettingField>
<SettingsSettingField
title="Расписание"
description="Cron-выражение для авто-очистки."
labelFor="runtime_logs_auto_schedule"
stacked
>
<Input
id="runtime_logs_auto_schedule"
className="w-full min-w-0"
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_schedule: e.target.value,
}))
}
/>
</SettingsSettingField>
<SettingsSettingField
title="Режим очистки"
description="Обнулить файл или удалить его."
stacked
last
>
<SelectMenu
items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
placeholder="Выберите"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_mode: v,
}))
}
/>
</SettingsSettingField>
</SettingsFieldGroup>
)}
</QueryState>
</SettingsCard>
<FrameDataGrid
title="Дополнительные параметры"
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
>
<QueryState
data={partitioned?.additional ?? []}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
empty={(partitioned?.additional ?? []).length === 0}
emptyTitle="Нет дополнительных параметров"
skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()}
>
{(items) => (
<SettingsKvGrid
items={items}
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
/>
)}
</QueryState>
</FrameDataGrid>
</div>
)
}
@@ -1,13 +1,14 @@
import { Link, useNavigate } from '@tanstack/react-router' import { Link, useNavigate } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { Save } from 'lucide-react' import { KeyRound, Save } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row' import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card' import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group' import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { import {
DEV_API_TOKEN, DEV_API_TOKEN,
@@ -16,7 +17,6 @@ import {
TOKEN_STORAGE_KEY, TOKEN_STORAGE_KEY,
} from '@/lib/api-client' } from '@/lib/api-client'
import { authKeys } from '@/queries/auth' import { authKeys } from '@/queries/auth'
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
@@ -50,21 +50,22 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
} }
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-6">
{tokenRequired ? ( {tokenRequired ? (
<Alert> <Alert variant="warning">
<KeyRound aria-hidden />
<AlertTitle>Нужен API-токен</AlertTitle>
<AlertDescription> <AlertDescription>
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать Сохраните токен ниже или нажмите «Использовать dev».
dev».
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
<SettingsCard <SettingsCard
title="Подключение к API" title="Подключение к API"
description="Токен хранится только в этом браузере (localStorage)" description="Токен хранится только в этом браузере"
footer={ footer={
<div className="flex w-full flex-wrap justify-end gap-2"> <div className="flex w-full min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="outline" onClick={useDevToken}> <Button type="button" variant="outline" onClick={useDevToken}>
Использовать dev Использовать dev
</Button> </Button>
@@ -83,8 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
title="Токен для запросов" title="Токен для запросов"
description={ description={
<> <>
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление Ключ для заголовка Authorization. Управление ключами арендатора в разделе{' '}
ключами tenant в разделе{' '}
<Link to="/access" className="text-primary underline-offset-4 hover:underline"> <Link to="/access" className="text-primary underline-offset-4 hover:underline">
Права доступа Права доступа
</Link> </Link>
@@ -93,16 +93,18 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
} }
titleAddon={ titleAddon={
<Badge variant="outline" size="sm"> <Badge variant="outline" size="sm">
localStorage браузер
</Badge> </Badge>
} }
labelFor="settings-token" labelFor="settings-token"
stacked
last last
> >
<Input <Input
id="settings-token" id="settings-token"
type="password" type="password"
autoComplete="off" autoComplete="off"
className="w-full min-w-0"
value={token} value={token}
onChange={(e) => setTokenValue(e.target.value)} onChange={(e) => setTokenValue(e.target.value)}
placeholder="dev или API-ключ" placeholder="dev или API-ключ"
@@ -6,7 +6,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { Fragment } from 'react' import { Fragment } from 'react'
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card' import { SettingsCard } from '@/components/settings/settings-card'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { import {
@@ -23,42 +23,45 @@ import {
const ADMIN_SECTIONS = [ const ADMIN_SECTIONS = [
{ {
id: 'tenant-settings', id: 'tenant-settings',
to: '/tenant-settings' as const, to: '/settings' as const,
search: { tab: 'bird' as const },
title: 'Параметры арендатора', title: 'Параметры арендатора',
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.', description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
icon: <SlidersHorizontalIcon aria-hidden="true" />, icon: <SlidersHorizontalIcon aria-hidden="true" />,
badge: { label: 'operator', variant: 'warning-light' as const }, badge: { label: 'оператор', variant: 'warning-light' as const },
}, },
{ {
id: 'access', id: 'access',
to: '/access' as const, to: '/access' as const,
search: undefined,
title: 'Права доступа', title: 'Права доступа',
description: 'API-ключи tenant, роли и управление доступом.', description: 'API-ключи арендатора, роли и управление доступом.',
icon: <KeyRoundIcon aria-hidden="true" />, icon: <KeyRoundIcon aria-hidden="true" />,
badge: { label: 'operator', variant: 'warning-light' as const }, badge: { label: 'оператор', variant: 'warning-light' as const },
}, },
{ {
id: 'monitoring', id: 'monitoring',
to: '/monitoring' as const, to: '/monitoring' as const,
search: undefined,
title: 'Мониторинг', title: 'Мониторинг',
description: 'Метрики, состояние jobs и observability control plane.', description: 'Метрики, состояние задач и наблюдаемость плоскости управления.',
icon: <ActivityIcon aria-hidden="true" />, icon: <ActivityIcon aria-hidden="true" />,
badge: { label: 'viewer+', variant: 'info-light' as const }, badge: { label: 'просмотр+', variant: 'info-light' as const },
}, },
] as const ] as const
export function SectionsSettingsTab() { export function SectionsSettingsTab() {
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-6">
<SettingsCard <SettingsCard
title="Разделы control plane" title="Разделы плоскости управления"
description="Параметры tenant и операции — отдельно от настроек браузера" description="Параметры арендатора и операции — отдельно от настроек браузера"
> >
<ItemGroup className="gap-0"> <ItemGroup className="gap-0">
{ADMIN_SECTIONS.map((section, index) => ( {ADMIN_SECTIONS.map((section, index) => (
<Fragment key={section.id}> <Fragment key={section.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null} {index > 0 ? <ItemSeparator className="my-0" /> : null}
<Item className="min-h-0 items-center gap-4 px-5 py-3.5"> <Item className="min-h-0 min-w-0 flex-wrap items-center gap-4 px-5 py-3.5">
<ItemMedia className="self-center!"> <ItemMedia className="self-center!">
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60"> <Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
{section.icon} {section.icon}
@@ -66,7 +69,7 @@ export function SectionsSettingsTab() {
</ItemMedia> </ItemMedia>
<ItemContent className="min-w-0 justify-center gap-0.5 self-center"> <ItemContent className="min-w-0 justify-center gap-0.5 self-center">
<ItemTitle className="gap-2 leading-5"> <ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
{section.title} {section.title}
<Badge variant={section.badge.variant} size="xs"> <Badge variant={section.badge.variant} size="xs">
{section.badge.label} {section.badge.label}
@@ -76,7 +79,17 @@ export function SectionsSettingsTab() {
</ItemContent> </ItemContent>
<ItemActions className="shrink-0 justify-end self-center"> <ItemActions className="shrink-0 justify-end self-center">
<Button variant="outline" size="sm" render={<Link to={section.to} />}> <Button
variant="outline"
size="sm"
render={
section.to === '/settings' ? (
<Link to="/settings" search={{ tab: 'bird' }} />
) : (
<Link to={section.to} />
)
}
>
Открыть Открыть
</Button> </Button>
</ItemActions> </ItemActions>
@@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { MonitorIcon } from 'lucide-react' import { MonitorIcon } from 'lucide-react'
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row' import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card' import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group' import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { TOKEN_STORAGE_KEY } from '@/lib/api-client' import { TOKEN_STORAGE_KEY } from '@/lib/api-client'
@@ -35,8 +35,8 @@ export function SessionSettingsTab() {
}) })
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-6">
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session"> <SettingsCard title="Текущая сессия" description="Проверка токена через API сессии">
<QueryState <QueryState
data={sessionQ.data} data={sessionQ.data}
isLoading={sessionQ.isLoading && hasStoredToken} isLoading={sessionQ.isLoading && hasStoredToken}
@@ -58,15 +58,17 @@ export function SessionSettingsTab() {
</ItemMedia> </ItemMedia>
<ItemContent className="min-w-0 justify-center gap-0.5 self-center"> <ItemContent className="min-w-0 justify-center gap-0.5 self-center">
<ItemTitle className="gap-2 leading-5"> <ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
Этот браузер Этот браузер
<Badge variant="success-light" size="xs"> <Badge variant="success-light" size="xs">
Активна Активна
</Badge> </Badge>
</ItemTitle> </ItemTitle>
<ItemDescription className="leading-5"> <ItemDescription className="leading-5">
Tenant:{' '} Арендатор:{' '}
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code> <code className="text-foreground font-mono text-xs break-all">
{session.tenant_id}
</code>
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
@@ -82,7 +84,7 @@ export function SessionSettingsTab() {
> >
<SettingRow <SettingRow
title="Роль" title="Роль"
description="Определяет доступ к операциям control plane и CRUD." description="Определяет доступ к операциям плоскости управления и изменению данных."
titleAddon={ titleAddon={
sessionQ.data ? ( sessionQ.data ? (
<Badge variant="info-light" size="sm"> <Badge variant="info-light" size="sm">
@@ -94,12 +96,13 @@ export function SessionSettingsTab() {
</Badge> </Badge>
) )
} }
stacked
last last
> >
{sessionQ.data ? ( {sessionQ.data ? (
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm break-words">
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '} Ключ с ролью <strong>{ROLE_LABELS[sessionQ.data.role] ?? sessionQ.data.role}</strong> у арендатора{' '}
<code className="font-mono text-xs">{sessionQ.data.tenant_id}</code>. <code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>.
</p> </p>
) : ( ) : (
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
@@ -0,0 +1,104 @@
import { type ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from '@evobgp/ui/components/field'
export interface SettingRowProps {
title: string
description?: ReactNode
children: ReactNode
last?: boolean
/** Opt-in FieldSeparator after the row (default off — ReUI settings use Frame+gap). */
separated?: boolean
compact?: boolean
stacked?: boolean
labelFor?: string
contentClassName?: string
className?: string
titleAddon?: ReactNode
}
/**
* Settings row settings-3 / settings-16 DNA.
* Control is flex-1 + min-w-0 (never a fixed w-78) so label and input do not overlap.
* @see https://reui.io/preview/base/settings-3
* @see https://reui.io/preview/base/settings-16
*/
export function SettingRow({
title,
description,
children,
last,
separated = false,
compact,
stacked,
labelFor,
contentClassName,
className,
titleAddon,
}: SettingRowProps) {
return (
<>
<Field
orientation={stacked ? 'vertical' : 'responsive'}
className={cn(
'min-w-0 gap-4 px-5 py-3.5',
!stacked &&
'@md/field-group:items-start @md/field-group:*:min-w-0 @md/field-group:*:max-w-full',
!last && !separated && 'border-border/40 border-b',
className,
)}
>
<div
className={cn(
'flex min-w-0 flex-1 flex-col gap-0.5',
!stacked && '@md/field-group:max-w-sm',
)}
>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{titleAddon}
</div>
{description ? (
<FieldDescription className="text-sm break-words">{description}</FieldDescription>
) : null}
</div>
<FieldContent
className={cn(
'w-full min-w-0 @md/field-group:flex-1',
stacked
? 'max-w-none'
: compact
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
: '@md/field-group:max-w-[min(100%,34rem)]',
contentClassName,
)}
>
<div
className={cn(
'flex w-full min-w-0 justify-start',
stacked ? 'justify-start' : '@md/field-group:justify-end',
)}
>
{children}
</div>
</FieldContent>
</Field>
{separated && !last ? <FieldSeparator /> : null}
</>
)
}
@@ -0,0 +1,53 @@
import { type ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
interface SettingsCardProps {
title: string
description?: string
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
footerClassName?: string
}
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 */
export function SettingsCard({
title,
description,
children,
footer,
className,
contentClassName,
footerClassName,
}: SettingsCardProps) {
return (
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
<FrameHeader className="gap-0 border-b px-5 py-3">
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
<div className={cn('min-w-0', contentClassName)}>{children}</div>
{footer ? (
<FrameFooter
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,36 @@
import { type ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
} from '@evobgp/ui/components/field'
interface SettingsFieldGroupProps {
legend: string
description: string
children: ReactNode
className?: string
fieldGroupClassName?: string
}
/** Settings field group — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */
export function SettingsFieldGroup({
legend,
description,
children,
className,
fieldGroupClassName,
}: SettingsFieldGroupProps) {
return (
<FieldSet className={cn('gap-0', className)}>
<FieldLegend className="sr-only">{legend}</FieldLegend>
<FieldDescription className="sr-only">{description}</FieldDescription>
<FieldGroup className={cn('gap-0', fieldGroupClassName)}>
{children}
</FieldGroup>
</FieldSet>
)
}
@@ -1,45 +1,138 @@
import { BadgeTabs, TabsContent } from '@/components/badge-tabs' import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@evobgp/ui/lib/utils'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@evobgp/ui/components/tabs'
import { AppearanceSettingsTab } from './appearance-settings-tab' import { AppearanceSettingsTab } from './appearance-settings-tab'
import { BirdSettingsTab } from './bird-settings-tab'
import { ConnectionSettingsTab } from './connection-settings-tab' import { ConnectionSettingsTab } from './connection-settings-tab'
import { SectionsSettingsTab } from './sections-settings-tab'
import { SessionSettingsTab } from './session-settings-tab' import { SessionSettingsTab } from './session-settings-tab'
import { import {
SETTINGS_TAB_ITEMS, SETTINGS_TAB_ITEMS,
type SettingsTab, type SettingsSection,
type SettingsTabItem,
} from './settings-tabs-data' } from './settings-tabs-data'
function UiSettingsPanel({ tokenRequired }: { tokenRequired: boolean }) {
return (
<div className="flex flex-col gap-6">
<ConnectionSettingsTab tokenRequired={tokenRequired} />
<SessionSettingsTab />
<AppearanceSettingsTab />
</div>
)
}
function SettingsNavigation({
isMobile,
activeValue,
items,
}: {
isMobile: boolean
activeValue: string
items: SettingsTabItem[]
}) {
return (
<div className={cn('min-w-0', isMobile ? 'w-full' : 'w-40 shrink-0')}>
{isMobile ? (
<div className="-mx-1 overflow-x-auto px-1 pb-1">
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
{items.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
)}
>
{tab.icon}
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
</div>
) : (
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
{items.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
)}
>
{tab.icon}
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
)}
</div>
)
}
/**
* Unified settings settings-7 AccountSettings 1:1 (header + vertical Tabs).
* Surface of cards stays Frame (`SettingsCard`), not shadcn Card.
* @see https://reui.io/preview/base/settings-7
* @see https://reui.io/blocks
* @see https://reui.io/docs/components/base/frame
*/
export function SettingsPageShell({ export function SettingsPageShell({
activeTab, activeTab,
onTabChange, onTabChange,
tokenRequired, tokenRequired,
showBird,
}: { }: {
activeTab: SettingsTab activeTab: SettingsSection
onTabChange: (tab: SettingsTab) => void onTabChange: (tab: SettingsSection) => void
tokenRequired: boolean tokenRequired: boolean
showBird: boolean
}) { }) {
const isMobile = useIsMobile()
const items = showBird
? SETTINGS_TAB_ITEMS
: SETTINGS_TAB_ITEMS.filter((tab) => tab.value === 'ui')
const resolvedTab = activeTab === 'bird' && showBird ? 'bird' : 'ui'
return ( return (
<BadgeTabs <div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
value={activeTab} <header className="px-1">
onValueChange={(value) => onTabChange(value as SettingsTab)} <h1 className="text-xl font-semibold tracking-tight">Настройки</h1>
items={SETTINGS_TAB_ITEMS.map((tab) => ({ <p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
value: tab.value, Интерфейс UI и глобальные параметры BIRD в одном разделе.
label: tab.label, </p>
icon: tab.icon, </header>
}))}
> <Tabs
<TabsContent value="connection" className="mt-0"> value={resolvedTab}
<ConnectionSettingsTab tokenRequired={tokenRequired} /> onValueChange={(value) => onTabChange(value as SettingsSection)}
</TabsContent> orientation={isMobile ? 'horizontal' : 'vertical'}
<TabsContent value="session" className="mt-0"> className="w-full gap-4 lg:gap-8"
<SessionSettingsTab /> >
</TabsContent> <SettingsNavigation
<TabsContent value="appearance" className="mt-0"> isMobile={isMobile}
<AppearanceSettingsTab /> activeValue={resolvedTab}
</TabsContent> items={items}
<TabsContent value="sections" className="mt-0"> />
<SectionsSettingsTab />
</TabsContent> <div className="min-w-0 flex-1">
</BadgeTabs> <TabsContent value="ui" className="mt-0">
<UiSettingsPanel tokenRequired={tokenRequired} />
</TabsContent>
{showBird ? (
<TabsContent value="bird" className="mt-0">
<BirdSettingsTab />
</TabsContent>
) : null}
</div>
</Tabs>
</div>
) )
} }
@@ -1,17 +1,9 @@
import { type ComponentProps, type ReactNode } from 'react' import { type ComponentProps, type ReactNode } from 'react'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils' import { SettingRow } from '@/components/settings/setting-row'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from '@evobgp/ui/components/field'
/** settings-3 row pattern for tenant/UI settings. */ /** Tenant settings row — same SettingRow DNA as UI settings. */
export function SettingsSettingField({ export function SettingsSettingField({
title, title,
description, description,
@@ -20,38 +12,34 @@ export function SettingsSettingField({
last, last,
labelFor, labelFor,
contentClassName, contentClassName,
stacked,
}: { }: {
title: string title: string
description: ReactNode description?: ReactNode
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] } badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
children: ReactNode children: ReactNode
last?: boolean last?: boolean
labelFor?: string labelFor?: string
contentClassName?: string contentClassName?: string
stacked?: boolean
}) { }) {
return ( return (
<> <SettingRow
<Field orientation="responsive" className="gap-4 px-4 py-4"> title={title}
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm"> description={description}
<div className="flex flex-wrap items-center gap-2"> last={last}
{labelFor ? ( labelFor={labelFor}
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel> contentClassName={contentClassName}
) : ( stacked={stacked}
<FieldTitle>{title}</FieldTitle> titleAddon={
)} badge ? (
{badge ? ( <Badge variant={badge.variant} size="sm">
<Badge variant={badge.variant} size="sm"> {badge.label}
{badge.label} </Badge>
</Badge> ) : null
) : null} }
</div> >
<FieldDescription className="text-xs">{description}</FieldDescription> {children}
</div> </SettingRow>
<FieldContent className={cn('min-w-0 @md/field-group:w-78', contentClassName)}>
{children}
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
) )
} }
@@ -1,49 +1,32 @@
import { import { ServerCogIcon, SettingsIcon } from 'lucide-react'
KeyRoundIcon,
MonitorSmartphoneIcon,
PaletteIcon,
SlidersHorizontalIcon,
} from 'lucide-react'
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections' export type SettingsSection = 'ui' | 'bird'
export type SettingsTabItem = { export type SettingsTabItem = {
value: SettingsTab value: SettingsSection
label: string label: string
icon: ReactNode icon: ReactNode
} }
/** Side rail items — settings-7 AccountSettings DNA. */
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [ export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
{ {
value: 'connection', value: 'ui',
label: 'Подключение', label: 'Настройки UI',
icon: <KeyRoundIcon aria-hidden="true" />, icon: <SettingsIcon aria-hidden="true" />,
}, },
{ {
value: 'session', value: 'bird',
label: 'Сессия', label: 'Настройки BIRD',
icon: <MonitorSmartphoneIcon aria-hidden="true" />, icon: <ServerCogIcon aria-hidden="true" />,
},
{
value: 'appearance',
label: 'Оформление',
icon: <PaletteIcon aria-hidden="true" />,
},
{
value: 'sections',
label: 'Разделы',
icon: <SlidersHorizontalIcon aria-hidden="true" />,
}, },
] ]
export function parseSettingsTab(value: unknown): SettingsTab { const LEGACY_BIRD = new Set(['bird', 'revision', 'runtime-logs', 'additional'])
if (
value === 'session' || /** Maps current and legacy `?tab=` values onto the unified settings rail. */
value === 'appearance' || export function parseSettingsSection(value: unknown): SettingsSection {
value === 'sections' if (typeof value === 'string' && LEGACY_BIRD.has(value)) return 'bird'
) { return 'ui'
return value
}
return 'connection'
} }
+3 -3
View File
@@ -39,11 +39,11 @@ export function AnalyticsDashboardSkeleton() {
</div> </div>
<div className={dashboardMainSidebarClassName}> <div className={dashboardMainSidebarClassName}>
<Skeleton className="h-96 w-full rounded-xl xl:col-span-8" /> <Skeleton className="h-96 w-full min-w-0 rounded-xl" />
<Skeleton className="h-96 w-full rounded-xl xl:col-span-4" /> <Skeleton className="h-96 w-full min-w-0 rounded-xl" />
</div> </div>
<div className="grid gap-4 lg:grid-cols-2"> <div className={chartPanelGridClassName}>
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-64 w-full rounded-xl" />
</div> </div>
+1 -1
View File
@@ -4,7 +4,7 @@ import { apiKeyRoleRu } from '@/lib/ui-labels'
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [ export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
{ value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` }, { value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
{ value: 'editor', label: `${apiKeyRoleRu('editor')}CRUD без применения` }, { value: 'editor', label: `${apiKeyRoleRu('editor')}изменение данных без применения` },
{ value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` }, { value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
{ value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` }, { value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
] ]
+33 -3
View File
@@ -308,6 +308,31 @@ export function sessionCanWriteModules(session: {
return role === 'editor' || role === 'operator' return role === 'editor' || role === 'operator'
} }
/**
* Whether session may create/update directories (`bgp:directories:write`).
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+.
*/
export function sessionCanWriteDirectories(session: {
role?: string
kind?: string
is_admin?: boolean
permissions?: readonly string[]
} | null | undefined): boolean {
if (!session) return false
const jwtPath =
session.kind === 'jwt' ||
session.is_admin === true ||
(session.permissions?.length ?? 0) > 0
if (jwtPath) {
return (
session.is_admin === true ||
hasPermission(session.permissions ?? [], 'bgp:directories:write')
)
}
const role = (session.role ?? '').toLowerCase()
return role === 'editor' || role === 'operator'
}
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */ /** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
export function permissionForPath(pathname: string): string | null { export function permissionForPath(pathname: string): string | null {
if (pathname === '/' || pathname.startsWith('/dashboard')) { if (pathname === '/' || pathname.startsWith('/dashboard')) {
@@ -321,11 +346,17 @@ export function permissionForPath(pathname: string): string | null {
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read' if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read' if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
if (pathname.startsWith('/access')) return 'bgp:access:admin' if (pathname.startsWith('/access')) return 'bgp:access:admin'
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin' if (pathname.startsWith('/tenant-settings') || pathname.startsWith('/settings')) {
if (pathname.startsWith('/settings')) return 'bgp:settings:read' return canOpenSettings() ? null : 'bgp:settings:read'
}
return null return null
} }
/** Unified `/settings` — UI (`settings:read`) or BIRD (`tenant_settings:admin`). */
export function canOpenSettings(): boolean {
return can('bgp:settings:read') || can('bgp:tenant_settings:admin')
}
const FALLBACK_PATH = '/dashboard' const FALLBACK_PATH = '/dashboard'
/** First path in the sidebar the current user may open. */ /** First path in the sidebar the current user may open. */
@@ -340,7 +371,6 @@ export function firstAllowedPath(): string {
'/schedule', '/schedule',
'/monitoring', '/monitoring',
'/access', '/access',
'/tenant-settings',
'/settings', '/settings',
] ]
for (const path of candidates) { for (const path of candidates) {
+1
View File
@@ -44,6 +44,7 @@ export const DATA_GRID_MESSAGES_RU = {
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = { export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
...DATA_GRID_TABLE_LAYOUT, ...DATA_GRID_TABLE_LAYOUT,
dense: true, dense: true,
width: 'auto',
} }
export function createTextGlobalFilter<TData extends object>( export function createTextGlobalFilter<TData extends object>(
+94
View File
@@ -0,0 +1,94 @@
import {
DEFAULT_I18N,
type FilterI18nConfig,
} from '@/components/reui/filters'
const OPERATOR_LABEL_RU: Record<string, string> = {
is: 'равно',
is_not: 'не равно',
is_any_of: 'любое из',
is_not_any_of: 'кроме',
includes_all: 'включает все',
excludes_all: 'исключает все',
before: 'до',
after: 'после',
between: 'между',
not_between: 'вне диапазона',
contains: 'содержит',
not_contains: 'не содержит',
starts_with: 'начинается с',
ends_with: 'заканчивается на',
equals: 'равно',
not_equals: 'не равно',
greater_than: 'больше',
less_than: 'меньше',
overlaps: 'пересекается',
includes: 'включает',
excludes: 'исключает',
empty: 'пусто',
not_empty: 'не пусто',
}
export const FILTERS_I18N_RU: FilterI18nConfig = {
...DEFAULT_I18N,
addFilter: 'Фильтр',
searchFields: 'Фильтр…',
noFieldsFound: 'Поля не найдены.',
noResultsFound: 'Ничего не найдено.',
select: 'Выбрать…',
true: 'Да',
false: 'Нет',
min: 'Мин.',
max: 'Макс.',
to: '—',
typeAndPressEnter: 'Введите и нажмите Enter',
selected: 'выбрано',
selectedCount: 'выбрано',
addFilterTitle: 'Добавить фильтр',
operators: {
...DEFAULT_I18N.operators,
is: 'равно',
isNot: 'не равно',
isAnyOf: 'любое из',
isNotAnyOf: 'кроме',
includesAll: 'включает все',
excludesAll: 'исключает все',
before: 'до',
after: 'после',
between: 'между',
notBetween: 'вне диапазона',
contains: 'содержит',
notContains: 'не содержит',
startsWith: 'начинается с',
endsWith: 'заканчивается на',
isExactly: 'точно',
equals: 'равно',
notEquals: 'не равно',
greaterThan: 'больше',
lessThan: 'меньше',
overlaps: 'пересекается',
includes: 'включает',
excludes: 'исключает',
includesAllOf: 'включает все',
includesAnyOf: 'включает любое',
empty: 'пусто',
notEmpty: 'не пусто',
},
placeholders: {
enterField: (fieldType: string) => `Введите ${fieldType}`,
selectField: 'Выбрать…',
searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}`,
enterKey: 'Введите ключ…',
enterValue: 'Введите значение…',
},
helpers: {
formatOperator: (operator: string) =>
OPERATOR_LABEL_RU[operator] ?? operator.replace(/_/g, ' '),
},
validation: {
invalidEmail: 'Некорректный email',
invalidUrl: 'Некорректный URL',
invalidTel: 'Некорректный телефон',
invalid: 'Некорректное значение',
},
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import {
isReadyCheckOk,
isSystemReady,
readinessBreakdown,
readyCheckStatusLabel,
} from '@/lib/metrics/readiness-breakdown'
describe('isSystemReady', () => {
it('accepts ready and ok', () => {
expect(isSystemReady('ready')).toBe(true)
expect(isSystemReady('ok')).toBe(true)
expect(isSystemReady('READY')).toBe(true)
})
it('rejects not_ready and empty', () => {
expect(isSystemReady('not_ready')).toBe(false)
expect(isSystemReady(undefined)).toBe(false)
expect(isSystemReady('')).toBe(false)
})
})
describe('isReadyCheckOk', () => {
it('parses backend string checks as ok', () => {
expect(isReadyCheckOk('ok')).toBe(true)
expect(isReadyCheckOk('memory')).toBe(true)
expect(isReadyCheckOk('unavailable')).toBe(false)
})
it('parses boolean and object forms', () => {
expect(isReadyCheckOk(true)).toBe(true)
expect(isReadyCheckOk(false)).toBe(false)
expect(isReadyCheckOk({ ok: true })).toBe(true)
expect(isReadyCheckOk({ ok: false, error: 'down' })).toBe(false)
})
})
describe('readyCheckStatusLabel', () => {
it('labels memory and failures', () => {
expect(readyCheckStatusLabel('memory', true)).toBe('В памяти')
expect(readyCheckStatusLabel('ok', true)).toBe('В норме')
expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно')
})
})
describe('readinessBreakdown', () => {
it('counts healthy handleReady payload without false failures', () => {
const slices = readinessBreakdown(
{
status: 'ready',
checks: { store: 'ok', jobs: 'memory', postgres: 'ok' },
},
true,
)
expect(slices.find((s) => s.key === 'checks-fail')).toBeUndefined()
expect(slices.find((s) => s.key === 'checks-ok')?.count).toBe(3)
expect(slices.find((s) => s.key === 'health')?.count).toBe(1)
})
it('counts unavailable checks as failures', () => {
const slices = readinessBreakdown(
{
status: 'not_ready',
checks: { store: 'unavailable', jobs: 'memory' },
},
true,
)
expect(slices.find((s) => s.key === 'checks-fail')?.count).toBe(1)
expect(slices.find((s) => s.key === 'checks-ok')?.count).toBe(1)
})
it('returns API down slice when health fails', () => {
const slices = readinessBreakdown({ status: 'ready', checks: { store: 'ok' } }, false)
expect(slices).toHaveLength(1)
expect(slices[0]?.key).toBe('health-fail')
})
})
@@ -2,12 +2,65 @@ import type { ReadyStatus } from '@/queries/monitoring'
import type { BreakdownSlice } from './types' import type { BreakdownSlice } from './types'
function checkOk(value: boolean | { ok?: boolean; error?: string } | undefined): boolean { /** Значение check из GET /v1/ready (строка, boolean или объект). */
export type ReadyCheckValue = boolean | string | { ok?: boolean; error?: string } | null | undefined
const OK_STRINGS = new Set(['ok', 'ready', 'memory', 'true', 'healthy', 'up'])
const FAIL_STRINGS = new Set([
'unavailable',
'error',
'not_ready',
'failed',
'down',
'false',
'unhealthy',
])
/** Top-level status GET /v1/ready: API отдаёт `ready`, не `ok`. */
export function isSystemReady(status: string | null | undefined): boolean {
if (!status) return false
const normalized = status.trim().toLowerCase()
return normalized === 'ready' || normalized === 'ok'
}
/**
* Интерпретация check value по контракту handleReady:
* store/postgres "ok" | "unavailable"; jobs "memory"; store_backend "memory".
*/
export function isReadyCheckOk(value: ReadyCheckValue): boolean {
if (typeof value === 'boolean') return value if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (OK_STRINGS.has(normalized)) return true
if (FAIL_STRINGS.has(normalized)) return false
// неизвестная непустая строка — считать OK (информативный статус бэкенда)
return normalized.length > 0
}
if (value && typeof value === 'object') return value.ok === true if (value && typeof value === 'object') return value.ok === true
return false return false
} }
/** Человекочитаемый статус check для UI. */
export function readyCheckStatusLabel(value: ReadyCheckValue, ok: boolean): string {
if (!ok) {
if (typeof value === 'string' && value.trim()) {
const n = value.trim().toLowerCase()
if (n === 'unavailable') return 'Недоступно'
if (n === 'not_ready') return 'Не готов'
return value
}
if (value && typeof value === 'object' && value.error) return value.error
return 'Ошибка'
}
if (typeof value === 'string') {
const n = value.trim().toLowerCase()
if (n === 'memory') return 'В памяти'
if (n === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме'
if (n) return value
}
return 'В норме'
}
export function readinessBreakdown( export function readinessBreakdown(
ready: ReadyStatus | null | undefined, ready: ReadyStatus | null | undefined,
healthOk: boolean, healthOk: boolean,
@@ -28,7 +81,7 @@ export function readinessBreakdown(
let failCount = 0 let failCount = 0
for (const value of Object.values(checks)) { for (const value of Object.values(checks)) {
if (checkOk(value)) okCount += 1 if (isReadyCheckOk(value)) okCount += 1
else failCount += 1 else failCount += 1
} }
@@ -61,9 +114,11 @@ export function readinessBreakdown(
if (slices.length === 1 && okCount === 0 && failCount === 0) { if (slices.length === 1 && okCount === 0 && failCount === 0) {
slices.push({ slices.push({
key: 'ready', key: 'ready',
label: ready?.status === 'ok' ? 'Готов' : 'Ожидает готовности', label: isSystemReady(ready?.status) ? 'Готов' : 'Не готов',
count: 1, count: 1,
color: 'var(--color-chart-4)', color: isSystemReady(ready?.status)
? 'var(--color-chart-1)'
: 'var(--color-warning)',
}) })
} }
+33 -4
View File
@@ -61,18 +61,45 @@ const JOB_STATUS_RU: Record<string, string> = {
paused: 'Приостановлен', paused: 'Приостановлен',
disabled: 'Выключен', disabled: 'Выключен',
archived: 'В архиве', archived: 'В архиве',
block: 'block', block: 'Блокировать',
accept: 'accept', accept: 'Принимать',
} }
export function jobStatusRu(status: string): string { export function jobStatusRu(status: string): string {
return JOB_STATUS_RU[status.toLowerCase()] ?? status return JOB_STATUS_RU[status.toLowerCase()] ?? status
} }
const BGP_SESSION_STATE_RU: Record<string, string> = {
Idle: 'Простой',
Connect: 'Соединение',
Active: 'Поиск',
OpenSent: 'Open отправлен',
OpenConfirm: 'Open подтверждён',
Established: 'Установлена',
}
export function bgpSessionStateRu(state: string | null | undefined): string { export function bgpSessionStateRu(state: string | null | undefined): string {
if (!state) return '—' if (!state) return '—'
if (state === 'Established') return 'Установлена' return BGP_SESSION_STATE_RU[state] ?? state
return state }
export function speakerRoleRu(role: string | null | undefined): string {
switch (role) {
case 'master':
return 'Основной'
case 'primary':
return 'Основной'
case 'secondary':
return 'Резервный'
case 'speaker':
return 'Спикер'
default:
return role?.trim() || '—'
}
}
export function cdnSourceKindRu(kind: string): string {
return kind === 'json' ? 'JSON' : 'Текст'
} }
export function speakerOnlineLabel(agentOk: boolean | undefined): string { export function speakerOnlineLabel(agentOk: boolean | undefined): string {
@@ -117,6 +144,8 @@ export function readyCheckRu(key: string): string {
return 'Хранилище' return 'Хранилище'
case 'jobs': case 'jobs':
return 'Очередь задач' return 'Очередь задач'
case 'store_backend':
return 'Бэкенд хранилища'
default: default:
return key return key
} }
+13 -3
View File
@@ -12,6 +12,10 @@
*/ */
export const UI_SURFACE = 'frame' as const 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 ReUI stats-12 KPI rows (36 tiles). */ /** Grid for ReUI stats-12 KPI rows (36 tiles). */
export const kpiStatGridClassName = '@container w-full' export const kpiStatGridClassName = '@container w-full'
@@ -26,7 +30,13 @@ export const kpiGridClassName =
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5' export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
/** Two-column chart panel row (monitoring / network overview). */ /** Two-column chart panel row (monitoring / network overview). */
export const chartPanelGridClassName = 'grid grid-cols-1 gap-4 @5xl:grid-cols-2' export const chartPanelGridClassName =
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
/** Main + sidebar layout (8+4) used on dashboard. */ /**
export const dashboardMainSidebarClassName = 'grid gap-4 xl:grid-cols-12 xl:items-start' * Main + sidebar: stack until the dashboard container is wide enough.
* Do not nest this inside another 2-col grid that squeezes modules/timeline.
* @see https://reui.io/preview/base/dashboard-1
*/
export const dashboardMainSidebarClassName =
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
+70 -3
View File
@@ -1,6 +1,17 @@
import { queryOptions } from '@tanstack/react-query' import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client' import { toast } from 'sonner'
import type { CommunitiesResponse, DohProfilesResponse } from '@/types/api'
import { apiJSON, apiMutate } from '@/lib/api-client'
import type {
BgpCommunity,
BgpCommunityCreate,
BgpCommunityPatch,
CommunitiesResponse,
DohProfile,
DohProfileCreate,
DohProfilePatch,
DohProfilesResponse,
} from '@/types/api'
export const directoriesKeys = { export const directoriesKeys = {
all: ['directories'] as const, all: ['directories'] as const,
@@ -23,3 +34,59 @@ export function directoriesDohQueryOptions() {
staleTime: 30_000, staleTime: 30_000,
}) })
} }
export function useCreateCommunityMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: BgpCommunityCreate) =>
apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('Community создано')
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось создать community'),
})
}
export function useUpdateCommunityMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) =>
apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: () => {
toast.success('Community обновлено')
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось обновить community'),
})
}
export function useCreateDohProfileMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: DohProfileCreate) =>
apiMutate<DohProfile>('/v1/doh-profiles', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('DoH-профиль создан')
void qc.invalidateQueries({ queryKey: directoriesKeys.doh() })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось создать DoH-профиль'),
})
}
export function useUpdateDohProfileMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: string; body: DohProfilePatch }) =>
apiMutate<DohProfile>(`/v1/doh-profiles/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: () => {
toast.success('DoH-профиль обновлён')
void qc.invalidateQueries({ queryKey: directoriesKeys.doh() })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось обновить DoH-профиль'),
})
}
+2 -1
View File
@@ -9,7 +9,8 @@ export interface HealthStatus {
export interface ReadyStatus { export interface ReadyStatus {
status?: string status?: string
checks?: Record<string, boolean | { ok?: boolean; error?: string }> /** Backend: string ("ok"|"memory"|"unavailable"), boolean, or { ok, error }. */
checks?: Record<string, boolean | string | { ok?: boolean; error?: string }>
} }
export interface VersionInfo { export interface VersionInfo {
+1 -1
View File
@@ -62,7 +62,7 @@ export const Route = createFileRoute('/_auth')({
if (!raw || !normalizeApiToken(raw)) { if (!raw || !normalizeApiToken(raw)) {
throw redirect({ throw redirect({
to: '/settings', to: '/settings',
search: { tab: 'connection', reason: 'token-required' }, search: { tab: 'ui', reason: 'token-required' },
}) })
} }
}, },
+7
View File
@@ -0,0 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { SettingsShell } from '@/components/reui-kit/settings-shell'
export const Route = createFileRoute('/_auth/_settings')({
component: SettingsShell,
})
@@ -0,0 +1,50 @@
import { createFileRoute } from '@tanstack/react-router'
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
import {
parseSettingsSection,
type SettingsSection,
} from '@/components/settings/settings-tabs-data'
import { can } from '@/lib/auth'
export const Route = createFileRoute('/_auth/_settings/settings')({
component: SettingsComponent,
validateSearch: (search: Record<string, unknown>): {
tab: SettingsSection
reason?: 'token-required'
} => ({
tab: parseSettingsSection(search.tab),
...(search.reason === 'token-required'
? { reason: 'token-required' as const }
: {}),
}),
})
function SettingsComponent() {
const navigate = Route.useNavigate()
const { tab, reason } = Route.useSearch()
const tokenRequired = reason === 'token-required'
const showBird = !tokenRequired && can('bgp:tenant_settings:admin')
const activeTab: SettingsSection =
tokenRequired || (tab === 'bird' && !showBird) ? 'ui' : tab
function handleTabChange(nextTab: SettingsSection) {
void navigate({
search: (prev) => ({
...prev,
tab: nextTab,
reason: nextTab === 'ui' ? prev.reason : undefined,
}),
replace: true,
})
}
return (
<SettingsPageShell
activeTab={activeTab}
onTabChange={handleTabChange}
tokenRequired={tokenRequired}
showBird={showBird}
/>
)
}

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