Compare commits

...
11 Commits
Author SHA1 Message Date
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
113 changed files with 2438 additions and 1459 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
---
# 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).
@@ -14,16 +14,17 @@ Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Ma
| Зона | Компонент / DNA |
|------|-----------------|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
Импорты UI: `@evobgp/ui/components/*`.
Импорты UI: `@evobgp/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
## NEVER
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
- Другой size / radius / solid brand fill вместо `bg-muted`
- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile
- Другой size / radius / solid brand fill вместо `elevated`
- `card-35` как замена stats-12 hybrid KPI
- Копипаст ReUI block в route — adapt через `reui-kit/`
- Голый lucide `size-4` в name-cell без hybrid tile
- Голый lucide `size-4` в name-cell без IconTile
+3 -2
View File
@@ -75,9 +75,9 @@ alwaysApply: true
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `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`, …
@@ -110,6 +110,7 @@ REUI_LICENSE_KEY=
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
- Смешивать Card и Frame на ops-экране
- Ставить 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-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)
+8 -6
View File
@@ -28,6 +28,8 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
Если 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`**.
@@ -35,11 +37,11 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
### Теги образов
```text
git.shts.su/<owner>/<имя>:latest
git.shts.su/<owner>/<имя>:v1.2.3
git.shts.su/<owner>/<имя>:1.2.3
git.shts.su/<owner>/<имя>:<short-sha>
git.shts.su/<owner>/<имя>:sha-<full-sha>
git.shx.one/<owner>/<имя>:latest
git.shx.one/<owner>/<имя>:v1.2.3
git.shx.one/<owner>/<имя>:1.2.3
git.shx.one/<owner>/<имя>:<short-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`.
@@ -49,7 +51,7 @@ git.shts.su/<owner>/<имя>:sha-<full-sha>
Пример:
```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).
+21 -6
View File
@@ -303,6 +303,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
persist-credentials: true
- uses: actions/setup-node@v4
@@ -317,14 +318,28 @@ jobs:
- name: Semantic release
run: npx semantic-release
env:
GITEA_URL: https://git.shts.su
GITEA_URL: https://git.shx.one
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Detect new release
id: rel
run: |
set -euo pipefail
version=""
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"
else
echo "released=false" >> "$GITHUB_OUTPUT"
@@ -348,20 +363,20 @@ jobs:
if: steps.rel.outputs.released == 'true'
uses: docker/login-action@v3
with:
registry: git.shts.su
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Build and push images (bake)
if: steps.rel.outputs.released == 'true'
env:
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
CACHE_REF_GO: git.shts.su/${{ 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_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
run: |
set -euxo pipefail
+1 -1
View File
@@ -37,7 +37,7 @@
[
"@markwylde/semantic-release-gitea",
{
"giteaUrl": "https://git.shts.su",
"giteaUrl": "https://git.shx.one",
"assets": [
{
"path": "CHANGELOG.md",
+2 -2
View File
@@ -22,8 +22,8 @@ Control plane для управления префиксами, модулями
```powershell
cd deploy\compose
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shts.su/<owner>
docker login git.shts.su
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shx.one/<owner>
docker login git.shx.one
docker compose --profile microvps pull
docker compose --profile microvps up -d
```
@@ -49,7 +49,8 @@ export function AccessApiKeysCard({
<>
<FrameDataGrid
title="API-ключи"
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
description="Полный токен показывается только при создании и ротации."
className="min-w-0"
actions={
<>
<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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
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 type { ApiKey } from '@/types/api'
@@ -34,7 +35,14 @@ export function AccessApiKeysGrid({
{
accessorKey: 'name',
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: 'Имя' },
},
{
@@ -45,14 +53,6 @@ export function AccessApiKeysGrid({
),
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',
enableSorting: false,
@@ -77,11 +77,11 @@ export function AccessApiKeysGrid({
{
id: 'last_used_at',
accessorFn: (row) => row.last_used_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
cell: ({ row }) => (
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
),
meta: { headerTitle: 'Последнее использование' },
meta: { headerTitle: 'Использован' },
},
{
id: 'actions',
@@ -90,7 +90,7 @@ export function AccessApiKeysGrid({
cell: ({ row }) => {
const k = row.original
return (
<div className="flex gap-1">
<div className="flex shrink-0 gap-1">
<ConfirmDialog
trigger={
<Button
@@ -149,6 +149,7 @@ export function AccessApiKeysGrid({
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ключей"
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск API-ключей…"
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils'
@@ -40,9 +42,10 @@ export function ChartDonutMetric({
}
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">
<PieChart>
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
<Pie
data={data}
dataKey="count"
@@ -75,7 +78,7 @@ export function ChartDonutMetric({
</PieChart>
</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) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
@@ -51,7 +51,7 @@ export function NetworkOverviewAnalyticsCard({
label: 'Offline',
percent: 100 - speakersPct,
}}
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`}
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
/>
</div>
)
+6 -3
View File
@@ -41,14 +41,17 @@ export function BadgeTabs({
defaultValue={defaultValue}
onValueChange={onValueChange}
orientation="horizontal"
className={cn('w-full', className)}
className={cn('w-full min-w-0', className)}
>
<TabsList
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) => (
<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.label}
{item.count !== undefined ? (
@@ -1,62 +1,2 @@
import { type ReactNode } from "react"
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}
</>
)
}
/** @deprecated Import from `@/components/settings/setting-row` — settings-7 is not a target. */
export { SettingRow } from '@/components/settings/setting-row'
@@ -1,53 +1,2 @@
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 */
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>
)
}
/** @deprecated Import from `@/components/settings/settings-card` — settings-7 is not a target. */
export { SettingsCard } from '@/components/settings/settings-card'
@@ -1,37 +1,2 @@
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
}
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>
)
}
/** @deprecated Import from `@/components/settings/settings-field-group` — settings-7 is not a target. */
export { SettingsFieldGroup } from '@/components/settings/settings-field-group'
+52 -93
View File
@@ -1,19 +1,16 @@
import { Button } from '@evobgp/ui/components/button'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@evobgp/ui/components/drawer'
import type { ReactElement, ReactNode } from 'react'
import { useState, type ReactElement, type ReactNode } from 'react'
import {
confirmDrawerContentClassName,
DrawerActionsFooter,
} from '@/components/drawer-layout'
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@evobgp/ui/components/alert-dialog'
type ConfirmDialogBaseProps = {
title: string
@@ -41,90 +38,52 @@ type ConfirmDialogControlled = ConfirmDialogBaseProps & {
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
function ConfirmDrawerBody({
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>
</>
)
}
/** Confirm overlay — Alert Dialog. Preview: https://reui.io/preview/base/components/c-alert-dialog-11 */
export function ConfirmDialog(props: ConfirmDialogProps) {
const bodyProps: ConfirmDialogBaseProps = {
title: props.title,
description: props.description,
confirmLabel: props.confirmLabel,
cancelLabel: props.cancelLabel,
destructive: props.destructive,
onConfirm: props.onConfirm,
confirmDisabled: props.confirmDisabled,
confirmLoading: props.confirmLoading,
confirmLoadingLabel: props.confirmLoadingLabel,
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
const isControlled = props.open !== undefined
const open = isControlled ? props.open : uncontrolledOpen
function setOpen(next: boolean) {
if (!isControlled) setUncontrolledOpen(next)
if (isControlled) props.onOpenChange?.(next)
}
if (props.trigger) {
return (
<Drawer swipeDirection="right">
<DrawerTrigger render={props.trigger} />
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} />
</DrawerContent>
</Drawer>
)
const confirmText =
props.confirmLoading && props.confirmLoadingLabel
? props.confirmLoadingLabel
: props.confirmLoading
? `${props.confirmLabel ?? 'Подтвердить'}`
: (props.confirmLabel ?? 'Подтвердить')
function handleConfirm() {
props.onConfirm()
if (!props.confirmLoading && !isControlled) setOpen(false)
}
return (
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} controlled />
</DrawerContent>
</Drawer>
<AlertDialog open={open} onOpenChange={setOpen}>
{props.trigger ? <AlertDialogTrigger render={props.trigger} /> : null}
<AlertDialogContent>
<AlertDialogHeader>
<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
title="Недавняя активность"
description="Задачи, ревизии и сетевые события"
className="h-full"
className="h-full min-w-0"
>
{loading ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка</p>
@@ -82,7 +82,7 @@ export function DashboardActivityTimeline({
</TimelineIndicator>
</TimelineHeader>
<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}
</TimelineTitle>
<div className="mt-2">
@@ -82,7 +82,7 @@ function buildKpis({
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
label: 'Пиры Established',
label: 'Пиры установлены',
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${network.peersTotal} в каталоге`}
@@ -94,13 +94,13 @@ function buildKpis({
icon: <ServerCog aria-hidden />,
iconClassName: 'text-warning',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
label: 'Спикеры online',
label: 'Спикеры в сети',
footer: (
<Badge
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
size="sm"
>
{loading ? '…' : 'live-снимок'}
{loading ? '…' : 'онлайн'}
</Badge>
),
},
@@ -11,8 +11,8 @@ import {
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
@@ -40,6 +40,7 @@ import {
InputGroupButton,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api'
import {
@@ -51,20 +52,19 @@ import {
type SortingState,
} from '@tanstack/react-table'
type ModuleSort = 'name' | 'type' | 'priority' | 'last_refreshed_at'
type ModuleSort = 'name' | 'type' | 'priority'
type EnabledFilter = 'all' | 'enabled' | 'disabled'
const sortLabels: Record<ModuleSort, string> = {
name: 'Название',
type: 'Тип',
priority: 'Приоритет',
last_refreshed_at: 'Обновлено',
}
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
function buildSorting(sortBy: ModuleSort): SortingState {
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }]
return [{ id: sortBy, desc: false }]
}
export function DashboardModulesGrid({
@@ -119,9 +119,10 @@ export function DashboardModulesGrid({
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2">
<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>
),
minSize: 180,
meta: { headerTitle: 'Модуль' },
},
{
@@ -138,30 +139,9 @@ export function DashboardModulesGrid({
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
),
size: 88,
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
title="Модули"
description="Поиск, сортировка и быстрый переход к настройке"
className="min-w-0"
actions={
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
<PlusIcon />
@@ -204,14 +185,14 @@ export function DashboardModulesGrid({
columnsVisibility: false,
columnsResizable: false,
columnsMovable: false,
width: 'fixed',
width: 'auto',
}}
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
>
<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">
<InputGroup className="w-full min-w-40 sm:max-w-xs">
<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-0 @3xl:max-w-xs">
<InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
</InputGroupAddon>
@@ -337,8 +318,8 @@ export function DashboardModulesGrid({
<div className="border-t px-4 py-3">
{filteredModules.length > 0 ? (
<DataGridPagination
{...DATA_GRID_PAGINATION_RU}
sizes={[10, 15, 20]}
info="{from}{to} из {count}"
className="py-0"
/>
) : (
@@ -57,7 +57,7 @@ export function DashboardNetworkHealth({
}
primary={{
value: loading ? '—' : `${utilization}%`,
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры online',
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
percent: loading ? 0 : utilization,
badge: (
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
@@ -67,7 +67,7 @@ export function DashboardNetworkHealth({
}}
secondary={{
value: loading ? '—' : offline,
label: mode === 'peers' ? 'Не Established' : 'Offline',
label: mode === 'peers' ? 'Не установлены' : 'Не в сети',
percent: total > 0 ? Math.round((offline / total) * 100) : 0,
}}
footer={
@@ -3,13 +3,16 @@ import { useMemo, useState } from 'react'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { PanelCard } from '@/components/panel-card'
import { Separator } from '@evobgp/ui/components/separator'
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
import type { JobRow, ModuleRow } from '@/types/api'
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({
jobs,
modules,
@@ -32,7 +35,7 @@ export function DashboardOperationsBreakdown({
return (
<PanelCard
title="Поток операций"
description="Распределение задач и типов модулей"
description="Доли статусов задач или типов модулей"
actions={
<AnalyticsSegmentControl
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 ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
Загрузка
</div>
) : (
<>
<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>
{loading ? (
<div className="text-muted-foreground flex h-44 items-center justify-center text-sm">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
)}
</PanelCard>
)
}
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
{
id: 'lookup',
title: 'Проверка IP/домена',
description: 'Membership в списках и community (entry + snapshot).',
description: 'Проверка IP в списках и BGP-сообществах.',
to: '/lookup',
icon: <Search aria-hidden />,
iconClassName: 'text-primary',
@@ -23,7 +23,7 @@ const ACTIONS: QuickActionItem[] = [
{
id: 'communities',
title: 'BGP-сообщества',
description: 'Справочник communities для политик экспорта.',
description: 'Справочник BGP-сообществ для политик экспорта.',
to: '/directories',
icon: <Tags aria-hidden />,
iconClassName: 'text-info',
@@ -31,7 +31,7 @@ const ACTIONS: QuickActionItem[] = [
{
id: 'network',
title: 'Сеть',
description: 'Обзор пиров, спикеров и live-сессий BGP.',
description: 'Обзор пиров, спикеров и живых BGP-сессий.',
to: '/network',
search: { tab: 'overview' },
icon: <Network aria-hidden />,
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
@@ -49,7 +49,7 @@ export function DashboardRecentJobsGrid({
[nameById],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
const { table, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => {
@@ -63,16 +63,13 @@ export function DashboardRecentJobsGrid({
})
return (
<DataGridSection
<DataGridShell
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет задач"
showPagination={false}
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 { 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 { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
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,
columns,
getSearchText: (row) => row.id,
@@ -51,16 +51,13 @@ export function DashboardRecentRevisionsGrid({
})
return (
<DataGridSection
<DataGridShell
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ревизий"
showPagination={false}
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}
tableLayout={tableLayout}
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
className={className}
className={cn('min-w-0', className)}
onRowClick={onRowClick}
>
<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 ? 'Редактировать сообщество' : 'Новое сообщество BGP'}
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 { DataGridPrimaryCell } from '@/components/data-grid-cell'
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 { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { BgpCommunity } from '@/types/api'
@@ -11,12 +12,16 @@ import type { BgpCommunity } from '@/types/api'
export function DirectoriesCommunitiesGrid({
items,
isLoading = false,
canWrite = false,
onEdit,
}: {
items: BgpCommunity[]
isLoading?: boolean
canWrite?: boolean
onEdit?: (row: BgpCommunity) => void
}) {
const columns = useMemo<ColumnDef<BgpCommunity>[]>(
() => [
const columns = useMemo<ColumnDef<BgpCommunity>[]>(() => {
const cols: ColumnDef<BgpCommunity>[] = [
{
accessorKey: 'title',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
@@ -38,9 +43,23 @@ export function DirectoriesCommunitiesGrid({
cell: () => <CategoryBadge>community</CategoryBadge>,
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({
data: items,
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
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 { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { DohProfile } from '@/types/api'
@@ -11,12 +12,16 @@ import type { DohProfile } from '@/types/api'
export function DirectoriesDohGrid({
items,
isLoading = false,
canWrite = false,
onEdit,
}: {
items: DohProfile[]
isLoading?: boolean
canWrite?: boolean
onEdit?: (row: DohProfile) => void
}) {
const columns = useMemo<ColumnDef<DohProfile>[]>(
() => [
const columns = useMemo<ColumnDef<DohProfile>[]>(() => {
const cols: ColumnDef<DohProfile>[] = [
{
id: 'name',
accessorFn: (row) => row.name ?? row.url,
@@ -43,9 +48,23 @@ export function DirectoriesDohGrid({
cell: () => <CategoryBadge></CategoryBadge>,
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({
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('Timeout должен быть целым числом > 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">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 { 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'
interface EmptyStateProps {
icon?: LucideIcon | ReactNode
title: string
description?: string
icon?: ReactNode
action?: ReactNode
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 (
<div
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,
)}
>
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
<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}
{body}
</div>
)
}
+25 -24
View File
@@ -2,18 +2,13 @@ import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@evobgp/ui/components/drawer'
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
import {
DrawerActionsFooter,
formDrawerContentClassName,
} from '@/components/drawer-layout'
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evobgp/ui/components/sheet'
interface FormDrawerProps {
open: boolean
@@ -25,6 +20,7 @@ interface FormDrawerProps {
className?: string
}
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
export function FormDrawer({
open,
onOpenChange,
@@ -35,17 +31,22 @@ export function FormDrawer({
className,
}: FormDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
<DrawerContent className={cn(formDrawerContentClassName, className)}>
<DrawerHeader className="shrink-0 border-b border-border pb-4">
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 px-4 py-4">{children}</div>
</ScrollArea>
<DrawerActionsFooter>{footer}</DrawerActionsFooter>
</DrawerContent>
</Drawer>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
>
<SheetHeader className="shrink-0 border-b border-border/50">
<SheetTitle>{title}</SheetTitle>
{description ? <SheetDescription>{description}</SheetDescription> : null}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
<SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{footer}
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
+8 -6
View File
@@ -8,7 +8,6 @@ import {
Settings,
BookText,
KeyRound,
ServerCog,
Search,
} from 'lucide-react'
@@ -47,6 +46,7 @@ import { CommandPalette, type CommandPaletteItem } from '@/components/layout/com
import { NavUser } from '@/components/layout/nav-user'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
interface NavItem {
to: string
@@ -96,8 +96,7 @@ const NAV_GROUPS: NavGroup[] = [
label: 'Система',
items: [
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
],
},
]
@@ -161,6 +160,9 @@ export function AppShell({ children }: { children: ReactNode }) {
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
@@ -196,7 +198,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavUser />
</SidebarFooter>
</Sidebar>
<SidebarInset>
<SidebarInset id="main-content">
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
@@ -220,9 +222,9 @@ export function AppShell({ children }: { children: ReactNode }) {
<SystemMonitorPopover />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</main>
</div>
</SidebarInset>
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
</SidebarProvider>
+1 -1
View File
@@ -91,7 +91,7 @@ export function AppsMenu() {
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
render={<Link to="/settings" search={{ tab: 'ui' }} />}
className="justify-center text-sm font-medium"
>
Настройки
+3 -3
View File
@@ -136,7 +136,7 @@ export function NavUser() {
setApiToken(null)
if (typeof window !== 'undefined') {
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>
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
render={<Link to="/settings" search={{ tab: 'ui' }} />}
>
<SettingsIcon aria-hidden />
Настройки UI
Настройки
</DropdownMenuItem>
{authOn ? (
<DropdownMenuItem
@@ -7,12 +7,20 @@ import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
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 { Item, ItemMedia } from '@evobgp/ui/components/item'
import { cn } from '@evobgp/ui/lib/utils'
const READY_CHECK_ICONS: Record<string, typeof Database> = {
postgres: Database,
store: HardDrive,
store_backend: HardDrive,
jobs: ListTodo,
}
@@ -21,6 +29,7 @@ interface ReadyCheckRow {
label: string
subtitle?: string
icon: typeof Database
iconClassName?: string
status: string
statusLabel: string
}
@@ -34,12 +43,14 @@ export function MonitoringReadyGrid({
}) {
const data = useMemo<ReadyCheckRow[]>(() => {
const checks = ready.checks ?? {}
const systemReady = isSystemReady(ready.status)
const rows: ReadyCheckRow[] = [
{
id: 'liveness',
label: 'Живучесть',
subtitle: '/v1/health',
icon: HeartPulse,
iconClassName: health?.ok ? 'text-success' : 'text-destructive',
status: health?.ok ? 'ok' : 'error',
statusLabel: health?.ok ? 'В норме' : 'Ошибка',
},
@@ -48,19 +59,21 @@ export function MonitoringReadyGrid({
label: 'Готовность',
subtitle: '/v1/ready',
icon: ShieldCheck,
status: ready.status === 'ok' ? 'ok' : 'warning',
statusLabel: ready.status === 'ok' ? 'Готов' : jobStatusRu(ready.status ?? 'pending'),
iconClassName: systemReady ? 'text-success' : 'text-warning',
status: systemReady ? 'ok' : 'warning',
statusLabel: systemReady ? 'Готов' : 'Не готов',
},
]
for (const key of Object.keys(checks)) {
const value = checks[key]
const ok = typeof value === 'boolean' ? value : value?.ok !== false
const ok = isReadyCheckOk(value)
rows.push({
id: key,
label: readyCheckRu(key),
icon: READY_CHECK_ICONS[key] ?? ListTodo,
iconClassName: ok ? 'text-success' : 'text-destructive',
status: ok ? 'ok' : 'error',
statusLabel: ok ? 'В норме' : 'Ошибка',
statusLabel: readyCheckStatusLabel(value, ok),
})
}
return rows
@@ -74,8 +87,17 @@ export function MonitoringReadyGrid({
cell: ({ row }) => {
const Icon = row.original.icon
return (
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 items-center gap-2.5 py-0.5">
<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
title={row.original.label}
subtitle={row.original.subtitle}
@@ -90,7 +112,9 @@ export function MonitoringReadyGrid({
enableSorting: false,
header: 'Статус',
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: 'Статус' },
},
@@ -30,7 +30,7 @@ export function NetworkKpi({
id: 'peers-established',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
label: 'Пиры Established',
label: 'Пиры установлены',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
footer: (
<Badge variant="success-light" size="sm">
@@ -44,7 +44,7 @@ export function NetworkKpi({
id: 'speakers-online',
icon: <ServerCog aria-hidden />,
iconClassName: 'text-info',
label: 'Спикеры online',
label: 'Спикеры в сети',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
footer: (
<Badge
@@ -55,7 +55,7 @@ export function NetworkKpi({
}
size="sm"
>
{loading ? '…' : 'live'}
{loading ? '…' : 'онлайн'}
</Badge>
),
to: '/network',
@@ -32,13 +32,13 @@ function peerDeleteLabel(p: PeerRow): string {
const PEER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'established', label: 'Established' },
{ id: 'established', label: 'Установлены' },
{ id: 'pending', label: 'Ожидание' },
{ id: 'disabled', label: 'Выключены' },
]
const SESSION_STATE_OPTIONS = [
{ value: 'Established', label: 'Established' },
{ value: 'Established', label: 'Установлена' },
{ value: 'Idle', label: 'Idle' },
{ value: 'Active', label: 'Active' },
{ value: 'Connect', label: 'Connect' },
@@ -72,7 +72,7 @@ export function PeerFormDialog({
}
const asn = Number(remoteAsn)
if (!asn || asn <= 0) {
toast.error('Remote ASN должен быть больше 0')
toast.error('ASN соседа должен быть больше 0')
return
}
const body: BgpPeerCreate = {
@@ -132,7 +132,7 @@ export function PeerFormDialog({
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-asn">Remote ASN</Label>
<Label htmlFor="peer-asn">ASN соседа</Label>
<Input
id="peer-asn"
type="number"
+8 -4
View File
@@ -8,12 +8,16 @@ interface PageHeaderProps {
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-1">
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<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>
{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>
)
}
+1 -1
View File
@@ -54,7 +54,7 @@ export function PanelCard({
<Frame
dense
spacing={spacing}
className={cn(panelCardClassName, className)}
className={cn(panelCardClassName, 'min-w-0', className)}
>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
@@ -8,7 +8,7 @@ import {
} from '@evobgp/ui/components/chart'
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({
title,
description,
@@ -30,13 +30,24 @@ export function DonutBreakdownCard({
const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count }))
return (
<PanelCard title={title} description={description} className="h-full">
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center">
<PanelCard
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 ? (
<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">
<PieChart>
<Pie
@@ -59,11 +70,11 @@ export function DonutBreakdownCard({
<span className="text-lg font-semibold tabular-nums">{total}</span>
</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) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
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="size-2.5 shrink-0 rounded-full"
@@ -81,11 +92,6 @@ export function DonutBreakdownCard({
</ul>
</>
)}
{badge ? (
<Badge variant="success-light" className="absolute top-4 right-4 hidden sm:flex">
{badge}
</Badge>
) : null}
</div>
</PanelCard>
)
@@ -2,7 +2,10 @@ import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack'
import { Card, CardContent } from '@evobgp/ui/components/card'
import {
Frame,
FramePanel,
} from '@/components/reui/frame'
import {
Empty,
EmptyDescription,
@@ -11,7 +14,7 @@ import {
EmptyTitle,
} 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({
title,
description,
@@ -24,8 +27,8 @@ export function IllustratedEmptyState({
action?: ReactNode
}) {
return (
<Card className="bg-muted min-h-[240px] p-0 shadow-none">
<CardContent className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
<Frame dense spacing="sm" className="w-full">
<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}
<Empty className="max-w-md gap-5 bg-transparent p-0">
<EmptyHeader className="items-center gap-5 text-center">
@@ -40,7 +43,7 @@ export function IllustratedEmptyState({
</div>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
</FramePanel>
</Frame>
)
}
@@ -4,7 +4,7 @@ import { Badge } from '@/components/reui/badge'
import { CardDotField } from '@/components/dashboard/card-dot-field'
import { PanelCorners } from '@/components/patterns/panel-corners'
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 = {
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 }) {
const tone = toneStyles[metric.tone]
return (
<Card className="relative overflow-hidden p-0">
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
<PanelCorners />
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
<div className="flex min-w-0 flex-col gap-0.5">
<Frame dense spacing="sm" className="relative overflow-hidden">
<FramePanel className="relative isolate flex min-h-[7.25rem] flex-col justify-between gap-5 overflow-hidden p-4">
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
<PanelCorners />
<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-muted-foreground truncate text-xs leading-4">{metric.label}</span>
</div>
<div className="flex items-end justify-between gap-4">
<div className="min-w-0 space-y-2.5">
<div className="relative z-10 flex items-end justify-between gap-4">
<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">
{metric.value}
</div>
@@ -71,7 +71,7 @@ export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
</div>
<Sparkline values={metric.sparkline} color={tone.stroke} />
</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
)
}
+20 -14
View File
@@ -34,23 +34,29 @@ export function QueryState<T>({
children,
}: QueryStateProps<T>) {
if (isLoading) {
return <>{skeleton ?? <DefaultSkeleton />}</>
return (
<div role="status" aria-live="polite" aria-busy="true">
{skeleton ?? <DefaultSkeleton />}
</div>
)
}
if (isError) {
return (
<EmptyState
icon={<AlertCircle className="size-8" />}
title="Ошибка загрузки"
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
action={
onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCwIcon data-icon="inline-start" />
Повторить
</Button>
) : null
}
/>
<div role="alert">
<EmptyState
icon={AlertCircle}
title="Ошибка загрузки"
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
action={
onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCwIcon data-icon="inline-start" />
Повторить
</Button>
) : null
}
/>
</div>
)
}
if (empty || data == null) {
@@ -29,10 +29,10 @@ export function FrameDataGrid({
}) {
const hasHeader = Boolean(title || description || actions)
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">
{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">
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
+1 -1
View File
@@ -24,4 +24,4 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { FrameDataGrid } from './frame-data-grid'
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 {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
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 === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
if (count <= 6) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4'
}
@@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
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'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
@@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{item.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
item.iconClassName ?? DEFAULT_ICON_CLASS,
)}
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
<ItemMedia variant="icon" className="size-auto">
{item.icon}
</ItemMedia>
</Item>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
@@ -308,7 +305,7 @@ export function KpiStatGrid({
}
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))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
@@ -1,11 +1,4 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { PageHeader } from '@/components/page-header'
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
@@ -27,7 +20,7 @@ interface OpsDashboardProps {
}
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() {
return (
@@ -37,7 +30,7 @@ function OpsDashboardSkeleton() {
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
</header>
<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" />
</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/stats-12
* @see https://reui.io/docs/components/base/frame
*/
export function OpsDashboard({
title = 'Панель управления',
@@ -77,21 +72,18 @@ export function OpsDashboard({
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
<section
aria-label="Аналитика"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
{charts}
</section>
<section aria-label={queueTitle}>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>{queueTitle}</FrameTitle>
<FrameDescription>{queueDescription}</FrameDescription>
</FrameHeader>
<FramePanel>{queue}</FramePanel>
</Frame>
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
{queueDescription ? (
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
) : null}
</div>
{queue}
</section>
</div>
)
@@ -8,7 +8,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
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 { kpiCols } from './kpi-cols'
@@ -54,16 +54,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
action.iconClassName ?? DEFAULT_ICON_CLASS,
)}
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
>
<ItemMedia variant="icon" className="size-auto">
{action.icon}
</ItemMedia>
</Item>
{action.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
@@ -39,6 +39,8 @@ import {
AlertTitle,
} from '@/components/reui/alert'
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'
export interface ResourcePageTab {
@@ -322,6 +324,7 @@ export function ResourcePage<T extends object>({
fields={filterFields}
onChange={handleFiltersChange}
size="default"
i18n={FILTERS_I18N_RU}
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
@@ -355,11 +358,8 @@ export function ResourcePage<T extends object>({
<FrameFooter>
<DataGridPagination
{...DATA_GRID_PAGINATION_RU}
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</FramePanel>
@@ -1,104 +1,17 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
import { Outlet } from '@tanstack/react-router'
import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
import { cn } from '@evobgp/ui/lib/utils'
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.
* @see https://reui.io/preview/base/settings-16
* @see https://reui.io/preview/base/settings-3
* Settings layout — page chrome only.
* Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings).
* @see https://reui.io/preview/base/settings-7
* @see https://reui.io/blocks
*/
export function SettingsShell({
title = 'Настройки',
description = 'Подключение UI и параметры плоскости управления',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
export function SettingsShell() {
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<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>
<Outlet />
</PageShell>
)
}
+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 }
@@ -133,7 +133,9 @@ export function ScheduleAgendaPanel({
</div>
<Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}>
<SelectTrigger size="sm" className="w-full sm:w-44">
<SelectValue />
<SelectValue>
{FILTER_ITEMS.find((item) => item.value === filter)?.label}
</SelectValue>
</SelectTrigger>
<SelectContent>
{FILTER_ITEMS.map((item) => (
@@ -3,9 +3,9 @@ import { useTheme } from 'next-themes'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { Badge } from '@/components/reui/badge'
import {
ToggleGroup,
@@ -44,7 +44,7 @@ export function AppearanceSettingsTab() {
})
return (
<div className="space-y-6">
<div className="flex flex-col gap-6">
<SettingsCard
title="Тема интерфейса"
description="Быстрый переключатель также доступен в боковой панели"
@@ -61,6 +61,7 @@ export function AppearanceSettingsTab() {
мгновенно
</Badge>
}
stacked
last
>
<ToggleGroup
@@ -72,7 +73,7 @@ export function AppearanceSettingsTab() {
variant="outline"
size="sm"
aria-label="Тема интерфейса"
className="w-full sm:w-auto"
className="w-full min-w-0"
>
{THEME_OPTIONS.map((option) => {
const Icon = option.icon
@@ -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: 'обнулить (truncate)' },
{ value: 'delete', label: 'удалить файл (delete)' },
] 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: 'BGP source IPv4',
bird_bgp_source_ipv6: 'BGP source IPv6',
peer_discovery_enabled: 'Автообнаружение пиров',
peer_discovery_ranges_v4: 'Discovery CIDR IPv4',
peer_discovery_ranges_v6: 'Discovery CIDR IPv6',
peer_discovery_require_external: 'Только external ASN',
}
const BIRD_BOOL_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
peer_discovery_enabled:
'Dynamic neighbor range в 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: 'neighbor range … 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 control plane"
description="Глобальные параметры BIRD для pipeline refresh/apply. Сохранение — роль operator."
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="Retention"
description="Срок хранения ревизий в минутах."
>
<SettingsSettingField
title="Retention"
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 { useQueryClient } from '@tanstack/react-query'
import { Save } from 'lucide-react'
import { KeyRound, Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { Badge } from '@/components/reui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { LoadingButton } from '@/components/loading-button'
import {
DEV_API_TOKEN,
@@ -16,7 +17,6 @@ import {
TOKEN_STORAGE_KEY,
} from '@/lib/api-client'
import { authKeys } from '@/queries/auth'
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
@@ -50,12 +50,13 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-6">
{tokenRequired ? (
<Alert>
<Alert variant="warning">
<KeyRound aria-hidden />
<AlertTitle>Нужен API-токен</AlertTitle>
<AlertDescription>
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
dev».
Сохраните токен ниже или нажмите «Использовать dev».
</AlertDescription>
</Alert>
) : null}
@@ -64,7 +65,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
title="Подключение к API"
description="Токен хранится только в этом браузере (localStorage)"
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}>
Использовать dev
</Button>
@@ -83,8 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
title="Токен для запросов"
description={
<>
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
ключами tenant в разделе{' '}
Ключ для заголовка Authorization. Управление ключами tenant в разделе{' '}
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
Права доступа
</Link>
@@ -97,12 +97,14 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
</Badge>
}
labelFor="settings-token"
stacked
last
>
<Input
id="settings-token"
type="password"
autoComplete="off"
className="w-full min-w-0"
value={token}
onChange={(e) => setTokenValue(e.target.value)}
placeholder="dev или API-ключ"
@@ -6,7 +6,7 @@ import {
} from 'lucide-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 { Button } from '@evobgp/ui/components/button'
import {
@@ -23,7 +23,8 @@ import {
const ADMIN_SECTIONS = [
{
id: 'tenant-settings',
to: '/tenant-settings' as const,
to: '/settings' as const,
search: { tab: 'bird' as const },
title: 'Параметры арендатора',
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
icon: <SlidersHorizontalIcon aria-hidden="true" />,
@@ -32,6 +33,7 @@ const ADMIN_SECTIONS = [
{
id: 'access',
to: '/access' as const,
search: undefined,
title: 'Права доступа',
description: 'API-ключи tenant, роли и управление доступом.',
icon: <KeyRoundIcon aria-hidden="true" />,
@@ -40,6 +42,7 @@ const ADMIN_SECTIONS = [
{
id: 'monitoring',
to: '/monitoring' as const,
search: undefined,
title: 'Мониторинг',
description: 'Метрики, состояние jobs и observability control plane.',
icon: <ActivityIcon aria-hidden="true" />,
@@ -49,7 +52,7 @@ const ADMIN_SECTIONS = [
export function SectionsSettingsTab() {
return (
<div className="space-y-6">
<div className="flex flex-col gap-6">
<SettingsCard
title="Разделы control plane"
description="Параметры tenant и операции — отдельно от настроек браузера"
@@ -58,7 +61,7 @@ export function SectionsSettingsTab() {
{ADMIN_SECTIONS.map((section, index) => (
<Fragment key={section.id}>
{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!">
<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}
@@ -66,7 +69,7 @@ export function SectionsSettingsTab() {
</ItemMedia>
<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}
<Badge variant={section.badge.variant} size="xs">
{section.badge.label}
@@ -76,7 +79,17 @@ export function SectionsSettingsTab() {
</ItemContent>
<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>
</ItemActions>
@@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query'
import { MonitorIcon } from 'lucide-react'
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { Badge } from '@/components/reui/badge'
import { QueryState } from '@/components/query-state'
import { TOKEN_STORAGE_KEY } from '@/lib/api-client'
@@ -35,7 +35,7 @@ export function SessionSettingsTab() {
})
return (
<div className="space-y-6">
<div className="flex flex-col gap-6">
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
<QueryState
data={sessionQ.data}
@@ -58,7 +58,7 @@ export function SessionSettingsTab() {
</ItemMedia>
<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">
Активна
@@ -66,7 +66,9 @@ export function SessionSettingsTab() {
</ItemTitle>
<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>
</ItemContent>
</Item>
@@ -94,12 +96,13 @@ export function SessionSettingsTab() {
</Badge>
)
}
stacked
last
>
{sessionQ.data ? (
<p className="text-muted-foreground text-sm">
<p className="text-muted-foreground text-sm break-words">
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '}
<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 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 { BirdSettingsTab } from './bird-settings-tab'
import { ConnectionSettingsTab } from './connection-settings-tab'
import { SectionsSettingsTab } from './sections-settings-tab'
import { SessionSettingsTab } from './session-settings-tab'
import {
SETTINGS_TAB_ITEMS,
type SettingsTab,
type SettingsSection,
type SettingsTabItem,
} 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({
activeTab,
onTabChange,
tokenRequired,
showBird,
}: {
activeTab: SettingsTab
onTabChange: (tab: SettingsTab) => void
activeTab: SettingsSection
onTabChange: (tab: SettingsSection) => void
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 (
<BadgeTabs
value={activeTab}
onValueChange={(value) => onTabChange(value as SettingsTab)}
items={SETTINGS_TAB_ITEMS.map((tab) => ({
value: tab.value,
label: tab.label,
icon: tab.icon,
}))}
>
<TabsContent value="connection" className="mt-0">
<ConnectionSettingsTab tokenRequired={tokenRequired} />
</TabsContent>
<TabsContent value="session" className="mt-0">
<SessionSettingsTab />
</TabsContent>
<TabsContent value="appearance" className="mt-0">
<AppearanceSettingsTab />
</TabsContent>
<TabsContent value="sections" className="mt-0">
<SectionsSettingsTab />
</TabsContent>
</BadgeTabs>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
<header className="px-1">
<h1 className="text-xl font-semibold tracking-tight">Настройки</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
Интерфейс UI и глобальные параметры BIRD в одном разделе.
</p>
</header>
<Tabs
value={resolvedTab}
onValueChange={(value) => onTabChange(value as SettingsSection)}
orientation={isMobile ? 'horizontal' : 'vertical'}
className="w-full gap-4 lg:gap-8"
>
<SettingsNavigation
isMobile={isMobile}
activeValue={resolvedTab}
items={items}
/>
<div className="min-w-0 flex-1">
<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 { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from '@evobgp/ui/components/field'
import { SettingRow } from '@/components/settings/setting-row'
/** settings-3 row pattern for tenant/UI settings. */
/** Tenant settings row — same SettingRow DNA as UI settings. */
export function SettingsSettingField({
title,
description,
@@ -20,38 +12,34 @@ export function SettingsSettingField({
last,
labelFor,
contentClassName,
stacked,
}: {
title: string
description: ReactNode
description?: ReactNode
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
children: ReactNode
last?: boolean
labelFor?: string
contentClassName?: string
stacked?: boolean
}) {
return (
<>
<Field orientation="responsive" className="gap-4 px-4 py-4">
<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>
)}
{badge ? (
<Badge variant={badge.variant} size="sm">
{badge.label}
</Badge>
) : null}
</div>
<FieldDescription className="text-xs">{description}</FieldDescription>
</div>
<FieldContent className={cn('min-w-0 @md/field-group:w-78', contentClassName)}>
{children}
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
<SettingRow
title={title}
description={description}
last={last}
labelFor={labelFor}
contentClassName={contentClassName}
stacked={stacked}
titleAddon={
badge ? (
<Badge variant={badge.variant} size="sm">
{badge.label}
</Badge>
) : null
}
>
{children}
</SettingRow>
)
}
@@ -1,49 +1,32 @@
import {
KeyRoundIcon,
MonitorSmartphoneIcon,
PaletteIcon,
SlidersHorizontalIcon,
} from 'lucide-react'
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
import { type ReactNode } from 'react'
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections'
export type SettingsSection = 'ui' | 'bird'
export type SettingsTabItem = {
value: SettingsTab
value: SettingsSection
label: string
icon: ReactNode
}
/** Side rail items — settings-7 AccountSettings DNA. */
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
{
value: 'connection',
label: 'Подключение',
icon: <KeyRoundIcon aria-hidden="true" />,
value: 'ui',
label: 'Настройки UI',
icon: <SettingsIcon aria-hidden="true" />,
},
{
value: 'session',
label: 'Сессия',
icon: <MonitorSmartphoneIcon aria-hidden="true" />,
},
{
value: 'appearance',
label: 'Оформление',
icon: <PaletteIcon aria-hidden="true" />,
},
{
value: 'sections',
label: 'Разделы',
icon: <SlidersHorizontalIcon aria-hidden="true" />,
value: 'bird',
label: 'Настройки BIRD',
icon: <ServerCogIcon aria-hidden="true" />,
},
]
export function parseSettingsTab(value: unknown): SettingsTab {
if (
value === 'session' ||
value === 'appearance' ||
value === 'sections'
) {
return value
}
return 'connection'
const LEGACY_BIRD = new Set(['bird', 'revision', 'runtime-logs', 'additional'])
/** Maps current and legacy `?tab=` values onto the unified settings rail. */
export function parseSettingsSection(value: unknown): SettingsSection {
if (typeof value === 'string' && LEGACY_BIRD.has(value)) return 'bird'
return 'ui'
}
+3 -3
View File
@@ -39,11 +39,11 @@ export function AnalyticsDashboardSkeleton() {
</div>
<div className={dashboardMainSidebarClassName}>
<Skeleton className="h-96 w-full rounded-xl xl:col-span-8" />
<Skeleton className="h-96 w-full rounded-xl xl:col-span-4" />
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
</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" />
</div>
+33 -3
View File
@@ -308,6 +308,31 @@ export function sessionCanWriteModules(session: {
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. */
export function permissionForPath(pathname: string): string | null {
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('/monitoring')) return 'bgp:monitoring:read'
if (pathname.startsWith('/access')) return 'bgp:access:admin'
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
if (pathname.startsWith('/tenant-settings') || pathname.startsWith('/settings')) {
return canOpenSettings() ? null : 'bgp:settings:read'
}
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'
/** First path in the sidebar the current user may open. */
@@ -340,7 +371,6 @@ export function firstAllowedPath(): string {
'/schedule',
'/monitoring',
'/access',
'/tenant-settings',
'/settings',
]
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']> = {
...DATA_GRID_TABLE_LAYOUT,
dense: true,
width: 'auto',
}
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('Memory')
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'
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 === '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
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 'Memory'
if (n === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме'
if (n) return value
}
return 'В норме'
}
export function readinessBreakdown(
ready: ReadyStatus | null | undefined,
healthOk: boolean,
@@ -28,7 +81,7 @@ export function readinessBreakdown(
let failCount = 0
for (const value of Object.values(checks)) {
if (checkOk(value)) okCount += 1
if (isReadyCheckOk(value)) okCount += 1
else failCount += 1
}
@@ -61,9 +114,11 @@ export function readinessBreakdown(
if (slices.length === 1 && okCount === 0 && failCount === 0) {
slices.push({
key: 'ready',
label: ready?.status === 'ok' ? 'Готов' : 'Ожидает готовности',
label: isSystemReady(ready?.status) ? 'Готов' : 'Не готов',
count: 1,
color: 'var(--color-chart-4)',
color: isSystemReady(ready?.status)
? 'var(--color-chart-1)'
: 'var(--color-warning)',
})
}
+2
View File
@@ -117,6 +117,8 @@ export function readyCheckRu(key: string): string {
return 'Хранилище'
case 'jobs':
return 'Очередь задач'
case 'store_backend':
return 'Бэкенд хранилища'
default:
return key
}
+13 -3
View File
@@ -12,6 +12,10 @@
*/
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). */
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'
/** 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 { apiJSON } from '@/lib/api-client'
import type { CommunitiesResponse, DohProfilesResponse } from '@/types/api'
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON, apiMutate } from '@/lib/api-client'
import type {
BgpCommunity,
BgpCommunityCreate,
BgpCommunityPatch,
CommunitiesResponse,
DohProfile,
DohProfileCreate,
DohProfilePatch,
DohProfilesResponse,
} from '@/types/api'
export const directoriesKeys = {
all: ['directories'] as const,
@@ -23,3 +34,59 @@ export function directoriesDohQueryOptions() {
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('Сообщество создано')
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось создать сообщество'),
})
}
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('Сообщество обновлено')
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось обновить сообщество'),
})
}
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 {
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 {
+1 -1
View File
@@ -62,7 +62,7 @@ export const Route = createFileRoute('/_auth')({
if (!raw || !normalizeApiToken(raw)) {
throw redirect({
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}
/>
)
}
@@ -0,0 +1,8 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
beforeLoad: () => {
throw redirect({ to: '/settings', search: { tab: 'bird' } })
},
component: () => null,
})
+67 -46
View File
@@ -1,15 +1,23 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { KeyRound, MonitorIcon, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { PanelCard } from '@/components/panel-card'
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@evobgp/ui/components/item'
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/reui/badge'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { SettingsCard } from '@/components/settings/settings-card'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { sessionCanManageApiKeys } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth'
@@ -38,6 +46,7 @@ function AccessComponent() {
const kpiItems: KpiStatItem[] = useMemo(
() => [
{
id: 'keys-total',
label: 'Всего ключей',
value: keys.length,
icon: <KeyRound className="size-4" />,
@@ -48,6 +57,7 @@ function AccessComponent() {
),
},
{
id: 'keys-active',
label: 'Активных',
value: activeCount,
icon: <ShieldCheck className="size-4" />,
@@ -58,13 +68,14 @@ function AccessComponent() {
),
},
{
id: 'keys-revoked',
label: 'Отозванных',
value: revokedCount,
icon: <ShieldOff className="size-4" />,
variant: revokedCount > 0 ? 'warning' : 'default',
footer: (
<Badge variant={revokedCount > 0 ? 'warning-light' : 'outline'} size="sm">
revoked
отозваны
</Badge>
),
},
@@ -93,7 +104,7 @@ function AccessComponent() {
})()
return (
<div className="flex flex-col gap-6">
<div className="flex min-w-0 flex-col gap-6">
<PageHeader
title="Права доступа"
description="API-ключи control plane и текущая сессия Bearer-токена."
@@ -108,47 +119,55 @@ function AccessComponent() {
/>
{session ? (
<PanelCard
<SettingsCard
title="Текущая сессия"
description="Tenant и права текущего Bearer (API-ключ или portal JWT)."
contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
>
<div>
<p className="text-muted-foreground">Tenant</p>
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
</div>
<div>
<p className="text-muted-foreground">Доступ</p>
<p className="font-mono text-xs">{sessionAccessLabel}</p>
</div>
{sessionKindLabel ? (
<div>
<p className="text-muted-foreground">Тип</p>
<p className="font-mono text-xs">{sessionKindLabel}</p>
</div>
) : null}
{session.email ? (
<div>
<p className="text-muted-foreground">Email</p>
<p className="break-all text-xs">{session.email}</p>
</div>
) : null}
</PanelCard>
<ItemGroup className="gap-0">
<Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
<ItemMedia className="self-start">
<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">
<MonitorIcon aria-hidden="true" />
</Item>
</ItemMedia>
<ItemContent className="min-w-0 gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<ItemTitle className="leading-5">Этот браузер</ItemTitle>
{sessionKindLabel ? (
<Badge variant="info-light" size="sm">
{sessionKindLabel}
</Badge>
) : null}
</div>
<ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
<span className="break-all">
Tenant:{' '}
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
</span>
<span className="break-words">Доступ: {sessionAccessLabel}</span>
{session.email ? <span className="break-all">Email: {session.email}</span> : null}
</ItemDescription>
</ItemContent>
</Item>
</ItemGroup>
</SettingsCard>
) : (
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
Не удалось определить сессию. Укажите токен в{' '}
<Link
to="/settings"
search={{ tab: 'connection' }}
className="text-primary underline-offset-4 hover:underline"
>
настройках
</Link>{' '}
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
) : null}
</PanelCard>
<SettingsCard title="Текущая сессия">
<p className="text-muted-foreground px-5 py-4 text-sm">
Не удалось определить сессию. Укажите токен в{' '}
<Link
to="/settings"
search={{ tab: 'ui' }}
className="text-primary underline-offset-4 hover:underline"
>
настройках
</Link>{' '}
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
<span className="text-destructive mt-2 block">{sessionQuery.error.message}</span>
) : null}
</p>
</SettingsCard>
)}
{canManageKeys ? (
@@ -167,11 +186,13 @@ function AccessComponent() {
/>
</>
) : session ? (
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
</PanelCard>
<SettingsCard title="API-ключи">
<p className="text-muted-foreground px-5 py-4 text-sm break-words">
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
</p>
</SettingsCard>
) : null}
</div>
)
+25 -28
View File
@@ -7,7 +7,6 @@ import { Button } from '@evobgp/ui/components/button'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
@@ -15,7 +14,7 @@ import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-o
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
import { OpsDashboard } from '@/components/reui-kit'
import { FrameDataGrid, OpsDashboard } from '@/components/reui-kit'
import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface'
import {
moduleNameById,
@@ -37,7 +36,9 @@ function parseShowQuickActions(value: unknown): boolean {
}
/**
* Dashboard — OpsDashboard kit (KPI → QuickActions → charts → queue).
* Dashboard — OpsDashboard kit (KPI → QuickActions → modules → activity/health → queue).
* Charts slot is a vertical stack: modules stay full-width; activity shares a row
* with BGP widgets only at @5xl (container), never nested 8+4 inside a 2-col parent.
* @see https://reui.io/preview/base/dashboard-1
* @see https://reui.io/preview/base/stats-12
*/
@@ -99,51 +100,47 @@ function DashboardComponent() {
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
charts={
<>
<div className={dashboardMainSidebarClassName}>
<div className="xl:col-span-8">
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
</div>
<div className="xl:col-span-4">
<DashboardActivityTimeline
jobs={jobs}
revisions={revisions}
peers={peers}
speakers={speakers}
/>
</div>
<div className="min-w-0">
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
</div>
<div className={chartPanelGridClassName}>
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
<div className={dashboardMainSidebarClassName}>
<DashboardActivityTimeline
jobs={jobs}
revisions={revisions}
peers={peers}
speakers={speakers}
/>
<div className="grid min-w-0 items-start gap-4">
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
</div>
</div>
</>
}
queueTitle="Недавняя активность"
queueDescription="Задачи и ревизии плоскости управления"
queueTitle="Задачи и ревизии"
queueDescription="Последние фоновые операции и история конфигураций"
queue={
<div className="grid gap-4 lg:grid-cols-2">
<DashboardFramePanel
<div className={chartPanelGridClassName}>
<FrameDataGrid
title="Недавние задачи"
description="Последние фоновые операции"
className="h-full"
>
{activityLoading ? (
<Skeleton className="m-4 h-24 w-auto" />
) : (
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
<DashboardRecentJobsGrid jobs={jobs.slice(0, 8)} nameById={nameById} isLoading={refreshing} />
)}
</DashboardFramePanel>
<DashboardFramePanel
</FrameDataGrid>
<FrameDataGrid
title="Последние ревизии"
description="История конфигураций"
className="h-full"
>
{activityLoading ? (
<Skeleton className="m-4 h-24 w-auto" />
) : (
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
)}
</DashboardFramePanel>
</FrameDataGrid>
</div>
}
/>
+92 -8
View File
@@ -1,23 +1,41 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { BookText, Globe, RefreshCw, Tags } from 'lucide-react'
import { BookText, Globe, Plus, RefreshCw, Tags } from 'lucide-react'
import { useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { Badge } from '@/components/reui/badge'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { FrameDataGrid, KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import {
CommunityFormDialog,
} from '@/components/directories/community-create-dialog'
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
import {
DohProfileFormDialog,
} from '@/components/directories/doh-profile-create-dialog'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { Badge } from '@/components/reui/badge'
import { FrameDataGrid, KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { sessionCanWriteDirectories } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth'
import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories'
import type { BgpCommunity, DohProfile } from '@/types/api'
export const Route = createFileRoute('/_auth/directories')({
component: DirectoriesComponent,
})
function DirectoriesComponent() {
const [communityOpen, setCommunityOpen] = useState(false)
const [communityEdit, setCommunityEdit] = useState<BgpCommunity | null>(null)
const [dohOpen, setDohOpen] = useState(false)
const [dohEdit, setDohEdit] = useState<DohProfile | null>(null)
const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteDirectories(sessionQ.data)
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
@@ -58,6 +76,40 @@ function DirectoriesComponent() {
},
]
function openCreateCommunity() {
setCommunityEdit(null)
setCommunityOpen(true)
}
function openEditCommunity(row: BgpCommunity) {
setCommunityEdit(row)
setCommunityOpen(true)
}
function openCreateDoh() {
setDohEdit(null)
setDohOpen(true)
}
function openEditDoh(row: DohProfile) {
setDohEdit(row)
setDohOpen(true)
}
const addCommunityButton = canWrite ? (
<Button size="sm" type="button" onClick={openCreateCommunity}>
<Plus />
Добавить
</Button>
) : null
const addDohButton = canWrite ? (
<Button size="sm" type="button" onClick={openCreateDoh}>
<Plus />
Добавить
</Button>
) : null
return (
<div className="flex flex-col gap-6">
<PageHeader
@@ -92,6 +144,7 @@ function DirectoriesComponent() {
<FrameDataGrid
title="Сообщества BGP"
description="Теги для префиксов в фильтрах BIRD"
actions={addCommunityButton}
>
<QueryState
data={communities}
@@ -100,13 +153,16 @@ function DirectoriesComponent() {
error={communitiesQ.error}
empty={communities.length === 0}
emptyTitle="Нет сообществ"
emptyAction={addCommunityButton}
skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => communitiesQ.refetch()}
>
{(items) => (
{(rows) => (
<DirectoriesCommunitiesGrid
items={items}
items={rows}
isLoading={communitiesQ.isFetching && !communitiesQ.isLoading}
canWrite={canWrite}
onEdit={openEditCommunity}
/>
)}
</QueryState>
@@ -114,7 +170,11 @@ function DirectoriesComponent() {
</TabsContent>
<TabsContent value="doh" className="mt-0">
<FrameDataGrid title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей">
<FrameDataGrid
title="DoH профили"
description="Резолверы DNS-over-HTTPS для доменных модулей"
actions={addDohButton}
>
<QueryState
data={dohProfiles}
isLoading={dohQ.isLoading}
@@ -122,19 +182,43 @@ function DirectoriesComponent() {
error={dohQ.error}
empty={dohProfiles.length === 0}
emptyTitle="Нет DoH профилей"
emptyAction={addDohButton}
skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => dohQ.refetch()}
>
{(items) => (
{(rows) => (
<DirectoriesDohGrid
items={items}
items={rows}
isLoading={dohQ.isFetching && !dohQ.isLoading}
canWrite={canWrite}
onEdit={openEditDoh}
/>
)}
</QueryState>
</FrameDataGrid>
</TabsContent>
</BadgeTabs>
{canWrite ? (
<>
<CommunityFormDialog
open={communityOpen}
onOpenChange={(open) => {
setCommunityOpen(open)
if (!open) setCommunityEdit(null)
}}
editTarget={communityEdit}
/>
<DohProfileFormDialog
open={dohOpen}
onOpenChange={(open) => {
setDohOpen(open)
if (!open) setDohEdit(null)
}}
editTarget={dohEdit}
/>
</>
) : null}
</div>
)
}
+17 -8
View File
@@ -1,7 +1,13 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Card, CardContent } from '@evobgp/ui/components/card'
import { Button } from '@evobgp/ui/components/button'
import { PageHeader } from '@/components/page-header'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
export const Route = createFileRoute('/_auth/modules/new')({
component: NewModuleComponent,
@@ -14,11 +20,14 @@ function NewModuleComponent() {
title="Новый модуль"
description="Создание модуля — через API или будущая форма"
/>
<Card>
<CardContent className="flex flex-col gap-3 py-6 text-sm text-muted-foreground">
<p>
Форма создания модуля будет добавлена позже. Сейчас модули можно создать через API:
</p>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Создание через API</FrameTitle>
<FrameDescription>
Форма в UI появится позже. Сейчас модуль можно создать запросом ниже.
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3 text-sm text-muted-foreground">
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{`POST /v1/modules
{ "type": "DOMAINS", "name": "Мой список" }`}
@@ -26,8 +35,8 @@ function NewModuleComponent() {
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
Назад к списку
</Button>
</CardContent>
</Card>
</FramePanel>
</Frame>
</div>
)
}
+44 -42
View File
@@ -81,7 +81,7 @@ function MonitoringComponent() {
.slice(0, 5)
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2 md:gap-3">
<PageHeader
title="Мониторинг"
description={`Состояние API, BGP и задач для диагностики инцидентов${
@@ -110,11 +110,11 @@ function MonitoringComponent() {
{ value: 'runtime-logs', label: 'Файловые логи' },
]}
>
<TabsContent value="system" className="mt-0 flex flex-col gap-6">
<TabsContent value="system" className="mt-0 flex flex-col gap-2 md:gap-3">
{analyticsLoading ? (
<AnalyticsDashboardSkeleton />
) : (
<div className="grid gap-4 lg:grid-cols-2">
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
<MonitoringHealthCard
healthOk={healthQ.data?.ok ?? false}
ready={readyQ.data}
@@ -124,7 +124,7 @@ function MonitoringComponent() {
</div>
)}
<div className="grid gap-4 lg:grid-cols-2">
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
<FrameDataGrid
title="Доступность и готовность"
description="GET /v1/health · GET /v1/ready"
@@ -149,23 +149,24 @@ function MonitoringComponent() {
</span>
}
description="GET /v1/bird/status"
contentClassName="py-4"
className="h-full"
contentClassName="px-5 py-4"
>
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<div className="h-40" />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<div className="h-40" />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</PanelCard>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-4">
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
<div className="flex flex-col gap-2 md:gap-3">
<SegmentedProgressCard
title="Задачи"
description="Последние 100 задач · GET /v1/jobs"
@@ -189,8 +190,8 @@ function MonitoringComponent() {
footer={`В выборке: ${jobs.length} задач`}
/>
{failedJobs.length > 0 ? (
<PanelCard title="Последние ошибки" contentClassName="space-y-2 py-4">
<ul className="space-y-2">
<PanelCard title="Последние ошибки" contentClassName="px-5 py-4">
<ul className="flex flex-col gap-2">
{failedJobs.map((job) => (
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
<div className="flex items-start justify-between gap-2">
@@ -218,28 +219,29 @@ function MonitoringComponent() {
</span>
}
description="Краткая шпаргалка для первичной диагностики"
contentClassName="py-4"
className="h-full"
contentClassName="px-5 py-4"
>
<ul className="space-y-3 text-sm text-muted-foreground">
<li>
<span className="font-medium text-foreground">API недоступен.</span> Если{' '}
<code className="text-xs">/v1/health</code> возвращает ошибку проверьте процесс API и
его логи.
</li>
<li>
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
и <code className="text-xs">jobs</code> в проверках.
</li>
<li>
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</li>
<li>
<span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и
проверьте последние неуспешные задачи.
</li>
</ul>
<ul className="flex flex-col gap-3 text-sm text-muted-foreground">
<li>
<span className="font-medium text-foreground">API недоступен.</span> Если{' '}
<code className="text-xs">/v1/health</code> возвращает ошибку проверьте процесс API и
его логи.
</li>
<li>
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
и <code className="text-xs">jobs</code> в проверках.
</li>
<li>
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</li>
<li>
<span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и
проверьте последние неуспешные задачи.
</li>
</ul>
</PanelCard>
</div>
</TabsContent>
@@ -256,7 +258,7 @@ function MonitoringComponent() {
<IllustratedEmptyState
icon={FileText}
title="Файловые логи"
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в tenant-settings."
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в Настройках BIRD."
/>
</TabsContent>
</BadgeTabs>
@@ -282,7 +284,7 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100)
: null
return (
<div className="space-y-2">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Установлено / всего</span>
<span className="font-medium tabular-nums">
-51
View File
@@ -1,51 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { PageHeader } from '@/components/page-header'
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
import {
type SettingsTab,
} from '@/components/settings/settings-tabs-data'
const settingsSearchSchema = z.object({
tab: z
.enum(['connection', 'session', 'appearance', 'sections'])
.catch('connection'),
reason: z.enum(['token-required']).optional(),
})
export const Route = createFileRoute('/_auth/settings')({
component: SettingsComponent,
validateSearch: (search) => settingsSearchSchema.parse(search),
})
function SettingsComponent() {
const navigate = Route.useNavigate()
const { tab, reason } = Route.useSearch()
const tokenRequired = reason === 'token-required'
function handleTabChange(nextTab: SettingsTab) {
void navigate({
search: (prev) => ({
...prev,
tab: nextTab,
}),
replace: true,
})
}
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<PageHeader
title="Настройки"
description="Параметры интерфейса и подключения браузера к API. Параметры tenant — во вкладке «Разделы»."
/>
<SettingsPageShell
activeTab={tab}
onTabChange={handleTabChange}
tokenRequired={tokenRequired}
/>
</div>
)
}
@@ -1,364 +0,0 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { PanelCard } from '@/components/panel-card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { FrameDataGrid } from '@/components/reui-kit'
import { SelectField } from '@/components/select-field'
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
import { PageHeader } from '@/components/page-header'
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'
export const Route = createFileRoute('/_auth/tenant-settings')({
component: TenantSettingsComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
? search.tab
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
}),
})
const RUNTIME_LOGS_ENABLED_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const RUNTIME_LOGS_MODE_ITEMS = [
{ value: 'truncate', label: 'обнулить (truncate)' },
{ value: 'delete', label: 'удалить файл (delete)' },
] 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: 'BGP source IPv4',
bird_bgp_source_ipv6: 'BGP source IPv6',
peer_discovery_enabled: 'Автообнаружение пиров',
peer_discovery_ranges_v4: 'Discovery CIDR IPv4',
peer_discovery_ranges_v6: 'Discovery CIDR IPv6',
peer_discovery_require_external: 'Только external ASN',
}
const BIRD_BOOL_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
peer_discovery_enabled:
'Dynamic neighbor range в 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: 'neighbor range … external (любой чужой ASN)',
}
function TenantSettingsComponent() {
const search = useSearch({ from: '/_auth/tenant-settings' })
const navigate = Route.useNavigate()
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">
<PageHeader
title="Параметры арендатора"
description="Параметры плоскости управления для текущего арендатора (API /v1/settings)"
/>
<BadgeTabs
value={search.tab}
onValueChange={(tab) =>
navigate({
search: { tab: tab as 'bird' | 'revision' | 'runtime-logs' | 'additional' },
})
}
items={[
{ value: 'bird', label: 'BIRD' },
{ value: 'revision', label: 'Ревизии' },
{ value: 'runtime-logs', label: 'Файловые логи' },
{ value: 'additional', label: 'Дополнительно' },
]}
>
<TabsContent value="bird" className="mt-0">
<PanelCard
title="BIRD control plane"
description={
<>
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
<code className="text-xs">PATCH /v1/settings</code> (роль operator).
</>
}
contentClassName="space-y-4 py-4"
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-64" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="flex flex-col">
{BIRD_SETTING_KEYS.map((key, index) => (
<SettingsSettingField
key={key}
title={BIRD_LABELS[key]}
description={BIRD_HINTS[key] ?? key}
labelFor={key}
badge={{ label: 'BIRD', variant: 'info-light' }}
last={index === BIRD_SETTING_KEYS.length - 1}
>
{key === 'peer_discovery_enabled' ||
key === 'peer_discovery_require_external' ? (
<SelectField
id={key}
items={[...BIRD_BOOL_ITEMS]}
value={birdForm[key] || 'false'}
onValueChange={(v) =>
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
}
placeholder="Выкл"
/>
) : (
<Input
id={key}
value={birdForm[key] ?? ''}
onChange={(e) =>
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
}
placeholder={BIRD_LABELS[key]}
/>
)}
</SettingsSettingField>
))}
<div className="px-4 py-4">
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
</div>
</div>
)}
</QueryState>
</PanelCard>
</TabsContent>
<TabsContent value="revision" className="mt-0">
<PanelCard
title="Ревизии"
description="Время хранения ревизий в БД"
contentClassName="space-y-4 py-4"
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="flex max-w-sm flex-col gap-1.5">
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label>
<Input
id="revision_retention_minutes"
type="number"
value={revisionForm.revision_retention_minutes ?? ''}
onChange={(e) =>
setRevisionForm((s) => ({
...s,
revision_retention_minutes: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
revision_retention_minutes
</p>
<LoadingButton
className="mt-2 w-fit"
onClick={saveRevision}
loading={patchMutation.isPending}
>
<Save />
Сохранить
</LoadingButton>
</div>
)}
</QueryState>
</PanelCard>
</TabsContent>
<TabsContent value="runtime-logs" className="mt-0">
<PanelCard
title="Файловые логи"
description="Автоматическая очистка логов"
contentClassName="space-y-4 py-4"
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-48" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="grid gap-4 md:grid-cols-2">
<SelectField
label="Авто-очистка включена"
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
placeholder="Выберите"
description="runtime_logs_auto_enabled"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_enabled: v,
}))
}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
<Input
id="runtime_logs_max_file_mb"
type="number"
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_max_file_mb: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_max_file_mb
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_auto_schedule">Расписание (cron)</Label>
<Input
id="runtime_logs_auto_schedule"
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_schedule: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_schedule
</p>
</div>
<SelectField
label="Режим очистки"
items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
placeholder="Выберите"
description="runtime_logs_auto_mode"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_mode: v,
}))
}
/>
<div className="md:col-span-2">
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
</div>
</div>
)}
</QueryState>
</PanelCard>
</TabsContent>
<TabsContent value="additional" className="mt-0">
<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>
</TabsContent>
</BadgeTabs>
</div>
)
}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,7 +1,7 @@
# Скопируйте в .env рядом с docker-compose.yaml и подставьте своего владельца реестра (нижний регистр).
# Перед первым запуском: docker login git.shts.su
# Перед первым запуском: docker login git.shx.one
EVOBGP_REGISTRY=git.shts.su/denozord
EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest
# Для защищенного Web UI (Traefik + Let's Encrypt + Cloudflare DNS challenge)
+1 -1
View File
@@ -1,7 +1,7 @@
# Production /opt/evobgp — скопируйте в .env на сервере
# cp .env.production.example .env
EVOBGP_REGISTRY=git.shts.su/denozord
EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest
# Имя compose-проекта (должно совпадать с name: в docker-compose.yaml)
+1 -1
View File
@@ -1,7 +1,7 @@
# Скопируйте в .env.remote-speaker рядом с docker-compose.remote-speaker.yaml
# Значения agent_secret и node token — из Web UI после создания спикера.
EVOBGP_REGISTRY=git.shts.su/denozord
EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest
# Control plane (HTTPS в prod)
@@ -4,7 +4,7 @@
# docker compose --env-file .env.stack.microvps-full -f stack.microvps-full.yaml up -d
# Образы EvoBGP
EVOBGP_REGISTRY=git.shts.su/denozord
EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest
# Защищенный Web UI (Traefik + Let's Encrypt + Cloudflare DNS challenge)
+2 -2
View File
@@ -7,7 +7,7 @@ name: evobgp-bgp-stack
services:
bird2:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
cap_add:
- NET_ADMIN
@@ -17,7 +17,7 @@ services:
# network_mode: "host" # раскомментируйте при анонсе BGP с хоста
evobgp-agent:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
- bird2
@@ -1,6 +1,6 @@
# Дополнительный файл Compose: профиль microvps-full + ужатые лимиты под хост ~1 ГиБ RAM.
#
# Запуск (PowerShell, из каталога deploy\compose): сначала .env из .env.example, docker login git.shts.su, docker compose pull
# Запуск (PowerShell, из каталога deploy\compose): сначала .env из .env.example, docker login git.shx.one, docker compose pull
# docker compose -f docker-compose.yaml -f docker-compose.microvps-full.yaml --profile microvps-full up -d
#
# Остановка:
@@ -6,7 +6,7 @@
# Запуск:
# cd /opt/evobgp
# mkdir -p runtime-logs
# docker login git.shts.su
# docker login git.shx.one
# docker compose pull
# docker compose up -d
#
@@ -74,7 +74,7 @@ services:
max-file: "3"
bird2:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
cap_add:
- NET_ADMIN
@@ -93,7 +93,7 @@ services:
max-file: "3"
evobgp-agent:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
bird2:
@@ -112,7 +112,7 @@ services:
max-file: "3"
evobgp-all:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-all:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -157,7 +157,7 @@ services:
max-file: "3"
evobgp-web:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-web-all:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-web-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
evobgp-all:
@@ -22,7 +22,7 @@ x-logging: &default-logging
services:
bird2:
profiles: ["production", "plain", "fallback"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
network_mode: host
cap_add:
@@ -35,7 +35,7 @@ services:
evobgp-agent:
profiles: ["production"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
- bird2
@@ -70,7 +70,7 @@ services:
evobgp-agent-plain:
profiles: ["plain", "fallback"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
network_mode: host
depends_on:
@@ -127,7 +127,7 @@ services:
sync-bundle:
profiles: ["fallback"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-node:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-node:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
- bird2
+12 -12
View File
@@ -1,8 +1,8 @@
# EvoBGP Compose: профили `reference` (эталон) и `microvps` (одна VPS).
#
# Образы EvoBGP тянутся из Container Registry (не собираются здесь). Префикс и тег — .env (см. .env.example):
# EVOBGP_REGISTRY=git.shts.su/<owner> EVOBGP_IMAGE_TAG=latest
# Перед up: docker login git.shts.su && docker compose pull
# EVOBGP_REGISTRY=git.shx.one/<owner> EVOBGP_IMAGE_TAG=latest
# Перед up: docker login git.shx.one && docker compose pull
#
# Запуск:
# docker compose --profile reference up -d
@@ -81,7 +81,7 @@ services:
evobgp-api:
profiles: ["reference"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-api:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-api:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -115,7 +115,7 @@ services:
evobgp-scheduler:
profiles: ["reference"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-scheduler:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-scheduler:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -139,7 +139,7 @@ services:
evobgp-ingest:
profiles: ["reference"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-ingest:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-ingest:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -157,7 +157,7 @@ services:
evobgp-render:
profiles: ["reference"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-render:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-render:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -175,7 +175,7 @@ services:
evobgp-deploy:
profiles: ["reference"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-deploy:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-deploy:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -196,7 +196,7 @@ services:
evobgp-web:
profiles: ["reference"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-web:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-web:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
- evobgp-api
@@ -211,7 +211,7 @@ services:
evobgp-all:
profiles: ["microvps", "microvps-full"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-all:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -241,7 +241,7 @@ services:
# Web UI → evobgp-all (образ evobgp-web-all из реестра). Reference: сервис evobgp-web → evobgp-api.
evobgp-web-microvps:
profiles: ["microvps-full"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-web-all:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-web-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
- evobgp-all
@@ -329,7 +329,7 @@ services:
bird2:
profiles: ["reference", "microvps", "microvps-full"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
cap_add:
- NET_ADMIN
@@ -349,7 +349,7 @@ services:
evobgp-agent:
profiles: ["reference", "microvps", "microvps-full"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
- bird2
+4 -4
View File
@@ -78,7 +78,7 @@ services:
max-file: "3"
bird2:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
cap_add:
- NET_ADMIN
@@ -97,7 +97,7 @@ services:
max-file: "3"
evobgp-agent:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
bird2:
@@ -116,7 +116,7 @@ services:
max-file: "3"
evobgp-all:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-all:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -161,7 +161,7 @@ services:
max-file: "3"
evobgp-web:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-web-all:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-web-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
evobgp-all:
+3 -3
View File
@@ -17,8 +17,8 @@
Кэш registry (переменные bake):
- `git.shts.su/<owner>/evobgp-buildcache:go-buildcache`**запись** только из target `go-build-all`
- `git.shts.su/<owner>/evobgp-buildcache:web-buildcache`**запись** только из target `web-build`
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache`**запись** только из target `go-build-all`
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache`**запись** только из target `web-build`
Остальные bake-target’ы только `cache-from` (чтение). Параллельный `cache-to` в один ref ломает manifest в registry (`content descriptor … not found`).
@@ -32,7 +32,7 @@
```bash
cd deploy/docker
export REGISTRY=git.shts.su/<owner>
export REGISTRY=git.shx.one/<owner>
export IMAGE_TAG=latest
export SHORT_SHA=$(git rev-parse --short HEAD)
export VERSION=dev

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