Compare commits

...
4 Commits
Author SHA1 Message Date
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
60 changed files with 639 additions and 475 deletions
+6 -6
View File
@@ -37,11 +37,11 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
### Теги образов ### Теги образов
```text ```text
git.shts.su/<owner>/<имя>:latest git.shx.one/<owner>/<имя>:latest
git.shts.su/<owner>/<имя>:v1.2.3 git.shx.one/<owner>/<имя>:v1.2.3
git.shts.su/<owner>/<имя>:1.2.3 git.shx.one/<owner>/<имя>:1.2.3
git.shts.su/<owner>/<имя>:<short-sha> git.shx.one/<owner>/<имя>:<short-sha>
git.shts.su/<owner>/<имя>:sha-<full-sha> git.shx.one/<owner>/<имя>:sha-<full-sha>
``` ```
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`. Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
@@ -51,7 +51,7 @@ git.shts.su/<owner>/<имя>:sha-<full-sha>
Пример: Пример:
```bash ```bash
docker pull git.shts.su/myuser/evobgp-api:1.2.3 docker pull git.shx.one/myuser/evobgp-api:1.2.3
``` ```
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md). См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
+5 -5
View File
@@ -318,7 +318,7 @@ jobs:
- name: Semantic release - name: Semantic release
run: npx semantic-release run: npx semantic-release
env: env:
GITEA_URL: https://git.shts.su GITEA_URL: https://git.shx.one
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }} GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Detect new release - name: Detect new release
id: rel id: rel
@@ -363,20 +363,20 @@ jobs:
if: steps.rel.outputs.released == 'true' if: steps.rel.outputs.released == 'true'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.shts.su registry: git.shx.one
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT || gitea.token }} password: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Build and push images (bake) - name: Build and push images (bake)
if: steps.rel.outputs.released == 'true' if: steps.rel.outputs.released == 'true'
env: env:
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }} REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest IMAGE_TAG: latest
VERSION: ${{ steps.meta.outputs.version }} VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }} SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }} SHA_FULL: ${{ github.sha }}
BUILD_TIME: ${{ steps.meta.outputs.build_time }} BUILD_TIME: ${{ steps.meta.outputs.build_time }}
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
BUILDX_BAKE_ENTITLEMENTS_FS: "0" BUILDX_BAKE_ENTITLEMENTS_FS: "0"
run: | run: |
set -euxo pipefail set -euxo pipefail
+1 -1
View File
@@ -37,7 +37,7 @@
[ [
"@markwylde/semantic-release-gitea", "@markwylde/semantic-release-gitea",
{ {
"giteaUrl": "https://git.shts.su", "giteaUrl": "https://git.shx.one",
"assets": [ "assets": [
{ {
"path": "CHANGELOG.md", "path": "CHANGELOG.md",
+2 -2
View File
@@ -22,8 +22,8 @@ Control plane для управления префиксами, модулями
```powershell ```powershell
cd deploy\compose cd deploy\compose
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shts.su/<owner> Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shx.one/<owner>
docker login git.shts.su docker login git.shx.one
docker compose --profile microvps pull docker compose --profile microvps pull
docker compose --profile microvps up -d docker compose --profile microvps up -d
``` ```
@@ -49,7 +49,8 @@ export function AccessApiKeysCard({
<> <>
<FrameDataGrid <FrameDataGrid
title="API-ключи" title="API-ключи"
description="Управление ключами tenant. Полный токен показывается только при создании и ротации." description="Полный токен показывается только при создании и ротации."
className="min-w-0"
actions={ actions={
<> <>
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}> <Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
@@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatApiKeyDate } from '@/lib/access/api-key-labels' import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ApiKey } from '@/types/api' import type { ApiKey } from '@/types/api'
@@ -34,7 +35,14 @@ export function AccessApiKeysGrid({
{ {
accessorKey: 'name', accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />, cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name}
accent="primary"
subtitle={`${row.original.prefix}`}
/>
),
minSize: 160,
meta: { headerTitle: 'Имя' }, meta: { headerTitle: 'Имя' },
}, },
{ {
@@ -45,14 +53,6 @@ export function AccessApiKeysGrid({
), ),
meta: { headerTitle: 'Роль' }, meta: { headerTitle: 'Роль' },
}, },
{
accessorKey: 'prefix',
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}</span>
),
meta: { headerTitle: 'Префикс' },
},
{ {
id: 'status', id: 'status',
enableSorting: false, enableSorting: false,
@@ -77,11 +77,11 @@ export function AccessApiKeysGrid({
{ {
id: 'last_used_at', id: 'last_used_at',
accessorFn: (row) => row.last_used_at ?? '', accessorFn: (row) => row.last_used_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell> <DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
), ),
meta: { headerTitle: 'Последнее использование' }, meta: { headerTitle: 'Использован' },
}, },
{ {
id: 'actions', id: 'actions',
@@ -90,7 +90,7 @@ export function AccessApiKeysGrid({
cell: ({ row }) => { cell: ({ row }) => {
const k = row.original const k = row.original
return ( return (
<div className="flex gap-1"> <div className="flex shrink-0 gap-1">
<ConfirmDialog <ConfirmDialog
trigger={ trigger={
<Button <Button
@@ -149,6 +149,7 @@ export function AccessApiKeysGrid({
recordCount={filteredCount} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет ключей" emptyMessage="Нет ключей"
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter} searchValue={globalFilter}
onSearchChange={setGlobalFilter} onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск API-ключей…" searchPlaceholder="Поиск API-ключей…"
@@ -51,7 +51,7 @@ export function NetworkOverviewAnalyticsCard({
label: 'Offline', label: 'Offline',
percent: 100 - speakersPct, percent: 100 - speakersPct,
}} }}
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`} footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
/> />
</div> </div>
) )
+6 -3
View File
@@ -41,14 +41,17 @@ export function BadgeTabs({
defaultValue={defaultValue} defaultValue={defaultValue}
onValueChange={onValueChange} onValueChange={onValueChange}
orientation="horizontal" orientation="horizontal"
className={cn('w-full', className)} className={cn('w-full min-w-0', className)}
> >
<TabsList <TabsList
variant="line" variant="line"
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)} className={cn(
'mb-3.5 w-full min-w-0 justify-start gap-4 overflow-x-auto',
listClassName,
)}
> >
{items.map((item) => ( {items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2"> <TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
{item.icon} {item.icon}
{item.label} {item.label}
{item.count !== undefined ? ( {item.count !== undefined ? (
@@ -50,7 +50,7 @@ export function DashboardActivityTimeline({
<DashboardFramePanel <DashboardFramePanel
title="Недавняя активность" title="Недавняя активность"
description="Задачи, ревизии и сетевые события" description="Задачи, ревизии и сетевые события"
className="h-full" className="h-full min-w-0"
> >
{loading ? ( {loading ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка</p> <p className="text-muted-foreground px-4 py-6 text-sm">Загрузка</p>
@@ -82,7 +82,7 @@ export function DashboardActivityTimeline({
</TimelineIndicator> </TimelineIndicator>
</TimelineHeader> </TimelineHeader>
<TimelineContent className="min-w-0 pb-1 text-foreground"> <TimelineContent className="min-w-0 pb-1 text-foreground">
<TimelineTitle className="text-sm leading-snug font-normal"> <TimelineTitle className="text-sm leading-snug font-normal break-words">
{item.message} {item.message}
</TimelineTitle> </TimelineTitle>
<div className="mt-2"> <div className="mt-2">
@@ -82,7 +82,7 @@ function buildKpis({
icon: <Share2 aria-hidden />, icon: <Share2 aria-hidden />,
iconClassName: 'text-success', iconClassName: 'text-success',
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`, value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
label: 'Пиры Established', label: 'Пиры установлены',
footer: ( footer: (
<Badge variant="success-light" size="sm"> <Badge variant="success-light" size="sm">
{loading ? '…' : `${network.peersTotal} в каталоге`} {loading ? '…' : `${network.peersTotal} в каталоге`}
@@ -94,13 +94,13 @@ function buildKpis({
icon: <ServerCog aria-hidden />, icon: <ServerCog aria-hidden />,
iconClassName: 'text-warning', iconClassName: 'text-warning',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`, value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
label: 'Спикеры online', label: 'Спикеры в сети',
footer: ( footer: (
<Badge <Badge
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'} variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
size="sm" size="sm"
> >
{loading ? '…' : 'live-снимок'} {loading ? '…' : 'онлайн'}
</Badge> </Badge>
), ),
}, },
@@ -11,8 +11,8 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { CategoryBadge, ModeBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel' import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid' import { DataGrid } from '@/components/reui/data-grid/data-grid'
@@ -40,6 +40,7 @@ import {
InputGroupButton, InputGroupButton,
InputGroupInput, InputGroupInput,
} from '@evobgp/ui/components/input-group' } from '@evobgp/ui/components/input-group'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { moduleTypeRu } from '@/lib/ui-labels' import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api' import type { ModuleRow } from '@/types/api'
import { import {
@@ -51,20 +52,19 @@ import {
type SortingState, type SortingState,
} from '@tanstack/react-table' } from '@tanstack/react-table'
type ModuleSort = 'name' | 'type' | 'priority' | 'last_refreshed_at' type ModuleSort = 'name' | 'type' | 'priority'
type EnabledFilter = 'all' | 'enabled' | 'disabled' type EnabledFilter = 'all' | 'enabled' | 'disabled'
const sortLabels: Record<ModuleSort, string> = { const sortLabels: Record<ModuleSort, string> = {
name: 'Название', name: 'Название',
type: 'Тип', type: 'Тип',
priority: 'Приоритет', priority: 'Приоритет',
last_refreshed_at: 'Обновлено',
} }
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.' const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
function buildSorting(sortBy: ModuleSort): SortingState { function buildSorting(sortBy: ModuleSort): SortingState {
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }] return [{ id: sortBy, desc: false }]
} }
export function DashboardModulesGrid({ export function DashboardModulesGrid({
@@ -119,9 +119,10 @@ export function DashboardModulesGrid({
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden /> <BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" /> <DataGridPrimaryCell title={row.original.name} accent="primary" />
</div> </div>
), ),
minSize: 180,
meta: { headerTitle: 'Модуль' }, meta: { headerTitle: 'Модуль' },
}, },
{ {
@@ -138,30 +139,9 @@ export function DashboardModulesGrid({
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span> <span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
), ),
size: 88,
meta: { headerTitle: 'Приоритет' }, meta: { headerTitle: 'Приоритет' },
}, },
{
id: 'enabled',
accessorFn: (row) => (row.enabled !== false ? 'enabled' : 'disabled'),
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => <ModeBadge enabled={row.original.enabled !== false} />,
meta: { headerTitle: 'Состояние' },
},
{
id: 'last_refreshed_at',
accessorFn: (row) => row.last_refreshed_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.last_refreshed_at
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
sortingFn: (a, b) =>
(a.original.last_refreshed_at ?? '').localeCompare(b.original.last_refreshed_at ?? ''),
meta: { headerTitle: 'Обновлено' },
},
], ],
[], [],
) )
@@ -185,6 +165,7 @@ export function DashboardModulesGrid({
<DashboardFramePanel <DashboardFramePanel
title="Модули" title="Модули"
description="Поиск, сортировка и быстрый переход к настройке" description="Поиск, сортировка и быстрый переход к настройке"
className="min-w-0"
actions={ actions={
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}> <Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
<PlusIcon /> <PlusIcon />
@@ -204,14 +185,14 @@ export function DashboardModulesGrid({
columnsVisibility: false, columnsVisibility: false,
columnsResizable: false, columnsResizable: false,
columnsMovable: false, columnsMovable: false,
width: 'fixed', width: 'auto',
}} }}
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }} tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })} onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
> >
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 border-b px-4 py-3 @3xl:flex-row @3xl:items-center @3xl:justify-between">
<InputGroup className="w-full min-w-40 sm:max-w-xs"> <InputGroup className="w-full min-w-0 @3xl:max-w-xs">
<InputGroupAddon align="inline-start"> <InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden /> <SearchIcon className="text-muted-foreground size-4" aria-hidden />
</InputGroupAddon> </InputGroupAddon>
@@ -337,8 +318,8 @@ export function DashboardModulesGrid({
<div className="border-t px-4 py-3"> <div className="border-t px-4 py-3">
{filteredModules.length > 0 ? ( {filteredModules.length > 0 ? (
<DataGridPagination <DataGridPagination
{...DATA_GRID_PAGINATION_RU}
sizes={[10, 15, 20]} sizes={[10, 15, 20]}
info="{from}{to} из {count}"
className="py-0" className="py-0"
/> />
) : ( ) : (
@@ -57,7 +57,7 @@ export function DashboardNetworkHealth({
} }
primary={{ primary={{
value: loading ? '—' : `${utilization}%`, value: loading ? '—' : `${utilization}%`,
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры online', label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
percent: loading ? 0 : utilization, percent: loading ? 0 : utilization,
badge: ( badge: (
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]"> <Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
@@ -67,7 +67,7 @@ export function DashboardNetworkHealth({
}} }}
secondary={{ secondary={{
value: loading ? '—' : offline, value: loading ? '—' : offline,
label: mode === 'peers' ? 'Не Established' : 'Offline', label: mode === 'peers' ? 'Не установлены' : 'Не в сети',
percent: total > 0 ? Math.round((offline / total) * 100) : 0, percent: total > 0 ? Math.round((offline / total) * 100) : 0,
}} }}
footer={ footer={
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
{ {
id: 'lookup', id: 'lookup',
title: 'Проверка IP/домена', title: 'Проверка IP/домена',
description: 'Membership в списках и community (entry + snapshot).', description: 'Проверка IP в списках и BGP-сообществах.',
to: '/lookup', to: '/lookup',
icon: <Search aria-hidden />, icon: <Search aria-hidden />,
iconClassName: 'text-primary', iconClassName: 'text-primary',
@@ -23,7 +23,7 @@ const ACTIONS: QuickActionItem[] = [
{ {
id: 'communities', id: 'communities',
title: 'BGP-сообщества', title: 'BGP-сообщества',
description: 'Справочник communities для политик экспорта.', description: 'Справочник BGP-сообществ для политик экспорта.',
to: '/directories', to: '/directories',
icon: <Tags aria-hidden />, icon: <Tags aria-hidden />,
iconClassName: 'text-info', iconClassName: 'text-info',
@@ -31,7 +31,7 @@ const ACTIONS: QuickActionItem[] = [
{ {
id: 'network', id: 'network',
title: 'Сеть', title: 'Сеть',
description: 'Обзор пиров, спикеров и live-сессий BGP.', description: 'Обзор пиров, спикеров и живых BGP-сессий.',
to: '/network', to: '/network',
search: { tab: 'overview' }, search: { tab: 'overview' },
icon: <Network aria-hidden />, icon: <Network aria-hidden />,
+1 -1
View File
@@ -50,7 +50,7 @@ export function DataGridShell<TData extends object>({
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage} loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
tableLayout={tableLayout} tableLayout={tableLayout}
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES} tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
className={className} className={cn('min-w-0', className)}
onRowClick={onRowClick} onRowClick={onRowClick}
> >
<DataGridContainer border={false}> <DataGridContainer border={false}>
@@ -30,7 +30,7 @@ export function NetworkKpi({
id: 'peers-established', id: 'peers-established',
icon: <Share2 aria-hidden />, icon: <Share2 aria-hidden />,
iconClassName: 'text-success', iconClassName: 'text-success',
label: 'Пиры Established', label: 'Пиры установлены',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`, value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
footer: ( footer: (
<Badge variant="success-light" size="sm"> <Badge variant="success-light" size="sm">
@@ -44,7 +44,7 @@ export function NetworkKpi({
id: 'speakers-online', id: 'speakers-online',
icon: <ServerCog aria-hidden />, icon: <ServerCog aria-hidden />,
iconClassName: 'text-info', iconClassName: 'text-info',
label: 'Спикеры online', label: 'Спикеры в сети',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`, value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
footer: ( footer: (
<Badge <Badge
@@ -55,7 +55,7 @@ export function NetworkKpi({
} }
size="sm" size="sm"
> >
{loading ? '…' : 'live'} {loading ? '…' : 'онлайн'}
</Badge> </Badge>
), ),
to: '/network', to: '/network',
@@ -32,13 +32,13 @@ function peerDeleteLabel(p: PeerRow): string {
const PEER_TABS = [ const PEER_TABS = [
{ id: 'all', label: 'Все' }, { id: 'all', label: 'Все' },
{ id: 'established', label: 'Established' }, { id: 'established', label: 'Установлены' },
{ id: 'pending', label: 'Ожидание' }, { id: 'pending', label: 'Ожидание' },
{ id: 'disabled', label: 'Выключены' }, { id: 'disabled', label: 'Выключены' },
] ]
const SESSION_STATE_OPTIONS = [ const SESSION_STATE_OPTIONS = [
{ value: 'Established', label: 'Established' }, { value: 'Established', label: 'Установлена' },
{ value: 'Idle', label: 'Idle' }, { value: 'Idle', label: 'Idle' },
{ value: 'Active', label: 'Active' }, { value: 'Active', label: 'Active' },
{ value: 'Connect', label: 'Connect' }, { value: 'Connect', label: 'Connect' },
@@ -72,7 +72,7 @@ export function PeerFormDialog({
} }
const asn = Number(remoteAsn) const asn = Number(remoteAsn)
if (!asn || asn <= 0) { if (!asn || asn <= 0) {
toast.error('Remote ASN должен быть больше 0') toast.error('ASN соседа должен быть больше 0')
return return
} }
const body: BgpPeerCreate = { const body: BgpPeerCreate = {
@@ -132,7 +132,7 @@ export function PeerFormDialog({
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="peer-asn">Remote ASN</Label> <Label htmlFor="peer-asn">ASN соседа</Label>
<Input <Input
id="peer-asn" id="peer-asn"
type="number" type="number"
+8 -4
View File
@@ -8,12 +8,16 @@ interface PageHeaderProps {
export function PageHeader({ title, description, actions }: PageHeaderProps) { export function PageHeader({ title, description, actions }: PageHeaderProps) {
return ( return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="flex flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1> <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null} {description ? (
<p className="text-muted-foreground max-w-prose text-sm break-words">{description}</p>
) : null}
</div> </div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null} {actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null}
</div> </div>
) )
} }
+1 -1
View File
@@ -54,7 +54,7 @@ export function PanelCard({
<Frame <Frame
dense dense
spacing={spacing} spacing={spacing}
className={cn(panelCardClassName, className)} className={cn(panelCardClassName, 'min-w-0', className)}
> >
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs"> <FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? ( {hasHeader ? (
@@ -29,10 +29,10 @@ export function FrameDataGrid({
}) { }) {
const hasHeader = Boolean(title || description || actions) const hasHeader = Boolean(title || description || actions)
return ( return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}> <Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs"> <FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? ( {hasHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3 border-b"> <FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-px"> <div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null} {title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? ( {description ? (
+7 -4
View File
@@ -1,10 +1,13 @@
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */ /**
* Shared grid column classes for hybrid KPI / Quick Actions tiles.
* Container queries only — never 56 columns on laptop width (tiles overlap).
* @see https://reui.io/preview/base/stats-12
*/
export function kpiCols(count: number): string { export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1' if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2' if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3' if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4' if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5' if (count <= 6) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6' return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
} }
@@ -305,7 +305,7 @@ export function KpiStatGrid({
} }
return ( return (
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}> <Frame className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}> <div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => ( {list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} /> <KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
@@ -27,7 +27,7 @@ interface OpsDashboardProps {
} }
const rootClassName = const rootClassName =
'text-foreground @container flex w-full flex-col gap-2 md:gap-3' 'text-foreground @container flex w-full min-w-0 flex-col gap-4 md:gap-6'
function OpsDashboardSkeleton() { function OpsDashboardSkeleton() {
return ( return (
@@ -37,7 +37,7 @@ function OpsDashboardSkeleton() {
<Skeleton className="mt-2 h-4 w-80 max-w-full" /> <Skeleton className="mt-2 h-4 w-80 max-w-full" />
</header> </header>
<KpiStatGrid cards={[]} isLoading skeletonCount={4} /> <KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<div className="grid gap-2 @3xl:grid-cols-2"> <div className="flex min-w-0 flex-col gap-4">
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-64 w-full rounded-xl" />
</div> </div>
@@ -79,13 +79,13 @@ export function OpsDashboard({
<section <section
aria-label="Аналитика" aria-label="Аналитика"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2" className="flex min-w-0 flex-col gap-4"
> >
{charts} {charts}
</section> </section>
<section aria-label={queueTitle}> <section aria-label={queueTitle}>
<Frame dense spacing="sm" className="w-full"> <Frame dense spacing="sm" className="w-full min-w-0">
<FrameHeader> <FrameHeader>
<FrameTitle>{queueTitle}</FrameTitle> <FrameTitle>{queueTitle}</FrameTitle>
<FrameDescription>{queueDescription}</FrameDescription> <FrameDescription>{queueDescription}</FrameDescription>
@@ -39,6 +39,8 @@ import {
AlertTitle, AlertTitle,
} from '@/components/reui/alert' } from '@/components/reui/alert'
import { EmptyState } from '@/components/empty-state' import { EmptyState } from '@/components/empty-state'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { FILTERS_I18N_RU } from '@/lib/filters-i18n'
import { applyFiltersToData } from './filter-utils' import { applyFiltersToData } from './filter-utils'
export interface ResourcePageTab { export interface ResourcePageTab {
@@ -322,6 +324,7 @@ export function ResourcePage<T extends object>({
fields={filterFields} fields={filterFields}
onChange={handleFiltersChange} onChange={handleFiltersChange}
size="default" size="default"
i18n={FILTERS_I18N_RU}
trigger={ trigger={
<Button type="button" variant="outline" aria-label="Фильтры"> <Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" /> <FilterIcon className="size-4" aria-hidden="true" />
@@ -355,11 +358,8 @@ export function ResourcePage<T extends object>({
<FrameFooter> <FrameFooter>
<DataGridPagination <DataGridPagination
{...DATA_GRID_PAGINATION_RU}
sizes={[5, 10, 20, 50]} sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/> />
</FrameFooter> </FrameFooter>
</FramePanel> </FramePanel>
@@ -2,7 +2,6 @@ import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router' import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { ServerCogIcon, SettingsIcon } from 'lucide-react' import { ServerCogIcon, SettingsIcon } from 'lucide-react'
import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
@@ -37,45 +36,39 @@ interface SettingsShellProps {
} }
/** /**
* Settings shell Frame surface, no hairline Separator under header. * Settings shell Frame surface, settings-16 left rail.
* Rail stacks above content until the page container is wide enough (no viewport-only squeeze).
* @see https://reui.io/preview/base/settings-16 * @see https://reui.io/preview/base/settings-16
* @see https://reui.io/preview/base/settings-3 * @see https://reui.io/preview/base/settings-3
*/ */
export function SettingsShell({ export function SettingsShell({
title = 'Настройки', title = 'Настройки',
description = 'Подключение UI и параметры плоскости управления', description,
tabs = DEFAULT_TABS, tabs = DEFAULT_TABS,
}: SettingsShellProps) { }: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname }) const pathname = useRouterState({ select: (s) => s.location.pathname })
const headerDescription =
description ??
(pathname.startsWith('/tenant-settings')
? 'Глобальные параметры BIRD и плоскости управления'
: 'Подключение UI и параметры плоскости управления')
return ( return (
<PageShell> <PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5"> <div className="@container mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-5">
<PageHeader title={title} description={description} /> <PageHeader title={title} description={headerDescription} />
<div <div className="flex min-w-0 flex-col gap-5 @3xl:flex-row @3xl:items-start">
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? ( {tabs.length > 1 ? (
<nav <nav
aria-label="Разделы настроек" aria-label="Разделы настроек"
className={cn( className="scrollbar-none flex min-w-0 gap-1 overflow-x-auto @3xl:w-44 @3xl:shrink-0 @3xl:flex-col @3xl:overflow-visible"
'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) => { {tabs.map((tab) => {
const isActive = pathname.startsWith(tab.to) const isActive = pathname.startsWith(tab.to)
const className = cn( const className = cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors', 'flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0', '@3xl:w-full',
!isMobile && 'w-full',
isActive isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60' ? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground', : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
@@ -133,7 +133,9 @@ export function ScheduleAgendaPanel({
</div> </div>
<Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}> <Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}>
<SelectTrigger size="sm" className="w-full sm:w-44"> <SelectTrigger size="sm" className="w-full sm:w-44">
<SelectValue /> <SelectValue>
{FILTER_ITEMS.find((item) => item.value === filter)?.label}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FILTER_ITEMS.map((item) => ( {FILTER_ITEMS.map((item) => (
@@ -44,7 +44,7 @@ export function AppearanceSettingsTab() {
}) })
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-6">
<SettingsCard <SettingsCard
title="Тема интерфейса" title="Тема интерфейса"
description="Быстрый переключатель также доступен в боковой панели" description="Быстрый переключатель также доступен в боковой панели"
@@ -61,6 +61,7 @@ export function AppearanceSettingsTab() {
мгновенно мгновенно
</Badge> </Badge>
} }
stacked
last last
> >
<ToggleGroup <ToggleGroup
@@ -72,7 +73,7 @@ export function AppearanceSettingsTab() {
variant="outline" variant="outline"
size="sm" size="sm"
aria-label="Тема интерфейса" aria-label="Тема интерфейса"
className="w-full sm:w-auto" className="w-full min-w-0"
> >
{THEME_OPTIONS.map((option) => { {THEME_OPTIONS.map((option) => {
const Icon = option.icon const Icon = option.icon
@@ -1,6 +1,6 @@
import { Link, useNavigate } from '@tanstack/react-router' import { Link, useNavigate } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { Save } from 'lucide-react' import { KeyRound, Save } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -8,6 +8,7 @@ import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/settings/settings-card' import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group' import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { import {
DEV_API_TOKEN, DEV_API_TOKEN,
@@ -16,7 +17,6 @@ import {
TOKEN_STORAGE_KEY, TOKEN_STORAGE_KEY,
} from '@/lib/api-client' } from '@/lib/api-client'
import { authKeys } from '@/queries/auth' import { authKeys } from '@/queries/auth'
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
@@ -50,12 +50,13 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
} }
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-6">
{tokenRequired ? ( {tokenRequired ? (
<Alert> <Alert variant="warning">
<KeyRound aria-hidden />
<AlertTitle>Нужен API-токен</AlertTitle>
<AlertDescription> <AlertDescription>
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать Сохраните токен ниже или нажмите «Использовать dev».
dev».
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
@@ -64,7 +65,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
title="Подключение к API" title="Подключение к API"
description="Токен хранится только в этом браузере (localStorage)" description="Токен хранится только в этом браузере (localStorage)"
footer={ footer={
<div className="flex w-full flex-wrap justify-end gap-2"> <div className="flex w-full min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="outline" onClick={useDevToken}> <Button type="button" variant="outline" onClick={useDevToken}>
Использовать dev Использовать dev
</Button> </Button>
@@ -83,8 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
title="Токен для запросов" title="Токен для запросов"
description={ description={
<> <>
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление Ключ для заголовка Authorization. Управление ключами tenant в разделе{' '}
ключами tenant в разделе{' '}
<Link to="/access" className="text-primary underline-offset-4 hover:underline"> <Link to="/access" className="text-primary underline-offset-4 hover:underline">
Права доступа Права доступа
</Link> </Link>
@@ -97,12 +97,14 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
</Badge> </Badge>
} }
labelFor="settings-token" labelFor="settings-token"
stacked
last last
> >
<Input <Input
id="settings-token" id="settings-token"
type="password" type="password"
autoComplete="off" autoComplete="off"
className="w-full min-w-0"
value={token} value={token}
onChange={(e) => setTokenValue(e.target.value)} onChange={(e) => setTokenValue(e.target.value)}
placeholder="dev или API-ключ" placeholder="dev или API-ключ"
@@ -49,7 +49,7 @@ const ADMIN_SECTIONS = [
export function SectionsSettingsTab() { export function SectionsSettingsTab() {
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-6">
<SettingsCard <SettingsCard
title="Разделы control plane" title="Разделы control plane"
description="Параметры tenant и операции — отдельно от настроек браузера" description="Параметры tenant и операции — отдельно от настроек браузера"
@@ -58,7 +58,7 @@ export function SectionsSettingsTab() {
{ADMIN_SECTIONS.map((section, index) => ( {ADMIN_SECTIONS.map((section, index) => (
<Fragment key={section.id}> <Fragment key={section.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null} {index > 0 ? <ItemSeparator className="my-0" /> : null}
<Item className="min-h-0 items-center gap-4 px-5 py-3.5"> <Item className="min-h-0 min-w-0 flex-wrap items-center gap-4 px-5 py-3.5">
<ItemMedia className="self-center!"> <ItemMedia className="self-center!">
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60"> <Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
{section.icon} {section.icon}
@@ -66,7 +66,7 @@ export function SectionsSettingsTab() {
</ItemMedia> </ItemMedia>
<ItemContent className="min-w-0 justify-center gap-0.5 self-center"> <ItemContent className="min-w-0 justify-center gap-0.5 self-center">
<ItemTitle className="gap-2 leading-5"> <ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
{section.title} {section.title}
<Badge variant={section.badge.variant} size="xs"> <Badge variant={section.badge.variant} size="xs">
{section.badge.label} {section.badge.label}
@@ -35,7 +35,7 @@ export function SessionSettingsTab() {
}) })
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-6">
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session"> <SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
<QueryState <QueryState
data={sessionQ.data} data={sessionQ.data}
@@ -58,7 +58,7 @@ export function SessionSettingsTab() {
</ItemMedia> </ItemMedia>
<ItemContent className="min-w-0 justify-center gap-0.5 self-center"> <ItemContent className="min-w-0 justify-center gap-0.5 self-center">
<ItemTitle className="gap-2 leading-5"> <ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
Этот браузер Этот браузер
<Badge variant="success-light" size="xs"> <Badge variant="success-light" size="xs">
Активна Активна
@@ -66,7 +66,9 @@ export function SessionSettingsTab() {
</ItemTitle> </ItemTitle>
<ItemDescription className="leading-5"> <ItemDescription className="leading-5">
Tenant:{' '} Tenant:{' '}
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code> <code className="text-foreground font-mono text-xs break-all">
{session.tenant_id}
</code>
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
@@ -94,12 +96,13 @@ export function SessionSettingsTab() {
</Badge> </Badge>
) )
} }
stacked
last last
> >
{sessionQ.data ? ( {sessionQ.data ? (
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm break-words">
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '} Ключ с ролью <strong>{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>
) : ( ) : (
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
@@ -10,31 +10,59 @@ import {
FieldTitle, FieldTitle,
} from '@evobgp/ui/components/field' } from '@evobgp/ui/components/field'
interface SettingRowProps { export interface SettingRowProps {
title: string title: string
description?: ReactNode description?: ReactNode
children: ReactNode children: ReactNode
last?: boolean last?: boolean
/** Opt-in FieldSeparator after the row (default off — ReUI settings use Frame+gap). */
separated?: boolean
compact?: boolean
stacked?: boolean
labelFor?: string labelFor?: string
contentClassName?: string contentClassName?: string
className?: string
titleAddon?: ReactNode titleAddon?: ReactNode
} }
/** Settings row — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */ /**
* 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({ export function SettingRow({
title, title,
description, description,
children, children,
last, last,
separated = false,
compact,
stacked,
labelFor, labelFor,
contentClassName, contentClassName,
className,
titleAddon, titleAddon,
}: SettingRowProps) { }: SettingRowProps) {
return ( return (
<> <>
<Field orientation="responsive" className="gap-4 px-5 py-3"> <Field
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm"> orientation={stacked ? 'vertical' : 'responsive'}
<div className="flex flex-wrap items-center gap-2"> 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 ? ( {labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel> <FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : ( ) : (
@@ -44,20 +72,33 @@ export function SettingRow({
</div> </div>
{description ? ( {description ? (
<FieldDescription>{description}</FieldDescription> <FieldDescription className="text-sm break-words">{description}</FieldDescription>
) : null} ) : null}
</div> </div>
<FieldContent <FieldContent
className={cn('min-w-0 @md/field-group:w-78', contentClassName)} 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="flex w-full justify-start @md/field-group:justify-end"> <div
className={cn(
'flex w-full min-w-0 justify-start',
stacked ? 'justify-start' : '@md/field-group:justify-end',
)}
>
{children} {children}
</div> </div>
</FieldContent> </FieldContent>
</Field> </Field>
{!last ? <FieldSeparator /> : null} {separated && !last ? <FieldSeparator /> : null}
</> </>
) )
} }
@@ -31,7 +31,7 @@ export function SettingsCard({
footerClassName, footerClassName,
}: SettingsCardProps) { }: SettingsCardProps) {
return ( return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}> <Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs"> <FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
<FrameHeader className="gap-0 border-b px-5 py-3"> <FrameHeader className="gap-0 border-b px-5 py-3">
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
@@ -1,17 +1,9 @@
import { type ComponentProps, type ReactNode } from 'react' import { type ComponentProps, type ReactNode } from 'react'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils' import { SettingRow } from '@/components/settings/setting-row'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from '@evobgp/ui/components/field'
/** settings-3 row pattern for tenant/UI settings. */ /** Tenant settings row — same SettingRow DNA as UI settings. */
export function SettingsSettingField({ export function SettingsSettingField({
title, title,
description, description,
@@ -20,38 +12,34 @@ export function SettingsSettingField({
last, last,
labelFor, labelFor,
contentClassName, contentClassName,
stacked,
}: { }: {
title: string title: string
description: ReactNode description?: ReactNode
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] } badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
children: ReactNode children: ReactNode
last?: boolean last?: boolean
labelFor?: string labelFor?: string
contentClassName?: string contentClassName?: string
stacked?: boolean
}) { }) {
return ( return (
<> <SettingRow
<Field orientation="responsive" className="gap-4 px-4 py-4"> title={title}
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm"> description={description}
<div className="flex flex-wrap items-center gap-2"> last={last}
{labelFor ? ( labelFor={labelFor}
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel> contentClassName={contentClassName}
) : ( stacked={stacked}
<FieldTitle>{title}</FieldTitle> titleAddon={
)} badge ? (
{badge ? ( <Badge variant={badge.variant} size="sm">
<Badge variant={badge.variant} size="sm"> {badge.label}
{badge.label} </Badge>
</Badge> ) : null
) : null} }
</div> >
<FieldDescription className="text-xs">{description}</FieldDescription> {children}
</div> </SettingRow>
<FieldContent className={cn('min-w-0 @md/field-group:w-78', contentClassName)}>
{children}
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
) )
} }
+3 -3
View File
@@ -39,11 +39,11 @@ export function AnalyticsDashboardSkeleton() {
</div> </div>
<div className={dashboardMainSidebarClassName}> <div className={dashboardMainSidebarClassName}>
<Skeleton className="h-96 w-full rounded-xl xl:col-span-8" /> <Skeleton className="h-96 w-full min-w-0 rounded-xl" />
<Skeleton className="h-96 w-full rounded-xl xl:col-span-4" /> <Skeleton className="h-96 w-full min-w-0 rounded-xl" />
</div> </div>
<div className="grid gap-4 lg:grid-cols-2"> <div className={chartPanelGridClassName}>
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-64 w-full rounded-xl" />
</div> </div>
+1
View File
@@ -44,6 +44,7 @@ export const DATA_GRID_MESSAGES_RU = {
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = { export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
...DATA_GRID_TABLE_LAYOUT, ...DATA_GRID_TABLE_LAYOUT,
dense: true, dense: true,
width: 'auto',
} }
export function createTextGlobalFilter<TData extends object>( export function createTextGlobalFilter<TData extends object>(
+94
View File
@@ -0,0 +1,94 @@
import {
DEFAULT_I18N,
type FilterI18nConfig,
} from '@/components/reui/filters'
const OPERATOR_LABEL_RU: Record<string, string> = {
is: 'равно',
is_not: 'не равно',
is_any_of: 'любое из',
is_not_any_of: 'кроме',
includes_all: 'включает все',
excludes_all: 'исключает все',
before: 'до',
after: 'после',
between: 'между',
not_between: 'вне диапазона',
contains: 'содержит',
not_contains: 'не содержит',
starts_with: 'начинается с',
ends_with: 'заканчивается на',
equals: 'равно',
not_equals: 'не равно',
greater_than: 'больше',
less_than: 'меньше',
overlaps: 'пересекается',
includes: 'включает',
excludes: 'исключает',
empty: 'пусто',
not_empty: 'не пусто',
}
export const FILTERS_I18N_RU: FilterI18nConfig = {
...DEFAULT_I18N,
addFilter: 'Фильтр',
searchFields: 'Фильтр…',
noFieldsFound: 'Поля не найдены.',
noResultsFound: 'Ничего не найдено.',
select: 'Выбрать…',
true: 'Да',
false: 'Нет',
min: 'Мин.',
max: 'Макс.',
to: '—',
typeAndPressEnter: 'Введите и нажмите Enter',
selected: 'выбрано',
selectedCount: 'выбрано',
addFilterTitle: 'Добавить фильтр',
operators: {
...DEFAULT_I18N.operators,
is: 'равно',
isNot: 'не равно',
isAnyOf: 'любое из',
isNotAnyOf: 'кроме',
includesAll: 'включает все',
excludesAll: 'исключает все',
before: 'до',
after: 'после',
between: 'между',
notBetween: 'вне диапазона',
contains: 'содержит',
notContains: 'не содержит',
startsWith: 'начинается с',
endsWith: 'заканчивается на',
isExactly: 'точно',
equals: 'равно',
notEquals: 'не равно',
greaterThan: 'больше',
lessThan: 'меньше',
overlaps: 'пересекается',
includes: 'включает',
excludes: 'исключает',
includesAllOf: 'включает все',
includesAnyOf: 'включает любое',
empty: 'пусто',
notEmpty: 'не пусто',
},
placeholders: {
enterField: (fieldType: string) => `Введите ${fieldType}`,
selectField: 'Выбрать…',
searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}`,
enterKey: 'Введите ключ…',
enterValue: 'Введите значение…',
},
helpers: {
formatOperator: (operator: string) =>
OPERATOR_LABEL_RU[operator] ?? operator.replace(/_/g, ' '),
},
validation: {
invalidEmail: 'Некорректный email',
invalidUrl: 'Некорректный URL',
invalidTel: 'Некорректный телефон',
invalid: 'Некорректное значение',
},
}
+9 -3
View File
@@ -30,7 +30,13 @@ export const kpiGridClassName =
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5' export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
/** Two-column chart panel row (monitoring / network overview). */ /** Two-column chart panel row (monitoring / network overview). */
export const chartPanelGridClassName = 'grid grid-cols-1 gap-4 @5xl:grid-cols-2' export const chartPanelGridClassName =
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
/** Main + sidebar layout (8+4) used on dashboard. */ /**
export const dashboardMainSidebarClassName = 'grid gap-4 xl:grid-cols-12 xl:items-start' * Main + sidebar: stack until the dashboard container is wide enough.
* Do not nest this inside another 2-col grid that squeezes modules/timeline.
* @see https://reui.io/preview/base/dashboard-1
*/
export const dashboardMainSidebarClassName =
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
@@ -4,12 +4,12 @@ import { Save } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PanelCard } from '@/components/panel-card'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { FrameDataGrid } from '@/components/reui-kit' import { FrameDataGrid } from '@/components/reui-kit'
import { SelectField } from '@/components/select-field' 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 { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
import { SettingsSettingField } from '@/components/settings/settings-setting-field' import { SettingsSettingField } from '@/components/settings/settings-setting-field'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
@@ -114,7 +114,7 @@ function TenantSettingsComponent() {
} }
return ( return (
<div className="flex flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
<BadgeTabs <BadgeTabs
value={search.tab} value={search.tab}
onValueChange={(tab) => onValueChange={(tab) =>
@@ -130,90 +130,104 @@ function TenantSettingsComponent() {
]} ]}
> >
<TabsContent value="bird" className="mt-0"> <TabsContent value="bird" className="mt-0">
<PanelCard <SettingsCard
title="BIRD control plane" title="BIRD control plane"
description={ description="Глобальные параметры BIRD для pipeline refresh/apply. Сохранение — роль operator."
<> footer={
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '} <LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
<code className="text-xs">PATCH /v1/settings</code> (роль operator). <Save />
</> Сохранить
</LoadingButton>
} }
contentClassName="space-y-4 py-4"
> >
<QueryState <QueryState
data={partitioned} data={partitioned}
isLoading={settingsQ.isLoading} isLoading={settingsQ.isLoading}
isError={settingsQ.isError} isError={settingsQ.isError}
error={settingsQ.error} error={settingsQ.error}
skeleton={<div className="h-64" />} skeleton={<div className="h-64" />}
onRetry={() => settingsQ.refetch()} onRetry={() => settingsQ.refetch()}
> >
{() => ( {() => (
<div className="flex flex-col"> <SettingsFieldGroup
{BIRD_SETTING_KEYS.map((key, index) => ( legend="BIRD"
<SettingsSettingField description="Параметры демона и автообнаружения пиров."
key={key} >
title={BIRD_LABELS[key]} {BIRD_SETTING_KEYS.map((key, index) => (
description={BIRD_HINTS[key] ?? key} <SettingsSettingField
labelFor={key} key={key}
badge={{ label: 'BIRD', variant: 'info-light' }} title={BIRD_LABELS[key]}
last={index === BIRD_SETTING_KEYS.length - 1} description={BIRD_HINTS[key]}
> labelFor={key}
{key === 'peer_discovery_enabled' || badge={{ label: 'BIRD', variant: 'info-light' }}
key === 'peer_discovery_require_external' ? ( stacked
<SelectField last={index === BIRD_SETTING_KEYS.length - 1}
id={key} >
items={[...BIRD_BOOL_ITEMS]} {key === 'peer_discovery_enabled' ||
value={birdForm[key] || 'false'} key === 'peer_discovery_require_external' ? (
onValueChange={(v) => <SelectMenu
setBirdForm((s) => ({ ...s, [key]: v || 'false' })) id={key}
} items={[...BIRD_BOOL_ITEMS]}
placeholder="Выкл" value={birdForm[key] || 'false'}
/> onValueChange={(v) =>
) : ( setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
<Input }
id={key} placeholder="Выкл"
value={birdForm[key] ?? ''} />
onChange={(e) => ) : (
setBirdForm((s) => ({ ...s, [key]: e.target.value })) <Input
} id={key}
placeholder={BIRD_LABELS[key]} className="w-full min-w-0"
/> value={birdForm[key] ?? ''}
)} onChange={(e) =>
</SettingsSettingField> setBirdForm((s) => ({ ...s, [key]: e.target.value }))
))} }
<div className="px-4 py-4"> placeholder={BIRD_LABELS[key]}
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}> />
<Save /> )}
Сохранить </SettingsSettingField>
</LoadingButton> ))}
</div> </SettingsFieldGroup>
</div> )}
)} </QueryState>
</QueryState> </SettingsCard>
</PanelCard>
</TabsContent> </TabsContent>
<TabsContent value="revision" className="mt-0"> <TabsContent value="revision" className="mt-0">
<PanelCard <SettingsCard
title="Ревизии" title="Ревизии"
description="Время хранения ревизий в БД" description="Время хранения ревизий в БД"
contentClassName="space-y-4 py-4" footer={
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
}
> >
<QueryState <QueryState
data={partitioned} data={partitioned}
isLoading={settingsQ.isLoading} isLoading={settingsQ.isLoading}
isError={settingsQ.isError} isError={settingsQ.isError}
error={settingsQ.error} error={settingsQ.error}
skeleton={<div className="h-32" />} skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()} onRetry={() => settingsQ.refetch()}
> >
{() => ( {() => (
<div className="flex max-w-sm flex-col gap-1.5"> <SettingsFieldGroup
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label> legend="Retention"
description="Срок хранения ревизий в минутах."
>
<SettingsSettingField
title="Retention"
description="Сколько минут хранить ревизии конфигурации."
labelFor="revision_retention_minutes"
stacked
last
>
<Input <Input
id="revision_retention_minutes" id="revision_retention_minutes"
type="number" type="number"
className="w-full min-w-0"
value={revisionForm.revision_retention_minutes ?? ''} value={revisionForm.revision_retention_minutes ?? ''}
onChange={(e) => onChange={(e) =>
setRevisionForm((s) => ({ setRevisionForm((s) => ({
@@ -222,45 +236,46 @@ function TenantSettingsComponent() {
})) }))
} }
/> />
<p className="font-mono text-xs text-muted-foreground"> </SettingsSettingField>
revision_retention_minutes </SettingsFieldGroup>
</p> )}
<LoadingButton </QueryState>
className="mt-2 w-fit" </SettingsCard>
onClick={saveRevision}
loading={patchMutation.isPending}
>
<Save />
Сохранить
</LoadingButton>
</div>
)}
</QueryState>
</PanelCard>
</TabsContent> </TabsContent>
<TabsContent value="runtime-logs" className="mt-0"> <TabsContent value="runtime-logs" className="mt-0">
<PanelCard <SettingsCard
title="Файловые логи" title="Файловые логи"
description="Автоматическая очистка логов" description="Автоматическая очистка логов"
contentClassName="space-y-4 py-4" footer={
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
}
> >
<QueryState <QueryState
data={partitioned} data={partitioned}
isLoading={settingsQ.isLoading} isLoading={settingsQ.isLoading}
isError={settingsQ.isError} isError={settingsQ.isError}
error={settingsQ.error} error={settingsQ.error}
skeleton={<div className="h-48" />} skeleton={<div className="h-48" />}
onRetry={() => settingsQ.refetch()} onRetry={() => settingsQ.refetch()}
> >
{() => ( {() => (
<div className="grid gap-4 md:grid-cols-2"> <SettingsFieldGroup
<SelectField legend="Авто-очистка"
label="Авто-очистка включена" description="Расписание и лимиты файловых логов runtime."
>
<SettingsSettingField
title="Авто-очистка"
description="Включить периодическую очистку файловых логов."
stacked
>
<SelectMenu
items={[...RUNTIME_LOGS_ENABLED_ITEMS]} items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'} value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
placeholder="Выберите" placeholder="Выберите"
description="runtime_logs_auto_enabled"
onValueChange={(v) => onValueChange={(v) =>
v && v &&
setRuntimeLogsForm((s) => ({ setRuntimeLogsForm((s) => ({
@@ -269,45 +284,54 @@ function TenantSettingsComponent() {
})) }))
} }
/> />
<div className="flex flex-col gap-1.5"> </SettingsSettingField>
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label> <SettingsSettingField
<Input title="Макс. размер файла"
id="runtime_logs_max_file_mb" description="Порог в мегабайтах, после которого срабатывает очистка."
type="number" labelFor="runtime_logs_max_file_mb"
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''} stacked
onChange={(e) => >
setRuntimeLogsForm((s) => ({ <Input
...s, id="runtime_logs_max_file_mb"
runtime_logs_max_file_mb: e.target.value, type="number"
})) className="w-full min-w-0"
} value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
/> onChange={(e) =>
<p className="font-mono text-xs text-muted-foreground"> setRuntimeLogsForm((s) => ({
runtime_logs_max_file_mb ...s,
</p> runtime_logs_max_file_mb: e.target.value,
</div> }))
<div className="flex flex-col gap-1.5"> }
<Label htmlFor="runtime_logs_auto_schedule">Расписание (cron)</Label> />
<Input </SettingsSettingField>
id="runtime_logs_auto_schedule" <SettingsSettingField
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''} title="Расписание"
onChange={(e) => description="Cron-выражение для авто-очистки."
setRuntimeLogsForm((s) => ({ labelFor="runtime_logs_auto_schedule"
...s, stacked
runtime_logs_auto_schedule: e.target.value, >
})) <Input
} id="runtime_logs_auto_schedule"
/> className="w-full min-w-0"
<p className="font-mono text-xs text-muted-foreground"> value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
runtime_logs_auto_schedule onChange={(e) =>
</p> setRuntimeLogsForm((s) => ({
</div> ...s,
<SelectField runtime_logs_auto_schedule: e.target.value,
label="Режим очистки" }))
}
/>
</SettingsSettingField>
<SettingsSettingField
title="Режим очистки"
description="Обнулить файл или удалить его."
stacked
last
>
<SelectMenu
items={[...RUNTIME_LOGS_MODE_ITEMS]} items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''} value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
placeholder="Выберите" placeholder="Выберите"
description="runtime_logs_auto_mode"
onValueChange={(v) => onValueChange={(v) =>
v && v &&
setRuntimeLogsForm((s) => ({ setRuntimeLogsForm((s) => ({
@@ -316,16 +340,11 @@ function TenantSettingsComponent() {
})) }))
} }
/> />
<div className="md:col-span-2"> </SettingsSettingField>
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}> </SettingsFieldGroup>
<Save /> )}
Сохранить </QueryState>
</LoadingButton> </SettingsCard>
</div>
</div>
)}
</QueryState>
</PanelCard>
</TabsContent> </TabsContent>
<TabsContent value="additional" className="mt-0"> <TabsContent value="additional" className="mt-0">
+67 -46
View File
@@ -1,15 +1,23 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' 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 { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button' 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 { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit' import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { SettingsCard } from '@/components/settings/settings-card'
import { SectionCardsSkeleton } from '@/components/skeletons' import { SectionCardsSkeleton } from '@/components/skeletons'
import { sessionCanManageApiKeys } from '@/lib/auth' import { sessionCanManageApiKeys } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth' import { authSessionQueryOptions } from '@/queries/auth'
@@ -38,6 +46,7 @@ function AccessComponent() {
const kpiItems: KpiStatItem[] = useMemo( const kpiItems: KpiStatItem[] = useMemo(
() => [ () => [
{ {
id: 'keys-total',
label: 'Всего ключей', label: 'Всего ключей',
value: keys.length, value: keys.length,
icon: <KeyRound className="size-4" />, icon: <KeyRound className="size-4" />,
@@ -48,6 +57,7 @@ function AccessComponent() {
), ),
}, },
{ {
id: 'keys-active',
label: 'Активных', label: 'Активных',
value: activeCount, value: activeCount,
icon: <ShieldCheck className="size-4" />, icon: <ShieldCheck className="size-4" />,
@@ -58,13 +68,14 @@ function AccessComponent() {
), ),
}, },
{ {
id: 'keys-revoked',
label: 'Отозванных', label: 'Отозванных',
value: revokedCount, value: revokedCount,
icon: <ShieldOff className="size-4" />, icon: <ShieldOff className="size-4" />,
variant: revokedCount > 0 ? 'warning' : 'default', variant: revokedCount > 0 ? 'warning' : 'default',
footer: ( footer: (
<Badge variant={revokedCount > 0 ? 'warning-light' : 'outline'} size="sm"> <Badge variant={revokedCount > 0 ? 'warning-light' : 'outline'} size="sm">
revoked отозваны
</Badge> </Badge>
), ),
}, },
@@ -93,7 +104,7 @@ function AccessComponent() {
})() })()
return ( return (
<div className="flex flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
<PageHeader <PageHeader
title="Права доступа" title="Права доступа"
description="API-ключи control plane и текущая сессия Bearer-токена." description="API-ключи control plane и текущая сессия Bearer-токена."
@@ -108,47 +119,55 @@ function AccessComponent() {
/> />
{session ? ( {session ? (
<PanelCard <SettingsCard
title="Текущая сессия" title="Текущая сессия"
description="Tenant и права текущего Bearer (API-ключ или portal JWT)." description="Tenant и права текущего Bearer (API-ключ или portal JWT)."
contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
> >
<div> <ItemGroup className="gap-0">
<p className="text-muted-foreground">Tenant</p> <Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
<p className="break-all font-mono text-xs">{session.tenant_id}</p> <ItemMedia className="self-start">
</div> <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">
<div> <MonitorIcon aria-hidden="true" />
<p className="text-muted-foreground">Доступ</p> </Item>
<p className="font-mono text-xs">{sessionAccessLabel}</p> </ItemMedia>
</div> <ItemContent className="min-w-0 gap-3">
{sessionKindLabel ? ( <div className="flex min-w-0 flex-wrap items-center gap-2">
<div> <ItemTitle className="leading-5">Этот браузер</ItemTitle>
<p className="text-muted-foreground">Тип</p> {sessionKindLabel ? (
<p className="font-mono text-xs">{sessionKindLabel}</p> <Badge variant="info-light" size="sm">
</div> {sessionKindLabel}
) : null} </Badge>
{session.email ? ( ) : null}
<div> </div>
<p className="text-muted-foreground">Email</p> <ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
<p className="break-all text-xs">{session.email}</p> <span className="break-all">
</div> Tenant:{' '}
) : null} <code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
</PanelCard> </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"> <SettingsCard title="Текущая сессия">
Не удалось определить сессию. Укажите токен в{' '} <p className="text-muted-foreground px-5 py-4 text-sm">
<Link Не удалось определить сессию. Укажите токен в{' '}
to="/settings" <Link
search={{ tab: 'connection' }} to="/settings"
className="text-primary underline-offset-4 hover:underline" search={{ tab: 'connection' }}
> className="text-primary underline-offset-4 hover:underline"
настройках >
</Link>{' '} настройках
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed). </Link>{' '}
{sessionQuery.isError && sessionQuery.error instanceof Error ? ( (для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span> {sessionQuery.isError && sessionQuery.error instanceof Error ? (
) : null} <span className="text-destructive mt-2 block">{sessionQuery.error.message}</span>
</PanelCard> ) : null}
</p>
</SettingsCard>
)} )}
{canManageKeys ? ( {canManageKeys ? (
@@ -167,11 +186,13 @@ function AccessComponent() {
/> />
</> </>
) : session ? ( ) : session ? (
<PanelCard contentClassName="py-4 text-sm text-muted-foreground"> <SettingsCard title="API-ключи">
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT <p className="text-muted-foreground px-5 py-4 text-sm break-words">
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>. Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>. с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
</PanelCard> Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
</p>
</SettingsCard>
) : null} ) : null}
</div> </div>
) )
+21 -21
View File
@@ -37,7 +37,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/dashboard-1
* @see https://reui.io/preview/base/stats-12 * @see https://reui.io/preview/base/stats-12
*/ */
@@ -99,33 +101,31 @@ function DashboardComponent() {
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null} afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
charts={ charts={
<> <>
<div className={dashboardMainSidebarClassName}> <div className="min-w-0">
<div className="xl:col-span-8"> <DashboardModulesGrid modules={modules} isLoading={refreshing} />
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
</div>
<div className="xl:col-span-4">
<DashboardActivityTimeline
jobs={jobs}
revisions={revisions}
peers={peers}
speakers={speakers}
/>
</div>
</div> </div>
<div className={chartPanelGridClassName}> <div className={dashboardMainSidebarClassName}>
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} /> <DashboardActivityTimeline
<DashboardOperationsBreakdown jobs={jobs} modules={modules} /> jobs={jobs}
revisions={revisions}
peers={peers}
speakers={speakers}
/>
<div className="grid min-w-0 gap-4">
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
</div>
</div> </div>
</> </>
} }
queueTitle="Недавняя активность" queueTitle="Задачи и ревизии"
queueDescription="Задачи и ревизии плоскости управления" queueDescription="Последние фоновые операции и история конфигураций"
queue={ queue={
<div className="grid gap-4 lg:grid-cols-2"> <div className={chartPanelGridClassName}>
<DashboardFramePanel <DashboardFramePanel
title="Недавние задачи" title="Недавние задачи"
description="Последние фоновые операции" description="Последние фоновые операции"
className="h-full" className="h-full min-w-0"
> >
{activityLoading ? ( {activityLoading ? (
<Skeleton className="m-4 h-24 w-auto" /> <Skeleton className="m-4 h-24 w-auto" />
@@ -136,7 +136,7 @@ function DashboardComponent() {
<DashboardFramePanel <DashboardFramePanel
title="Последние ревизии" title="Последние ревизии"
description="История конфигураций" description="История конфигураций"
className="h-full" className="h-full min-w-0"
> >
{activityLoading ? ( {activityLoading ? (
<Skeleton className="m-4 h-24 w-auto" /> <Skeleton className="m-4 h-24 w-auto" />
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,7 +1,7 @@
# Скопируйте в .env рядом с docker-compose.yaml и подставьте своего владельца реестра (нижний регистр). # Скопируйте в .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 EVOBGP_IMAGE_TAG=latest
# Для защищенного Web UI (Traefik + Let's Encrypt + Cloudflare DNS challenge) # Для защищенного Web UI (Traefik + Let's Encrypt + Cloudflare DNS challenge)
+1 -1
View File
@@ -1,7 +1,7 @@
# Production /opt/evobgp — скопируйте в .env на сервере # Production /opt/evobgp — скопируйте в .env на сервере
# cp .env.production.example .env # cp .env.production.example .env
EVOBGP_REGISTRY=git.shts.su/denozord EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest EVOBGP_IMAGE_TAG=latest
# Имя compose-проекта (должно совпадать с name: в docker-compose.yaml) # Имя compose-проекта (должно совпадать с name: в docker-compose.yaml)
+1 -1
View File
@@ -1,7 +1,7 @@
# Скопируйте в .env.remote-speaker рядом с docker-compose.remote-speaker.yaml # Скопируйте в .env.remote-speaker рядом с docker-compose.remote-speaker.yaml
# Значения agent_secret и node token — из Web UI после создания спикера. # Значения agent_secret и node token — из Web UI после создания спикера.
EVOBGP_REGISTRY=git.shts.su/denozord EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest EVOBGP_IMAGE_TAG=latest
# Control plane (HTTPS в prod) # Control plane (HTTPS в prod)
@@ -4,7 +4,7 @@
# docker compose --env-file .env.stack.microvps-full -f stack.microvps-full.yaml up -d # docker compose --env-file .env.stack.microvps-full -f stack.microvps-full.yaml up -d
# Образы EvoBGP # Образы EvoBGP
EVOBGP_REGISTRY=git.shts.su/denozord EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest EVOBGP_IMAGE_TAG=latest
# Защищенный Web UI (Traefik + Let's Encrypt + Cloudflare DNS challenge) # Защищенный Web UI (Traefik + Let's Encrypt + Cloudflare DNS challenge)
+2 -2
View File
@@ -7,7 +7,7 @@ name: evobgp-bgp-stack
services: services:
bird2: 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 restart: unless-stopped
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
@@ -17,7 +17,7 @@ services:
# network_mode: "host" # раскомментируйте при анонсе BGP с хоста # network_mode: "host" # раскомментируйте при анонсе BGP с хоста
evobgp-agent: 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 restart: unless-stopped
depends_on: depends_on:
- bird2 - bird2
@@ -1,6 +1,6 @@
# Дополнительный файл Compose: профиль microvps-full + ужатые лимиты под хост ~1 ГиБ RAM. # Дополнительный файл 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 # docker compose -f docker-compose.yaml -f docker-compose.microvps-full.yaml --profile microvps-full up -d
# #
# Остановка: # Остановка:
@@ -6,7 +6,7 @@
# Запуск: # Запуск:
# cd /opt/evobgp # cd /opt/evobgp
# mkdir -p runtime-logs # mkdir -p runtime-logs
# docker login git.shts.su # docker login git.shx.one
# docker compose pull # docker compose pull
# docker compose up -d # docker compose up -d
# #
@@ -74,7 +74,7 @@ services:
max-file: "3" max-file: "3"
bird2: 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 restart: unless-stopped
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
@@ -93,7 +93,7 @@ services:
max-file: "3" max-file: "3"
evobgp-agent: 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 restart: unless-stopped
depends_on: depends_on:
bird2: bird2:
@@ -112,7 +112,7 @@ services:
max-file: "3" max-file: "3"
evobgp-all: 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 restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
@@ -157,7 +157,7 @@ services:
max-file: "3" max-file: "3"
evobgp-web: 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 restart: unless-stopped
depends_on: depends_on:
evobgp-all: evobgp-all:
@@ -22,7 +22,7 @@ x-logging: &default-logging
services: services:
bird2: bird2:
profiles: ["production", "plain", "fallback"] 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 restart: unless-stopped
network_mode: host network_mode: host
cap_add: cap_add:
@@ -35,7 +35,7 @@ services:
evobgp-agent: evobgp-agent:
profiles: ["production"] 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 restart: unless-stopped
depends_on: depends_on:
- bird2 - bird2
@@ -70,7 +70,7 @@ services:
evobgp-agent-plain: evobgp-agent-plain:
profiles: ["plain", "fallback"] 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 restart: unless-stopped
network_mode: host network_mode: host
depends_on: depends_on:
@@ -127,7 +127,7 @@ services:
sync-bundle: sync-bundle:
profiles: ["fallback"] 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 restart: unless-stopped
depends_on: depends_on:
- bird2 - bird2
+12 -12
View File
@@ -1,8 +1,8 @@
# EvoBGP Compose: профили `reference` (эталон) и `microvps` (одна VPS). # EvoBGP Compose: профили `reference` (эталон) и `microvps` (одна VPS).
# #
# Образы EvoBGP тянутся из Container Registry (не собираются здесь). Префикс и тег — .env (см. .env.example): # Образы EvoBGP тянутся из Container Registry (не собираются здесь). Префикс и тег — .env (см. .env.example):
# EVOBGP_REGISTRY=git.shts.su/<owner> EVOBGP_IMAGE_TAG=latest # EVOBGP_REGISTRY=git.shx.one/<owner> EVOBGP_IMAGE_TAG=latest
# Перед up: docker login git.shts.su && docker compose pull # Перед up: docker login git.shx.one && docker compose pull
# #
# Запуск: # Запуск:
# docker compose --profile reference up -d # docker compose --profile reference up -d
@@ -81,7 +81,7 @@ services:
evobgp-api: evobgp-api:
profiles: ["reference"] 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 restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
@@ -115,7 +115,7 @@ services:
evobgp-scheduler: evobgp-scheduler:
profiles: ["reference"] 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 restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
@@ -139,7 +139,7 @@ services:
evobgp-ingest: evobgp-ingest:
profiles: ["reference"] 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 restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
@@ -157,7 +157,7 @@ services:
evobgp-render: evobgp-render:
profiles: ["reference"] 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 restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
@@ -175,7 +175,7 @@ services:
evobgp-deploy: evobgp-deploy:
profiles: ["reference"] 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 restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
@@ -196,7 +196,7 @@ services:
evobgp-web: evobgp-web:
profiles: ["reference"] 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 restart: unless-stopped
depends_on: depends_on:
- evobgp-api - evobgp-api
@@ -211,7 +211,7 @@ services:
evobgp-all: evobgp-all:
profiles: ["microvps", "microvps-full"] 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 restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
@@ -241,7 +241,7 @@ services:
# Web UI → evobgp-all (образ evobgp-web-all из реестра). Reference: сервис evobgp-web → evobgp-api. # Web UI → evobgp-all (образ evobgp-web-all из реестра). Reference: сервис evobgp-web → evobgp-api.
evobgp-web-microvps: evobgp-web-microvps:
profiles: ["microvps-full"] 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 restart: unless-stopped
depends_on: depends_on:
- evobgp-all - evobgp-all
@@ -329,7 +329,7 @@ services:
bird2: bird2:
profiles: ["reference", "microvps", "microvps-full"] 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 restart: unless-stopped
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
@@ -349,7 +349,7 @@ services:
evobgp-agent: evobgp-agent:
profiles: ["reference", "microvps", "microvps-full"] 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 restart: unless-stopped
depends_on: depends_on:
- bird2 - bird2
+4 -4
View File
@@ -78,7 +78,7 @@ services:
max-file: "3" max-file: "3"
bird2: 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 restart: unless-stopped
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
@@ -97,7 +97,7 @@ services:
max-file: "3" max-file: "3"
evobgp-agent: 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 restart: unless-stopped
depends_on: depends_on:
bird2: bird2:
@@ -116,7 +116,7 @@ services:
max-file: "3" max-file: "3"
evobgp-all: 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 restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
@@ -161,7 +161,7 @@ services:
max-file: "3" max-file: "3"
evobgp-web: 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 restart: unless-stopped
depends_on: depends_on:
evobgp-all: evobgp-all:
+3 -3
View File
@@ -17,8 +17,8 @@
Кэш registry (переменные bake): Кэш registry (переменные bake):
- `git.shts.su/<owner>/evobgp-buildcache:go-buildcache`**запись** только из target `go-build-all` - `git.shx.one/<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:web-buildcache`**запись** только из target `web-build`
Остальные bake-target’ы только `cache-from` (чтение). Параллельный `cache-to` в один ref ломает manifest в registry (`content descriptor … not found`). Остальные bake-target’ы только `cache-from` (чтение). Параллельный `cache-to` в один ref ломает manifest в registry (`content descriptor … not found`).
@@ -32,7 +32,7 @@
```bash ```bash
cd deploy/docker cd deploy/docker
export REGISTRY=git.shts.su/<owner> export REGISTRY=git.shx.one/<owner>
export IMAGE_TAG=latest export IMAGE_TAG=latest
export SHORT_SHA=$(git rev-parse --short HEAD) export SHORT_SHA=$(git rev-parse --short HEAD)
export VERSION=dev export VERSION=dev
+1 -1
View File
@@ -4,7 +4,7 @@
# Переменные: REGISTRY, IMAGE_TAG, CACHE_REF_GO, CACHE_REF_WEB # Переменные: REGISTRY, IMAGE_TAG, CACHE_REF_GO, CACHE_REF_WEB
variable "REGISTRY" { variable "REGISTRY" {
default = "git.shts.su/evobgp" default = "git.shx.one/evobgp"
} }
variable "IMAGE_TAG" { variable "IMAGE_TAG" {
+1 -1
View File
@@ -14,7 +14,7 @@
| Раздел | Описание | | Раздел | Описание |
|--------|----------| |--------|----------|
| [overview.md](overview.md) | Ключевые возможности системы | | [overview.md](overview.md) | Ключевые возможности системы |
| [quickstart.md](quickstart.md) | Быстрый запуск: готовые образы из `git.shts.su`, Docker Compose, локальный Go, веб | | [quickstart.md](quickstart.md) | Быстрый запуск: готовые образы из `git.shx.one`, Docker Compose, локальный Go, веб |
| [architecture.md](architecture.md) | Компоненты, потоки данных, пакеты | | [architecture.md](architecture.md) | Компоненты, потоки данных, пакеты |
| [manual.md](manual.md) | Подробное руководство по модулям, процессам и API | | [manual.md](manual.md) | Подробное руководство по модулям, процессам и API |
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI | | [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI |
+1 -1
View File
@@ -4,7 +4,7 @@
## Portal SSO (JWT) ## Portal SSO (JWT)
Единый вход через **auth-portal** (app id `bgp`). См. [integrate-evobgp.md](https://git.shts.su/denozord/auth-portal/src/branch/main/docs/integrate-evobgp.md) в репозитории auth-portal. Единый вход через **auth-portal** (app id `bgp`). См. [integrate-evobgp.md](https://git.shx.one/denozord/auth-portal/src/branch/main/docs/integrate-evobgp.md) в репозитории auth-portal.
| Переменная | Назначение | | Переменная | Назначение |
|------------|------------| |------------|------------|
+1 -1
View File
@@ -1,6 +1,6 @@
# Firewall (deprecated in EvoBGP) # Firewall (deprecated in EvoBGP)
**Hard cutover:** подсистема firewall перенесена в отдельный продукт **[EvoFirewall](https://git.shts.su/denozord/EvoFirewall)**. **Hard cutover:** подсистема firewall перенесена в отдельный продукт **[EvoFirewall](https://git.shx.one/denozord/EvoFirewall)**.
Все HTTP-эндпоинты `/v1/firewall/*` в EvoBGP отвечают **410 Gone**. Все HTTP-эндпоинты `/v1/firewall/*` в EvoBGP отвечают **410 Gone**.
+1 -1
View File
@@ -65,7 +65,7 @@ CLI `evobgp-node` поддерживает `pull-bundle`, `verify-bundle`, `appl
## Связанные документы ## Связанные документы
- [quickstart.md](quickstart.md) — Docker Compose, **готовые образы из Container Registry** (`docker pull git.shts.su/...`) без сборки на сервере. - [quickstart.md](quickstart.md) — Docker Compose, **готовые образы из Container Registry** (`docker pull git.shx.one/...`) без сборки на сервере.
- [api.md](api.md) — как вызывать API на практике. - [api.md](api.md) — как вызывать API на практике.
- [architecture.md](architecture.md) — из каких процессов и пакетов это собрано. - [architecture.md](architecture.md) — из каких процессов и пакетов это собрано.
- [evobgp-api-sketches.md](evobgp-api-sketches.md) — ранние таблицы эндпоинтов (черновик). - [evobgp-api-sketches.md](evobgp-api-sketches.md) — ранние таблицы эндпоинтов (черновик).
+20 -20
View File
@@ -11,12 +11,12 @@
## Готовые образы без сборки (Container Registry Gitea) ## Готовые образы без сборки (Container Registry Gitea)
После успешного CI (push в `main` или `master`) образы публикуются в **Container Registry** вашего Gitea. В workflow зафиксирован хост реестра **`git.shts.su`**; имя владельца в пути образа — **в нижнем регистре**, как у `github.repository_owner` в CI (например, пользователь `Denozord` → префикс `denozord`). После успешного CI (push в `main` или `master`) образы публикуются в **Container Registry** вашего Gitea. В workflow зафиксирован хост реестра **`git.shx.one`**; имя владельца в пути образа — **в нижнем регистре**, как у `github.repository_owner` в CI (например, пользователь `Denozord` → префикс `denozord`).
### Шаблон имени и теги ### Шаблон имени и теги
```text ```text
git.shts.su/<owner>/<имя_образа>:<тег> git.shx.one/<owner>/<имя_образа>:<тег>
``` ```
**Теги:** `latest`, короткий SHA коммита (7 символов), `sha-<полный_sha>` — см. [.gitea/README.md](../.gitea/README.md). **Теги:** `latest`, короткий SHA коммита (7 символов), `sha-<полный_sha>` — см. [.gitea/README.md](../.gitea/README.md).
@@ -28,28 +28,28 @@ git.shts.su/<owner>/<имя_образа>:<тег>
На сервере или в PowerShell перед `docker pull`: На сервере или в PowerShell перед `docker pull`:
```powershell ```powershell
docker login git.shts.su docker login git.shx.one
``` ```
Укажите учётную запись Gitea и **PAT / пароль приложения** с правом чтения пакетов (или токен, который принимает ваш реестр). Укажите учётную запись Gitea и **PAT / пароль приложения** с правом чтения пакетов (или токен, который принимает ваш реестр).
### Ссылки и команды `docker pull` ### Ссылки и команды `docker pull`
Ниже пример для владельца **`denozord`** — замените сегмент пути на своего владельца репозитория в нижнем регистре. В веб-интерфейсе все контейнерные пакеты можно открыть разом: [список пакетов `denozord` на git.shts.su](https://git.shts.su/denozord/-/packages). Если прямая ссылка на версию `latest` не открывается (зависит от версии Gitea), откройте общий список пакетов и выберите нужный образ по имени. Ниже пример для владельца **`denozord`** — замените сегмент пути на своего владельца репозитория в нижнем регистре. В веб-интерфейсе все контейнерные пакеты можно открыть разом: [список пакетов `denozord` на git.shx.one](https://git.shx.one/denozord/-/packages). Если прямая ссылка на версию `latest` не открывается (зависит от версии Gitea), откройте общий список пакетов и выберите нужный образ по имени.
| Образ | Назначение | Страница пакета (пример) | Pull | | Образ | Назначение | Страница пакета (пример) | Pull |
|--------|------------|--------------------------|------| |--------|------------|--------------------------|------|
| `evobgp-api` | HTTP API (с `birdc` в образе) | [packages/…/evobgp-api](https://git.shts.su/denozord/-/packages/container/evobgp-api/latest) | `docker pull git.shts.su/denozord/evobgp-api:latest` | | `evobgp-api` | HTTP API (с `birdc` в образе) | [packages/…/evobgp-api](https://git.shx.one/denozord/-/packages/container/evobgp-api/latest) | `docker pull git.shx.one/denozord/evobgp-api:latest` |
| `evobgp-all` | Монолит microVPS: API + in-process воркеры scheduler/ingest/render/deploy | [packages/…/evobgp-all](https://git.shts.su/denozord/-/packages/container/evobgp-all/latest) | `docker pull git.shts.su/denozord/evobgp-all:latest` | | `evobgp-all` | Монолит microVPS: API + in-process воркеры scheduler/ingest/render/deploy | [packages/…/evobgp-all](https://git.shx.one/denozord/-/packages/container/evobgp-all/latest) | `docker pull git.shx.one/denozord/evobgp-all:latest` |
| `evobgp-scheduler` | Планировщик (reference) | [packages/…/evobgp-scheduler](https://git.shts.su/denozord/-/packages/container/evobgp-scheduler/latest) | `docker pull git.shts.su/denozord/evobgp-scheduler:latest` | | `evobgp-scheduler` | Планировщик (reference) | [packages/…/evobgp-scheduler](https://git.shx.one/denozord/-/packages/container/evobgp-scheduler/latest) | `docker pull git.shx.one/denozord/evobgp-scheduler:latest` |
| `evobgp-ingest` | Ingest CDN / ETag | [packages/…/evobgp-ingest](https://git.shts.su/denozord/-/packages/container/evobgp-ingest/latest) | `docker pull git.shts.su/denozord/evobgp-ingest:latest` | | `evobgp-ingest` | Ingest CDN / ETag | [packages/…/evobgp-ingest](https://git.shx.one/denozord/-/packages/container/evobgp-ingest/latest) | `docker pull git.shx.one/denozord/evobgp-ingest:latest` |
| `evobgp-render` | Render | [packages/…/evobgp-render](https://git.shts.su/denozord/-/packages/container/evobgp-render/latest) | `docker pull git.shts.su/denozord/evobgp-render:latest` | | `evobgp-render` | Render | [packages/…/evobgp-render](https://git.shx.one/denozord/-/packages/container/evobgp-render/latest) | `docker pull git.shx.one/denozord/evobgp-render:latest` |
| `evobgp-deploy` | Deploy | [packages/…/evobgp-deploy](https://git.shts.su/denozord/-/packages/container/evobgp-deploy/latest) | `docker pull git.shts.su/denozord/evobgp-deploy:latest` | | `evobgp-deploy` | Deploy | [packages/…/evobgp-deploy](https://git.shx.one/denozord/-/packages/container/evobgp-deploy/latest) | `docker pull git.shx.one/denozord/evobgp-deploy:latest` |
| `evobgp-node` | Нода на площадке | [packages/…/evobgp-node](https://git.shts.su/denozord/-/packages/container/evobgp-node/latest) | `docker pull git.shts.su/denozord/evobgp-node:latest` | | `evobgp-node` | Нода на площадке | [packages/…/evobgp-node](https://git.shx.one/denozord/-/packages/container/evobgp-node/latest) | `docker pull git.shx.one/denozord/evobgp-node:latest` |
| `evobgp-web` | Статика UI + nginx (прокси на **evobgp-api**) | [packages/…/evobgp-web](https://git.shts.su/denozord/-/packages/container/evobgp-web/latest) | `docker pull git.shts.su/denozord/evobgp-web:latest` | | `evobgp-web` | Статика UI + nginx (прокси на **evobgp-api**) | [packages/…/evobgp-web](https://git.shx.one/denozord/-/packages/container/evobgp-web/latest) | `docker pull git.shx.one/denozord/evobgp-web:latest` |
| `evobgp-web-all` | Тот же UI, прокси на **evobgp-all** (профиль `microvps-full`) | [packages/…/evobgp-web-all](https://git.shts.su/denozord/-/packages/container/evobgp-web-all/latest) | `docker pull git.shts.su/denozord/evobgp-web-all:latest` | | `evobgp-web-all` | Тот же UI, прокси на **evobgp-all** (профиль `microvps-full`) | [packages/…/evobgp-web-all](https://git.shx.one/denozord/-/packages/container/evobgp-web-all/latest) | `docker pull git.shx.one/denozord/evobgp-web-all:latest` |
| `evobgp-agent` | Агент (bird2 в образе) | [packages/…/evobgp-agent](https://git.shts.su/denozord/-/packages/container/evobgp-agent/latest) | `docker pull git.shts.su/denozord/evobgp-agent:latest` | | `evobgp-agent` | Агент (bird2 в образе) | [packages/…/evobgp-agent](https://git.shx.one/denozord/-/packages/container/evobgp-agent/latest) | `docker pull git.shx.one/denozord/evobgp-agent:latest` |
| `evobgp-bird2` | Только BIRD2 | [packages/…/evobgp-bird2](https://git.shts.su/denozord/-/packages/container/evobgp-bird2/latest) | `docker pull git.shts.su/denozord/evobgp-bird2:latest` | | `evobgp-bird2` | Только BIRD2 | [packages/…/evobgp-bird2](https://git.shx.one/denozord/-/packages/container/evobgp-bird2/latest) | `docker pull git.shx.one/denozord/evobgp-bird2:latest` |
На **Linux-сервере** команды `docker pull` и `docker login` такие же (выполняйте в обычном shell). На **Linux-сервере** команды `docker pull` и `docker login` такие же (выполняйте в обычном shell).
@@ -61,10 +61,10 @@ docker login git.shts.su
docker run --rm -p 8080:8080 ` docker run --rm -p 8080:8080 `
-e EVOBGP_DATABASE_URL="postgres://user:pass@host:5432/evobgp?sslmode=disable" ` -e EVOBGP_DATABASE_URL="postgres://user:pass@host:5432/evobgp?sslmode=disable" `
-e EVOBGP_HTTP_ADDR=":8080" ` -e EVOBGP_HTTP_ADDR=":8080" `
git.shts.su/denozord/evobgp-api:latest git.shx.one/denozord/evobgp-api:latest
``` ```
**Compose** в `deploy/compose` поднимает стек **только из образов реестра** (`image:`, без локальной сборки). Префикс и тег задаются через **`.env`** рядом с compose-файлами (шаблон — [deploy/compose/.env.example](../deploy/compose/.env.example)): `EVOBGP_REGISTRY=git.shts.su/<owner>` (владелец в **нижнем регистре**), `EVOBGP_IMAGE_TAG=latest` или SHA. Перед первым запуском: `docker login git.shts.su`, затем `docker compose pull` и `docker compose ... up -d`. Имена пакетов и CI — [.gitea/README.md](../.gitea/README.md). **Compose** в `deploy/compose` поднимает стек **только из образов реестра** (`image:`, без локальной сборки). Префикс и тег задаются через **`.env`** рядом с compose-файлами (шаблон — [deploy/compose/.env.example](../deploy/compose/.env.example)): `EVOBGP_REGISTRY=git.shx.one/<owner>` (владелец в **нижнем регистре**), `EVOBGP_IMAGE_TAG=latest` или SHA. Перед первым запуском: `docker login git.shx.one`, затем `docker compose pull` и `docker compose ... up -d`. Имена пакетов и CI — [.gitea/README.md](../.gitea/README.md).
## Вариант 1: Docker, профиль microvps ## Вариант 1: Docker, профиль microvps
@@ -73,7 +73,7 @@ docker run --rm -p 8080:8080 `
```powershell ```powershell
cd deploy\compose cd deploy\compose
Copy-Item .env.example .env -Force # или создайте .env вручную; укажите EVOBGP_REGISTRY / EVOBGP_IMAGE_TAG Copy-Item .env.example .env -Force # или создайте .env вручную; укажите EVOBGP_REGISTRY / EVOBGP_IMAGE_TAG
docker login git.shts.su docker login git.shx.one
docker compose --profile microvps pull docker compose --profile microvps pull
docker compose --profile microvps up -d docker compose --profile microvps up -d
``` ```
@@ -107,7 +107,7 @@ docker compose --profile microvps down -v
**Рекомендация:** на хосте с ровно **1 ГиБ** включите **swap** (1–2 ГиБ), иначе при пиках возможен OOM. На **2+ ГиБ** можно поднять тот же профиль **без** второго файла — останутся более мягкие лимиты из основного `docker-compose.yaml`. **Рекомендация:** на хосте с ровно **1 ГиБ** включите **swap** (1–2 ГиБ), иначе при пиках возможен OOM. На **2+ ГиБ** можно поднять тот же профиль **без** второго файла — останутся более мягкие лимиты из основного `docker-compose.yaml`.
Из каталога `deploy\compose` (как в варианте 1: `.env` из `.env.example`, при необходимости `docker login git.shts.su`): Из каталога `deploy\compose` (как в варианте 1: `.env` из `.env.example`, при необходимости `docker login git.shx.one`):
1. Подготовьте отдельный файл параметров защищенного UI: 1. Подготовьте отдельный файл параметров защищенного UI:
@@ -193,7 +193,7 @@ docker compose --env-file .env --env-file .env.web-sec -f docker-compose.yaml -f
```powershell ```powershell
cd deploy\compose cd deploy\compose
Copy-Item .env.example .env -Force Copy-Item .env.example .env -Force
docker login git.shts.su docker login git.shx.one
docker compose --profile reference pull docker compose --profile reference pull
docker compose --profile reference up -d docker compose --profile reference up -d
``` ```
+1 -1
View File
@@ -1,6 +1,6 @@
# Релизы и версионирование EvoBGP # Релизы и версионирование EvoBGP
EvoBGP использует [Conventional Commits](https://www.conventionalcommits.org/) и [semantic-release](https://semantic-release.gitbook.io/) для полностью автоматических релизов на Gitea (`git.shts.su`). Ручное повышение версии в коде не требуется. EvoBGP использует [Conventional Commits](https://www.conventionalcommits.org/) и [semantic-release](https://semantic-release.gitbook.io/) для полностью автоматических релизов на Gitea (`git.shx.one`). Ручное повышение версии в коде не требуется.
## Как определяется версия ## Как определяется версия
+1 -1
View File
@@ -15,7 +15,7 @@ if [ ! -f "$COMPOSE" ]; then
fi fi
# Required compose interpolation vars (CI mock values). # Required compose interpolation vars (CI mock values).
export EVOBGP_REGISTRY="${EVOBGP_REGISTRY:-git.shts.su/denozord}" export EVOBGP_REGISTRY="${EVOBGP_REGISTRY:-git.shx.one/denozord}"
export EVOBGP_IMAGE_TAG="${EVOBGP_IMAGE_TAG:-latest}" export EVOBGP_IMAGE_TAG="${EVOBGP_IMAGE_TAG:-latest}"
export EVOBGP_AGENT_SECRET="${EVOBGP_AGENT_SECRET:-ci-test-secret}" export EVOBGP_AGENT_SECRET="${EVOBGP_AGENT_SECRET:-ci-test-secret}"
export EVOBGP_CONTROL_PLANE_URL="${EVOBGP_CONTROL_PLANE_URL:-https://cp.example.com}" export EVOBGP_CONTROL_PLANE_URL="${EVOBGP_CONTROL_PLANE_URL:-https://cp.example.com}"