Compare commits

..
6 Commits
Author SHA1 Message Date
Denozordec e0d695f2a4 refactor(web): unify settings navigation and update tab structure
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 40s
CI / web (push) Successful in 55s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m6s
Refactored the settings components to unify navigation between UI and BIRD settings. Updated tab structures to streamline access and improve user experience. Adjusted routing and search parameters to reflect the new tab organization, ensuring a cohesive interface. Removed legacy tenant settings references and enhanced the settings page layout for clarity and usability.
2026-08-18 16:30:10 +07:00
Denozordec dfc33dcb99 refactor(web): streamline dashboard and tab components for improved layout
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 40s
CI / web (push) Successful in 57s
CI / release (push) Successful in 4m24s
Refactored various dashboard components and tab structures to enhance layout and responsiveness. Adjusted class names to include 'min-w-0' for better overflow handling and improved alignment. Updated the ChartDonutMetric to include a tooltip for better data representation. Simplified the DashboardRecentJobsGrid and DashboardRecentRevisionsGrid by replacing DataGridSection with DataGridShell for consistency. Enhanced the OpsDashboard layout for better clarity and usability.
2026-08-18 16:03:52 +07:00
Denozordec 44f70443c3 refactor(web): improve layout and responsiveness of settings components
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 53s
CI / web (push) Successful in 1m16s
CI / release (push) Successful in 4m24s
Enhanced various settings components by adding 'min-w-0' class for better overflow handling and adjusting flex properties for improved alignment. Updated card and grid structures to ensure a cohesive user experience across the settings interface. Refined descriptions and layout in several settings tabs for better clarity and usability.
2026-08-18 01:19:42 +07:00
DenozordecandCursor 357ed4ce7b refactor(web): enhance layout and styling for dashboard components
CI / changes (push) Successful in 12s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 1m18s
CI / web (push) Successful in 2m0s
CI / release (push) Successful in 6m51s
Updated various dashboard components to improve layout and responsiveness. Adjusted class names to include 'min-w-0' for better handling of overflow and added flex properties to ensure proper alignment. Refined grid structures and skeleton loading states for a more cohesive user experience.

Co-authored-by: Cursor <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
2026-08-17 16:43:39 +07:00
77 changed files with 1047 additions and 943 deletions
+6 -6
View File
@@ -37,11 +37,11 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
### Теги образов
```text
git.shts.su/<owner>/<имя>:latest
git.shts.su/<owner>/<имя>:v1.2.3
git.shts.su/<owner>/<имя>:1.2.3
git.shts.su/<owner>/<имя>:<short-sha>
git.shts.su/<owner>/<имя>:sha-<full-sha>
git.shx.one/<owner>/<имя>:latest
git.shx.one/<owner>/<имя>:v1.2.3
git.shx.one/<owner>/<имя>:1.2.3
git.shx.one/<owner>/<имя>:<short-sha>
git.shx.one/<owner>/<имя>:sha-<full-sha>
```
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
@@ -51,7 +51,7 @@ git.shts.su/<owner>/<имя>:sha-<full-sha>
Пример:
```bash
docker pull git.shts.su/myuser/evobgp-api:1.2.3
docker pull git.shx.one/myuser/evobgp-api:1.2.3
```
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
+5 -5
View File
@@ -318,7 +318,7 @@ jobs:
- name: Semantic release
run: npx semantic-release
env:
GITEA_URL: https://git.shts.su
GITEA_URL: https://git.shx.one
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Detect new release
id: rel
@@ -363,20 +363,20 @@ jobs:
if: steps.rel.outputs.released == 'true'
uses: docker/login-action@v3
with:
registry: git.shts.su
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Build and push images (bake)
if: steps.rel.outputs.released == 'true'
env:
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
run: |
set -euxo pipefail
+1 -1
View File
@@ -37,7 +37,7 @@
[
"@markwylde/semantic-release-gitea",
{
"giteaUrl": "https://git.shts.su",
"giteaUrl": "https://git.shx.one",
"assets": [
{
"path": "CHANGELOG.md",
+2 -2
View File
@@ -22,8 +22,8 @@ Control plane для управления префиксами, модулями
```powershell
cd deploy\compose
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shts.su/<owner>
docker login git.shts.su
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shx.one/<owner>
docker login git.shx.one
docker compose --profile microvps pull
docker compose --profile microvps up -d
```
@@ -49,7 +49,8 @@ export function AccessApiKeysCard({
<>
<FrameDataGrid
title="API-ключи"
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
description="Полный токен показывается только при создании и ротации."
className="min-w-0"
actions={
<>
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
@@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ApiKey } from '@/types/api'
@@ -34,7 +35,14 @@ export function AccessApiKeysGrid({
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name}
accent="primary"
subtitle={`${row.original.prefix}`}
/>
),
minSize: 160,
meta: { headerTitle: 'Имя' },
},
{
@@ -45,14 +53,6 @@ export function AccessApiKeysGrid({
),
meta: { headerTitle: 'Роль' },
},
{
accessorKey: 'prefix',
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}</span>
),
meta: { headerTitle: 'Префикс' },
},
{
id: 'status',
enableSorting: false,
@@ -77,11 +77,11 @@ export function AccessApiKeysGrid({
{
id: 'last_used_at',
accessorFn: (row) => row.last_used_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
cell: ({ row }) => (
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
),
meta: { headerTitle: 'Последнее использование' },
meta: { headerTitle: 'Использован' },
},
{
id: 'actions',
@@ -90,7 +90,7 @@ export function AccessApiKeysGrid({
cell: ({ row }) => {
const k = row.original
return (
<div className="flex gap-1">
<div className="flex shrink-0 gap-1">
<ConfirmDialog
trigger={
<Button
@@ -149,6 +149,7 @@ export function AccessApiKeysGrid({
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ключей"
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск API-ключей…"
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils'
@@ -43,6 +45,7 @@ export function ChartDonutMetric({
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
<PieChart>
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
<Pie
data={data}
dataKey="count"
@@ -51,7 +51,7 @@ export function NetworkOverviewAnalyticsCard({
label: 'Offline',
percent: 100 - speakersPct,
}}
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`}
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
/>
</div>
)
+6 -3
View File
@@ -41,14 +41,17 @@ export function BadgeTabs({
defaultValue={defaultValue}
onValueChange={onValueChange}
orientation="horizontal"
className={cn('w-full', className)}
className={cn('w-full min-w-0', className)}
>
<TabsList
variant="line"
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
className={cn(
'mb-3.5 w-full min-w-0 justify-start gap-4',
listClassName,
)}
>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2">
<TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
{item.icon}
{item.label}
{item.count !== undefined ? (
@@ -50,7 +50,7 @@ export function DashboardActivityTimeline({
<DashboardFramePanel
title="Недавняя активность"
description="Задачи, ревизии и сетевые события"
className="h-full"
className="h-full min-w-0"
>
{loading ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка</p>
@@ -82,7 +82,7 @@ export function DashboardActivityTimeline({
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="min-w-0 pb-1 text-foreground">
<TimelineTitle className="text-sm leading-snug font-normal">
<TimelineTitle className="text-sm leading-snug font-normal break-words">
{item.message}
</TimelineTitle>
<div className="mt-2">
@@ -82,7 +82,7 @@ function buildKpis({
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
label: 'Пиры Established',
label: 'Пиры установлены',
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${network.peersTotal} в каталоге`}
@@ -94,13 +94,13 @@ function buildKpis({
icon: <ServerCog aria-hidden />,
iconClassName: 'text-warning',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
label: 'Спикеры online',
label: 'Спикеры в сети',
footer: (
<Badge
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
size="sm"
>
{loading ? '…' : 'live-снимок'}
{loading ? '…' : 'онлайн'}
</Badge>
),
},
@@ -11,8 +11,8 @@ import {
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
@@ -40,6 +40,7 @@ import {
InputGroupButton,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api'
import {
@@ -51,20 +52,19 @@ import {
type SortingState,
} from '@tanstack/react-table'
type ModuleSort = 'name' | 'type' | 'priority' | 'last_refreshed_at'
type ModuleSort = 'name' | 'type' | 'priority'
type EnabledFilter = 'all' | 'enabled' | 'disabled'
const sortLabels: Record<ModuleSort, string> = {
name: 'Название',
type: 'Тип',
priority: 'Приоритет',
last_refreshed_at: 'Обновлено',
}
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
function buildSorting(sortBy: ModuleSort): SortingState {
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }]
return [{ id: sortBy, desc: false }]
}
export function DashboardModulesGrid({
@@ -119,9 +119,10 @@ export function DashboardModulesGrid({
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2">
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" />
<DataGridPrimaryCell title={row.original.name} accent="primary" />
</div>
),
minSize: 180,
meta: { headerTitle: 'Модуль' },
},
{
@@ -138,30 +139,9 @@ export function DashboardModulesGrid({
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
),
size: 88,
meta: { headerTitle: 'Приоритет' },
},
{
id: 'enabled',
accessorFn: (row) => (row.enabled !== false ? 'enabled' : 'disabled'),
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => <ModeBadge enabled={row.original.enabled !== false} />,
meta: { headerTitle: 'Состояние' },
},
{
id: 'last_refreshed_at',
accessorFn: (row) => row.last_refreshed_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.last_refreshed_at
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
sortingFn: (a, b) =>
(a.original.last_refreshed_at ?? '').localeCompare(b.original.last_refreshed_at ?? ''),
meta: { headerTitle: 'Обновлено' },
},
],
[],
)
@@ -185,6 +165,7 @@ export function DashboardModulesGrid({
<DashboardFramePanel
title="Модули"
description="Поиск, сортировка и быстрый переход к настройке"
className="min-w-0"
actions={
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
<PlusIcon />
@@ -204,14 +185,14 @@ export function DashboardModulesGrid({
columnsVisibility: false,
columnsResizable: false,
columnsMovable: false,
width: 'fixed',
width: 'auto',
}}
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
>
<div className="flex flex-col">
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<InputGroup className="w-full min-w-40 sm:max-w-xs">
<div className="flex flex-col gap-3 border-b px-4 py-3 @3xl:flex-row @3xl:items-center @3xl:justify-between">
<InputGroup className="w-full min-w-0 @3xl:max-w-xs">
<InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
</InputGroupAddon>
@@ -337,8 +318,8 @@ export function DashboardModulesGrid({
<div className="border-t px-4 py-3">
{filteredModules.length > 0 ? (
<DataGridPagination
{...DATA_GRID_PAGINATION_RU}
sizes={[10, 15, 20]}
info="{from}{to} из {count}"
className="py-0"
/>
) : (
@@ -57,7 +57,7 @@ export function DashboardNetworkHealth({
}
primary={{
value: loading ? '—' : `${utilization}%`,
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры online',
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
percent: loading ? 0 : utilization,
badge: (
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
@@ -67,7 +67,7 @@ export function DashboardNetworkHealth({
}}
secondary={{
value: loading ? '—' : offline,
label: mode === 'peers' ? 'Не Established' : 'Offline',
label: mode === 'peers' ? 'Не установлены' : 'Не в сети',
percent: total > 0 ? Math.round((offline / total) * 100) : 0,
}}
footer={
@@ -3,13 +3,16 @@ import { useMemo, useState } from 'react'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { PanelCard } from '@/components/panel-card'
import { Separator } from '@evobgp/ui/components/separator'
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
import type { JobRow, ModuleRow } from '@/types/api'
type FlowMode = 'jobs' | 'modules'
/** chart-13 inspired donut breakdown (Card surface). */
/**
* Donut distribution — chart-27 DNA: Frame + one legend (inside ChartDonutMetric).
* @see https://reui.io/preview/base/chart-27
* @see https://reui.io/docs/components/base/frame
*/
export function DashboardOperationsBreakdown({
jobs,
modules,
@@ -32,7 +35,7 @@ export function DashboardOperationsBreakdown({
return (
<PanelCard
title="Поток операций"
description="Распределение задач и типов модулей"
description="Доли статусов задач или типов модулей"
actions={
<AnalyticsSegmentControl
value={mode}
@@ -43,45 +46,16 @@ export function DashboardOperationsBreakdown({
]}
/>
}
className="h-full"
className="min-w-0"
contentClassName="px-5 py-4"
>
<div className="p-4">
{loading ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
Загрузка
</div>
) : (
<>
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
{slices.length > 0 ? (
<ul className="mt-4 flex min-w-0 flex-col">
{slices.map((slice, index) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
<li key={slice.key}>
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2">
<div className="flex min-w-0 items-center gap-2.5">
<span
aria-hidden
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
style={{ backgroundColor: slice.color }}
/>
<span className="truncate text-sm font-medium">{slice.label}</span>
</div>
<span className="text-sm font-medium tabular-nums">{slice.count}</span>
<span className="text-muted-foreground/70 w-10 text-right text-xs tabular-nums">
{pct}%
</span>
</div>
{index < slices.length - 1 ? <Separator className="w-auto" /> : null}
</li>
)
})}
</ul>
) : null}
</>
)}
</div>
{loading ? (
<div className="text-muted-foreground flex h-44 items-center justify-center text-sm">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
)}
</PanelCard>
)
}
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
{
id: 'lookup',
title: 'Проверка IP/домена',
description: 'Membership в списках и community (entry + snapshot).',
description: 'Проверка IP в списках и BGP-сообществах.',
to: '/lookup',
icon: <Search aria-hidden />,
iconClassName: 'text-primary',
@@ -23,7 +23,7 @@ const ACTIONS: QuickActionItem[] = [
{
id: 'communities',
title: 'BGP-сообщества',
description: 'Справочник communities для политик экспорта.',
description: 'Справочник BGP-сообществ для политик экспорта.',
to: '/directories',
icon: <Tags aria-hidden />,
iconClassName: 'text-info',
@@ -31,7 +31,7 @@ const ACTIONS: QuickActionItem[] = [
{
id: 'network',
title: 'Сеть',
description: 'Обзор пиров, спикеров и live-сессий BGP.',
description: 'Обзор пиров, спикеров и живых BGP-сессий.',
to: '/network',
search: { tab: 'overview' },
icon: <Network aria-hidden />,
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridShell } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
@@ -49,7 +49,7 @@ export function DashboardRecentJobsGrid({
[nameById],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
const { table, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => {
@@ -63,16 +63,13 @@ export function DashboardRecentJobsGrid({
})
return (
<DataGridSection
<DataGridShell
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет задач"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/>
)
}
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
@@ -42,7 +42,7 @@ export function DashboardRecentRevisionsGrid({
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
const { table, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => row.id,
@@ -51,16 +51,13 @@ export function DashboardRecentRevisionsGrid({
})
return (
<DataGridSection
<DataGridShell
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ревизий"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
/>
)
}
+1 -1
View File
@@ -50,7 +50,7 @@ export function DataGridShell<TData extends object>({
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
tableLayout={tableLayout}
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
className={className}
className={cn('min-w-0', className)}
onRowClick={onRowClick}
>
<DataGridContainer border={false}>
+1 -3
View File
@@ -8,7 +8,6 @@ import {
Settings,
BookText,
KeyRound,
ServerCog,
Search,
} from 'lucide-react'
@@ -97,8 +96,7 @@ const NAV_GROUPS: NavGroup[] = [
label: 'Система',
items: [
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
],
},
]
+1 -1
View File
@@ -91,7 +91,7 @@ export function AppsMenu() {
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
render={<Link to="/settings" search={{ tab: 'ui' }} />}
className="justify-center text-sm font-medium"
>
Настройки
+3 -3
View File
@@ -136,7 +136,7 @@ export function NavUser() {
setApiToken(null)
if (typeof window !== 'undefined') {
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
window.location.assign('/settings?tab=connection&reason=token-required')
window.location.assign('/settings?tab=ui&reason=token-required')
}
}
@@ -190,10 +190,10 @@ export function NavUser() {
<DropdownMenuGroup>
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
render={<Link to="/settings" search={{ tab: 'ui' }} />}
>
<SettingsIcon aria-hidden />
Настройки UI
Настройки
</DropdownMenuItem>
{authOn ? (
<DropdownMenuItem
@@ -30,7 +30,7 @@ export function NetworkKpi({
id: 'peers-established',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
label: 'Пиры Established',
label: 'Пиры установлены',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
footer: (
<Badge variant="success-light" size="sm">
@@ -44,7 +44,7 @@ export function NetworkKpi({
id: 'speakers-online',
icon: <ServerCog aria-hidden />,
iconClassName: 'text-info',
label: 'Спикеры online',
label: 'Спикеры в сети',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
footer: (
<Badge
@@ -55,7 +55,7 @@ export function NetworkKpi({
}
size="sm"
>
{loading ? '…' : 'live'}
{loading ? '…' : 'онлайн'}
</Badge>
),
to: '/network',
@@ -32,13 +32,13 @@ function peerDeleteLabel(p: PeerRow): string {
const PEER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'established', label: 'Established' },
{ id: 'established', label: 'Установлены' },
{ id: 'pending', label: 'Ожидание' },
{ id: 'disabled', label: 'Выключены' },
]
const SESSION_STATE_OPTIONS = [
{ value: 'Established', label: 'Established' },
{ value: 'Established', label: 'Установлена' },
{ value: 'Idle', label: 'Idle' },
{ value: 'Active', label: 'Active' },
{ value: 'Connect', label: 'Connect' },
@@ -72,7 +72,7 @@ export function PeerFormDialog({
}
const asn = Number(remoteAsn)
if (!asn || asn <= 0) {
toast.error('Remote ASN должен быть больше 0')
toast.error('ASN соседа должен быть больше 0')
return
}
const body: BgpPeerCreate = {
@@ -132,7 +132,7 @@ export function PeerFormDialog({
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-asn">Remote ASN</Label>
<Label htmlFor="peer-asn">ASN соседа</Label>
<Input
id="peer-asn"
type="number"
+8 -4
View File
@@ -8,12 +8,16 @@ interface PageHeaderProps {
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-1">
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
{description ? (
<p className="text-muted-foreground max-w-prose text-sm break-words">{description}</p>
) : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null}
</div>
)
}
+1 -1
View File
@@ -54,7 +54,7 @@ export function PanelCard({
<Frame
dense
spacing={spacing}
className={cn(panelCardClassName, className)}
className={cn(panelCardClassName, 'min-w-0', className)}
>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
@@ -29,10 +29,10 @@ export function FrameDataGrid({
}) {
const hasHeader = Boolean(title || description || actions)
return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3 border-b">
<FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
+1 -1
View File
@@ -24,4 +24,4 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { FrameDataGrid } from './frame-data-grid'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export { SettingsShell } from './settings-shell'
+7 -4
View File
@@ -1,10 +1,13 @@
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
/**
* Shared grid column classes for hybrid KPI / Quick Actions tiles.
* Container queries only — never 56 columns on laptop width (tiles overlap).
* @see https://reui.io/preview/base/stats-12
*/
export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
if (count <= 6) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4'
}
@@ -305,7 +305,7 @@ export function KpiStatGrid({
}
return (
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
<Frame className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
@@ -1,11 +1,4 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { PageHeader } from '@/components/page-header'
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
@@ -27,7 +20,7 @@ interface OpsDashboardProps {
}
const rootClassName =
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
'text-foreground @container flex w-full min-w-0 flex-col gap-4 md:gap-6'
function OpsDashboardSkeleton() {
return (
@@ -37,7 +30,7 @@ function OpsDashboardSkeleton() {
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
</header>
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<div className="grid gap-2 @3xl:grid-cols-2">
<div className="flex min-w-0 flex-col gap-4">
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</div>
@@ -47,9 +40,11 @@ function OpsDashboardSkeleton() {
}
/**
* Ops dashboard: KPI → optional Quick Actions → charts → attention queue.
* Ops dashboard: KPI → optional Quick Actions → charts → queue.
* Queue is a sibling Frame grid — never wrap FrameDataGrid inside another Frame.
* @see https://reui.io/preview/base/dashboard-1
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/docs/components/base/frame
*/
export function OpsDashboard({
title = 'Панель управления',
@@ -77,21 +72,18 @@ export function OpsDashboard({
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
<section
aria-label="Аналитика"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
{charts}
</section>
<section aria-label={queueTitle}>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>{queueTitle}</FrameTitle>
<FrameDescription>{queueDescription}</FrameDescription>
</FrameHeader>
<FramePanel>{queue}</FramePanel>
</Frame>
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
{queueDescription ? (
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
) : null}
</div>
{queue}
</section>
</div>
)
@@ -39,6 +39,8 @@ import {
AlertTitle,
} from '@/components/reui/alert'
import { EmptyState } from '@/components/empty-state'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { FILTERS_I18N_RU } from '@/lib/filters-i18n'
import { applyFiltersToData } from './filter-utils'
export interface ResourcePageTab {
@@ -322,6 +324,7 @@ export function ResourcePage<T extends object>({
fields={filterFields}
onChange={handleFiltersChange}
size="default"
i18n={FILTERS_I18N_RU}
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
@@ -355,11 +358,8 @@ export function ResourcePage<T extends object>({
<FrameFooter>
<DataGridPagination
{...DATA_GRID_PAGINATION_RU}
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</FramePanel>
@@ -1,120 +1,17 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
import { Outlet } from '@tanstack/react-router'
import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
import { cn } from '@evobgp/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
export interface SettingsTabConfig {
id: string
to: '/settings' | '/tenant-settings'
label: string
icon?: ReactNode
}
/** Default nav for EvoBGP settings routes (`/settings`, `/tenant-settings`). */
const DEFAULT_TABS: SettingsTabConfig[] = [
{
id: 'ui',
to: '/settings',
label: 'Настройки UI',
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
},
{
id: 'tenant',
to: '/tenant-settings',
label: 'Настройки BIRD',
icon: <ServerCogIcon className="size-4" aria-hidden="true" />,
},
]
interface SettingsShellProps {
title?: string
description?: string
tabs?: SettingsTabConfig[]
}
/**
* Settings shell — Frame surface, no hairline Separator under header.
* @see https://reui.io/preview/base/settings-16
* @see https://reui.io/preview/base/settings-3
* Settings layout — page chrome only.
* Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings).
* @see https://reui.io/preview/base/settings-7
* @see https://reui.io/blocks
*/
export function SettingsShell({
title = 'Настройки',
description = 'Подключение UI и параметры плоскости управления',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
export function SettingsShell() {
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<PageHeader title={title} description={description} />
<div
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? (
<nav
aria-label="Разделы настроек"
className={cn(
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
: 'w-44 shrink-0 flex-col',
)}
>
{tabs.map((tab) => {
const isActive = pathname.startsWith(tab.to)
const className = cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)
if (tab.to === '/tenant-settings') {
return (
<Link
key={tab.id}
to="/tenant-settings"
search={{ tab: 'bird' }}
aria-current={isActive ? 'page' : undefined}
className={className}
>
{tab.icon}
{tab.label}
</Link>
)
}
return (
<Link
key={tab.id}
to="/settings"
search={{ tab: 'connection' }}
aria-current={isActive ? 'page' : undefined}
className={className}
>
{tab.icon}
{tab.label}
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
<Outlet />
</div>
</div>
</div>
<Outlet />
</PageShell>
)
}
@@ -133,7 +133,9 @@ export function ScheduleAgendaPanel({
</div>
<Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}>
<SelectTrigger size="sm" className="w-full sm:w-44">
<SelectValue />
<SelectValue>
{FILTER_ITEMS.find((item) => item.value === filter)?.label}
</SelectValue>
</SelectTrigger>
<SelectContent>
{FILTER_ITEMS.map((item) => (
@@ -44,7 +44,7 @@ export function AppearanceSettingsTab() {
})
return (
<div className="space-y-6">
<div className="flex flex-col gap-6">
<SettingsCard
title="Тема интерфейса"
description="Быстрый переключатель также доступен в боковой панели"
@@ -61,6 +61,7 @@ export function AppearanceSettingsTab() {
мгновенно
</Badge>
}
stacked
last
>
<ToggleGroup
@@ -72,7 +73,7 @@ export function AppearanceSettingsTab() {
variant="outline"
size="sm"
aria-label="Тема интерфейса"
className="w-full sm:w-auto"
className="w-full min-w-0"
>
{THEME_OPTIONS.map((option) => {
const Icon = option.icon
@@ -0,0 +1,341 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Input } from '@evobgp/ui/components/input'
import { FrameDataGrid } from '@/components/reui-kit'
import { SelectMenu } from '@/components/select-field'
import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
import { QueryState } from '@/components/query-state'
import { LoadingButton } from '@/components/loading-button'
import {
BIRD_SETTING_KEYS,
REVISION_SETTING_KEYS,
RUNTIME_LOGS_SETTING_KEYS,
buildPayload,
partitionSettings,
settingsKeys,
settingsQueryOptions,
type BirdSettingKey,
} from '@/queries/settings'
import { apiMutate } from '@/lib/api-client'
const RUNTIME_LOGS_ENABLED_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const RUNTIME_LOGS_MODE_ITEMS = [
{ value: 'truncate', label: 'обнулить (truncate)' },
{ value: 'delete', label: 'удалить файл (delete)' },
] as const
const BIRD_LABELS: Record<BirdSettingKey, string> = {
bird_router_id: 'Router ID',
bird_local_ipv4: 'Локальный IPv4',
bird_local_ipv6: 'Локальный IPv6',
bird_local_asn: 'Локальный ASN',
bird_bgp_source_ipv4: 'BGP source IPv4',
bird_bgp_source_ipv6: 'BGP source IPv6',
peer_discovery_enabled: 'Автообнаружение пиров',
peer_discovery_ranges_v4: 'Discovery CIDR IPv4',
peer_discovery_ranges_v6: 'Discovery CIDR IPv6',
peer_discovery_require_external: 'Только external ASN',
}
const BIRD_BOOL_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
peer_discovery_enabled:
'Dynamic neighbor range в BIRD (карантин import/export none). Требует CIDR.',
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
peer_discovery_require_external: 'neighbor range … external (любой чужой ASN)',
}
export function BirdSettingsTab() {
const settingsQ = useQuery(settingsQueryOptions())
const qc = useQueryClient()
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
useEffect(() => {
if (partitioned) {
setBirdForm({ ...partitioned.bird })
setRevisionForm({ ...partitioned.revision })
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsQ.data])
const patchMutation = useMutation({
mutationFn: (payload: Record<string, string | number | boolean>) =>
apiMutate('/v1/settings', 'PATCH', payload),
onSuccess: () => {
toast.success('Параметры сохранены')
void qc.invalidateQueries({ queryKey: settingsKeys.all })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
function saveBird() {
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
}
function saveRevision() {
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
}
function saveRuntimeLogs() {
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
}
return (
<div className="flex flex-col gap-6">
<SettingsCard
title="BIRD control plane"
description="Глобальные параметры BIRD для pipeline refresh/apply. Сохранение — роль operator."
footer={
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
}
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-64" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<SettingsFieldGroup
legend="BIRD"
description="Параметры демона и автообнаружения пиров."
>
{BIRD_SETTING_KEYS.map((key, index) => (
<SettingsSettingField
key={key}
title={BIRD_LABELS[key]}
description={BIRD_HINTS[key]}
labelFor={key}
badge={{ label: 'BIRD', variant: 'info-light' }}
stacked
last={index === BIRD_SETTING_KEYS.length - 1}
>
{key === 'peer_discovery_enabled' ||
key === 'peer_discovery_require_external' ? (
<SelectMenu
id={key}
items={[...BIRD_BOOL_ITEMS]}
value={birdForm[key] || 'false'}
onValueChange={(v) =>
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
}
placeholder="Выкл"
/>
) : (
<Input
id={key}
className="w-full min-w-0"
value={birdForm[key] ?? ''}
onChange={(e) =>
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
}
placeholder={BIRD_LABELS[key]}
/>
)}
</SettingsSettingField>
))}
</SettingsFieldGroup>
)}
</QueryState>
</SettingsCard>
<SettingsCard
title="Ревизии"
description="Время хранения ревизий в БД"
footer={
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
}
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<SettingsFieldGroup
legend="Retention"
description="Срок хранения ревизий в минутах."
>
<SettingsSettingField
title="Retention"
description="Сколько минут хранить ревизии конфигурации."
labelFor="revision_retention_minutes"
stacked
last
>
<Input
id="revision_retention_minutes"
type="number"
className="w-full min-w-0"
value={revisionForm.revision_retention_minutes ?? ''}
onChange={(e) =>
setRevisionForm((s) => ({
...s,
revision_retention_minutes: e.target.value,
}))
}
/>
</SettingsSettingField>
</SettingsFieldGroup>
)}
</QueryState>
</SettingsCard>
<SettingsCard
title="Файловые логи"
description="Автоматическая очистка логов"
footer={
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
}
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-48" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<SettingsFieldGroup
legend="Авто-очистка"
description="Расписание и лимиты файловых логов runtime."
>
<SettingsSettingField
title="Авто-очистка"
description="Включить периодическую очистку файловых логов."
stacked
>
<SelectMenu
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
placeholder="Выберите"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_enabled: v,
}))
}
/>
</SettingsSettingField>
<SettingsSettingField
title="Макс. размер файла"
description="Порог в мегабайтах, после которого срабатывает очистка."
labelFor="runtime_logs_max_file_mb"
stacked
>
<Input
id="runtime_logs_max_file_mb"
type="number"
className="w-full min-w-0"
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_max_file_mb: e.target.value,
}))
}
/>
</SettingsSettingField>
<SettingsSettingField
title="Расписание"
description="Cron-выражение для авто-очистки."
labelFor="runtime_logs_auto_schedule"
stacked
>
<Input
id="runtime_logs_auto_schedule"
className="w-full min-w-0"
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_schedule: e.target.value,
}))
}
/>
</SettingsSettingField>
<SettingsSettingField
title="Режим очистки"
description="Обнулить файл или удалить его."
stacked
last
>
<SelectMenu
items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
placeholder="Выберите"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_mode: v,
}))
}
/>
</SettingsSettingField>
</SettingsFieldGroup>
)}
</QueryState>
</SettingsCard>
<FrameDataGrid
title="Дополнительные параметры"
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
>
<QueryState
data={partitioned?.additional ?? []}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
empty={(partitioned?.additional ?? []).length === 0}
emptyTitle="Нет дополнительных параметров"
skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()}
>
{(items) => (
<SettingsKvGrid
items={items}
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
/>
)}
</QueryState>
</FrameDataGrid>
</div>
)
}
@@ -1,6 +1,6 @@
import { Link, useNavigate } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { Save } from 'lucide-react'
import { KeyRound, Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -8,6 +8,7 @@ import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { Badge } from '@/components/reui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { LoadingButton } from '@/components/loading-button'
import {
DEV_API_TOKEN,
@@ -16,7 +17,6 @@ import {
TOKEN_STORAGE_KEY,
} from '@/lib/api-client'
import { authKeys } from '@/queries/auth'
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
@@ -50,12 +50,13 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-6">
{tokenRequired ? (
<Alert>
<Alert variant="warning">
<KeyRound aria-hidden />
<AlertTitle>Нужен API-токен</AlertTitle>
<AlertDescription>
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
dev».
Сохраните токен ниже или нажмите «Использовать dev».
</AlertDescription>
</Alert>
) : null}
@@ -64,7 +65,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
title="Подключение к API"
description="Токен хранится только в этом браузере (localStorage)"
footer={
<div className="flex w-full flex-wrap justify-end gap-2">
<div className="flex w-full min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="outline" onClick={useDevToken}>
Использовать dev
</Button>
@@ -83,8 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
title="Токен для запросов"
description={
<>
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
ключами tenant в разделе{' '}
Ключ для заголовка Authorization. Управление ключами tenant в разделе{' '}
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
Права доступа
</Link>
@@ -97,12 +97,14 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
</Badge>
}
labelFor="settings-token"
stacked
last
>
<Input
id="settings-token"
type="password"
autoComplete="off"
className="w-full min-w-0"
value={token}
onChange={(e) => setTokenValue(e.target.value)}
placeholder="dev или API-ключ"
@@ -23,7 +23,8 @@ import {
const ADMIN_SECTIONS = [
{
id: 'tenant-settings',
to: '/tenant-settings' as const,
to: '/settings' as const,
search: { tab: 'bird' as const },
title: 'Параметры арендатора',
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
icon: <SlidersHorizontalIcon aria-hidden="true" />,
@@ -32,6 +33,7 @@ const ADMIN_SECTIONS = [
{
id: 'access',
to: '/access' as const,
search: undefined,
title: 'Права доступа',
description: 'API-ключи tenant, роли и управление доступом.',
icon: <KeyRoundIcon aria-hidden="true" />,
@@ -40,6 +42,7 @@ const ADMIN_SECTIONS = [
{
id: 'monitoring',
to: '/monitoring' as const,
search: undefined,
title: 'Мониторинг',
description: 'Метрики, состояние jobs и observability control plane.',
icon: <ActivityIcon aria-hidden="true" />,
@@ -49,7 +52,7 @@ const ADMIN_SECTIONS = [
export function SectionsSettingsTab() {
return (
<div className="space-y-6">
<div className="flex flex-col gap-6">
<SettingsCard
title="Разделы control plane"
description="Параметры tenant и операции — отдельно от настроек браузера"
@@ -58,7 +61,7 @@ export function SectionsSettingsTab() {
{ADMIN_SECTIONS.map((section, index) => (
<Fragment key={section.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null}
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
<Item className="min-h-0 min-w-0 flex-wrap items-center gap-4 px-5 py-3.5">
<ItemMedia className="self-center!">
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
{section.icon}
@@ -66,7 +69,7 @@ export function SectionsSettingsTab() {
</ItemMedia>
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
<ItemTitle className="gap-2 leading-5">
<ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
{section.title}
<Badge variant={section.badge.variant} size="xs">
{section.badge.label}
@@ -76,7 +79,17 @@ export function SectionsSettingsTab() {
</ItemContent>
<ItemActions className="shrink-0 justify-end self-center">
<Button variant="outline" size="sm" render={<Link to={section.to} />}>
<Button
variant="outline"
size="sm"
render={
section.to === '/settings' ? (
<Link to="/settings" search={{ tab: 'bird' }} />
) : (
<Link to={section.to} />
)
}
>
Открыть
</Button>
</ItemActions>
@@ -35,7 +35,7 @@ export function SessionSettingsTab() {
})
return (
<div className="space-y-6">
<div className="flex flex-col gap-6">
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
<QueryState
data={sessionQ.data}
@@ -58,7 +58,7 @@ export function SessionSettingsTab() {
</ItemMedia>
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
<ItemTitle className="gap-2 leading-5">
<ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
Этот браузер
<Badge variant="success-light" size="xs">
Активна
@@ -66,7 +66,9 @@ export function SessionSettingsTab() {
</ItemTitle>
<ItemDescription className="leading-5">
Tenant:{' '}
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
<code className="text-foreground font-mono text-xs break-all">
{session.tenant_id}
</code>
</ItemDescription>
</ItemContent>
</Item>
@@ -94,12 +96,13 @@ export function SessionSettingsTab() {
</Badge>
)
}
stacked
last
>
{sessionQ.data ? (
<p className="text-muted-foreground text-sm">
<p className="text-muted-foreground text-sm break-words">
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '}
<code className="font-mono text-xs">{sessionQ.data.tenant_id}</code>.
<code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>.
</p>
) : (
<p className="text-muted-foreground text-sm">
@@ -10,31 +10,59 @@ import {
FieldTitle,
} from '@evobgp/ui/components/field'
interface SettingRowProps {
export interface SettingRowProps {
title: string
description?: ReactNode
children: ReactNode
last?: boolean
/** Opt-in FieldSeparator after the row (default off — ReUI settings use Frame+gap). */
separated?: boolean
compact?: boolean
stacked?: boolean
labelFor?: string
contentClassName?: string
className?: string
titleAddon?: ReactNode
}
/** Settings row — settings-3 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({
title,
description,
children,
last,
separated = false,
compact,
stacked,
labelFor,
contentClassName,
className,
titleAddon,
}: SettingRowProps) {
return (
<>
<Field orientation="responsive" className="gap-4 px-5 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
<Field
orientation={stacked ? 'vertical' : 'responsive'}
className={cn(
'min-w-0 gap-4 px-5 py-3.5',
!stacked &&
'@md/field-group:items-start @md/field-group:*:min-w-0 @md/field-group:*:max-w-full',
!last && !separated && 'border-border/40 border-b',
className,
)}
>
<div
className={cn(
'flex min-w-0 flex-1 flex-col gap-0.5',
!stacked && '@md/field-group:max-w-sm',
)}
>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
@@ -44,20 +72,33 @@ export function SettingRow({
</div>
{description ? (
<FieldDescription>{description}</FieldDescription>
<FieldDescription className="text-sm break-words">{description}</FieldDescription>
) : null}
</div>
<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}
</div>
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
{separated && !last ? <FieldSeparator /> : null}
</>
)
}
@@ -31,7 +31,7 @@ export function SettingsCard({
footerClassName,
}: SettingsCardProps) {
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">
<FrameHeader className="gap-0 border-b px-5 py-3">
<FrameTitle>{title}</FrameTitle>
@@ -1,45 +1,138 @@
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@evobgp/ui/lib/utils'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@evobgp/ui/components/tabs'
import { AppearanceSettingsTab } from './appearance-settings-tab'
import { BirdSettingsTab } from './bird-settings-tab'
import { ConnectionSettingsTab } from './connection-settings-tab'
import { SectionsSettingsTab } from './sections-settings-tab'
import { SessionSettingsTab } from './session-settings-tab'
import {
SETTINGS_TAB_ITEMS,
type SettingsTab,
type SettingsSection,
type SettingsTabItem,
} from './settings-tabs-data'
function UiSettingsPanel({ tokenRequired }: { tokenRequired: boolean }) {
return (
<div className="flex flex-col gap-6">
<ConnectionSettingsTab tokenRequired={tokenRequired} />
<SessionSettingsTab />
<AppearanceSettingsTab />
</div>
)
}
function SettingsNavigation({
isMobile,
activeValue,
items,
}: {
isMobile: boolean
activeValue: string
items: SettingsTabItem[]
}) {
return (
<div className={cn('min-w-0', isMobile ? 'w-full' : 'w-40 shrink-0')}>
{isMobile ? (
<div className="-mx-1 overflow-x-auto px-1 pb-1">
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
{items.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
)}
>
{tab.icon}
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
</div>
) : (
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
{items.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
)}
>
{tab.icon}
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
)}
</div>
)
}
/**
* Unified settings — settings-7 AccountSettings 1:1 (header + vertical Tabs).
* Surface of cards stays Frame (`SettingsCard`), not shadcn Card.
* @see https://reui.io/preview/base/settings-7
* @see https://reui.io/blocks
* @see https://reui.io/docs/components/base/frame
*/
export function SettingsPageShell({
activeTab,
onTabChange,
tokenRequired,
showBird,
}: {
activeTab: SettingsTab
onTabChange: (tab: SettingsTab) => void
activeTab: SettingsSection
onTabChange: (tab: SettingsSection) => void
tokenRequired: boolean
showBird: boolean
}) {
const isMobile = useIsMobile()
const items = showBird
? SETTINGS_TAB_ITEMS
: SETTINGS_TAB_ITEMS.filter((tab) => tab.value === 'ui')
const resolvedTab = activeTab === 'bird' && showBird ? 'bird' : 'ui'
return (
<BadgeTabs
value={activeTab}
onValueChange={(value) => onTabChange(value as SettingsTab)}
items={SETTINGS_TAB_ITEMS.map((tab) => ({
value: tab.value,
label: tab.label,
icon: tab.icon,
}))}
>
<TabsContent value="connection" className="mt-0">
<ConnectionSettingsTab tokenRequired={tokenRequired} />
</TabsContent>
<TabsContent value="session" className="mt-0">
<SessionSettingsTab />
</TabsContent>
<TabsContent value="appearance" className="mt-0">
<AppearanceSettingsTab />
</TabsContent>
<TabsContent value="sections" className="mt-0">
<SectionsSettingsTab />
</TabsContent>
</BadgeTabs>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
<header className="px-1">
<h1 className="text-xl font-semibold tracking-tight">Настройки</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
Интерфейс UI и глобальные параметры BIRD в одном разделе.
</p>
</header>
<Tabs
value={resolvedTab}
onValueChange={(value) => onTabChange(value as SettingsSection)}
orientation={isMobile ? 'horizontal' : 'vertical'}
className="w-full gap-4 lg:gap-8"
>
<SettingsNavigation
isMobile={isMobile}
activeValue={resolvedTab}
items={items}
/>
<div className="min-w-0 flex-1">
<TabsContent value="ui" className="mt-0">
<UiSettingsPanel tokenRequired={tokenRequired} />
</TabsContent>
{showBird ? (
<TabsContent value="bird" className="mt-0">
<BirdSettingsTab />
</TabsContent>
) : null}
</div>
</Tabs>
</div>
)
}
@@ -1,17 +1,9 @@
import { type ComponentProps, type ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from '@evobgp/ui/components/field'
import { SettingRow } from '@/components/settings/setting-row'
/** settings-3 row pattern for tenant/UI settings. */
/** Tenant settings row — same SettingRow DNA as UI settings. */
export function SettingsSettingField({
title,
description,
@@ -20,38 +12,34 @@ export function SettingsSettingField({
last,
labelFor,
contentClassName,
stacked,
}: {
title: string
description: ReactNode
description?: ReactNode
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
children: ReactNode
last?: boolean
labelFor?: string
contentClassName?: string
stacked?: boolean
}) {
return (
<>
<Field orientation="responsive" className="gap-4 px-4 py-4">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{badge ? (
<Badge variant={badge.variant} size="sm">
{badge.label}
</Badge>
) : null}
</div>
<FieldDescription className="text-xs">{description}</FieldDescription>
</div>
<FieldContent className={cn('min-w-0 @md/field-group:w-78', contentClassName)}>
{children}
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
<SettingRow
title={title}
description={description}
last={last}
labelFor={labelFor}
contentClassName={contentClassName}
stacked={stacked}
titleAddon={
badge ? (
<Badge variant={badge.variant} size="sm">
{badge.label}
</Badge>
) : null
}
>
{children}
</SettingRow>
)
}
@@ -1,49 +1,32 @@
import {
KeyRoundIcon,
MonitorSmartphoneIcon,
PaletteIcon,
SlidersHorizontalIcon,
} from 'lucide-react'
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
import { type ReactNode } from 'react'
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections'
export type SettingsSection = 'ui' | 'bird'
export type SettingsTabItem = {
value: SettingsTab
value: SettingsSection
label: string
icon: ReactNode
}
/** Side rail items — settings-7 AccountSettings DNA. */
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
{
value: 'connection',
label: 'Подключение',
icon: <KeyRoundIcon aria-hidden="true" />,
value: 'ui',
label: 'Настройки UI',
icon: <SettingsIcon aria-hidden="true" />,
},
{
value: 'session',
label: 'Сессия',
icon: <MonitorSmartphoneIcon aria-hidden="true" />,
},
{
value: 'appearance',
label: 'Оформление',
icon: <PaletteIcon aria-hidden="true" />,
},
{
value: 'sections',
label: 'Разделы',
icon: <SlidersHorizontalIcon aria-hidden="true" />,
value: 'bird',
label: 'Настройки BIRD',
icon: <ServerCogIcon aria-hidden="true" />,
},
]
export function parseSettingsTab(value: unknown): SettingsTab {
if (
value === 'session' ||
value === 'appearance' ||
value === 'sections'
) {
return value
}
return 'connection'
const LEGACY_BIRD = new Set(['bird', 'revision', 'runtime-logs', 'additional'])
/** Maps current and legacy `?tab=` values onto the unified settings rail. */
export function parseSettingsSection(value: unknown): SettingsSection {
if (typeof value === 'string' && LEGACY_BIRD.has(value)) return 'bird'
return 'ui'
}
+3 -3
View File
@@ -39,11 +39,11 @@ export function AnalyticsDashboardSkeleton() {
</div>
<div className={dashboardMainSidebarClassName}>
<Skeleton className="h-96 w-full rounded-xl xl:col-span-8" />
<Skeleton className="h-96 w-full rounded-xl xl:col-span-4" />
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className={chartPanelGridClassName}>
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</div>
+8 -3
View File
@@ -346,11 +346,17 @@ export function permissionForPath(pathname: string): string | null {
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
if (pathname.startsWith('/access')) return 'bgp:access:admin'
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
if (pathname.startsWith('/tenant-settings') || pathname.startsWith('/settings')) {
return canOpenSettings() ? null : 'bgp:settings:read'
}
return null
}
/** Unified `/settings` — UI (`settings:read`) or BIRD (`tenant_settings:admin`). */
export function canOpenSettings(): boolean {
return can('bgp:settings:read') || can('bgp:tenant_settings:admin')
}
const FALLBACK_PATH = '/dashboard'
/** First path in the sidebar the current user may open. */
@@ -365,7 +371,6 @@ export function firstAllowedPath(): string {
'/schedule',
'/monitoring',
'/access',
'/tenant-settings',
'/settings',
]
for (const path of candidates) {
+1
View File
@@ -44,6 +44,7 @@ export const DATA_GRID_MESSAGES_RU = {
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
...DATA_GRID_TABLE_LAYOUT,
dense: true,
width: 'auto',
}
export function createTextGlobalFilter<TData extends object>(
+94
View File
@@ -0,0 +1,94 @@
import {
DEFAULT_I18N,
type FilterI18nConfig,
} from '@/components/reui/filters'
const OPERATOR_LABEL_RU: Record<string, string> = {
is: 'равно',
is_not: 'не равно',
is_any_of: 'любое из',
is_not_any_of: 'кроме',
includes_all: 'включает все',
excludes_all: 'исключает все',
before: 'до',
after: 'после',
between: 'между',
not_between: 'вне диапазона',
contains: 'содержит',
not_contains: 'не содержит',
starts_with: 'начинается с',
ends_with: 'заканчивается на',
equals: 'равно',
not_equals: 'не равно',
greater_than: 'больше',
less_than: 'меньше',
overlaps: 'пересекается',
includes: 'включает',
excludes: 'исключает',
empty: 'пусто',
not_empty: 'не пусто',
}
export const FILTERS_I18N_RU: FilterI18nConfig = {
...DEFAULT_I18N,
addFilter: 'Фильтр',
searchFields: 'Фильтр…',
noFieldsFound: 'Поля не найдены.',
noResultsFound: 'Ничего не найдено.',
select: 'Выбрать…',
true: 'Да',
false: 'Нет',
min: 'Мин.',
max: 'Макс.',
to: '—',
typeAndPressEnter: 'Введите и нажмите Enter',
selected: 'выбрано',
selectedCount: 'выбрано',
addFilterTitle: 'Добавить фильтр',
operators: {
...DEFAULT_I18N.operators,
is: 'равно',
isNot: 'не равно',
isAnyOf: 'любое из',
isNotAnyOf: 'кроме',
includesAll: 'включает все',
excludesAll: 'исключает все',
before: 'до',
after: 'после',
between: 'между',
notBetween: 'вне диапазона',
contains: 'содержит',
notContains: 'не содержит',
startsWith: 'начинается с',
endsWith: 'заканчивается на',
isExactly: 'точно',
equals: 'равно',
notEquals: 'не равно',
greaterThan: 'больше',
lessThan: 'меньше',
overlaps: 'пересекается',
includes: 'включает',
excludes: 'исключает',
includesAllOf: 'включает все',
includesAnyOf: 'включает любое',
empty: 'пусто',
notEmpty: 'не пусто',
},
placeholders: {
enterField: (fieldType: string) => `Введите ${fieldType}`,
selectField: 'Выбрать…',
searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}`,
enterKey: 'Введите ключ…',
enterValue: 'Введите значение…',
},
helpers: {
formatOperator: (operator: string) =>
OPERATOR_LABEL_RU[operator] ?? operator.replace(/_/g, ' '),
},
validation: {
invalidEmail: 'Некорректный email',
invalidUrl: 'Некорректный URL',
invalidTel: 'Некорректный телефон',
invalid: 'Некорректное значение',
},
}
+9 -3
View File
@@ -30,7 +30,13 @@ export const kpiGridClassName =
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
/** Two-column chart panel row (monitoring / network overview). */
export const chartPanelGridClassName = 'grid grid-cols-1 gap-4 @5xl:grid-cols-2'
export const chartPanelGridClassName =
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
/** Main + sidebar layout (8+4) used on dashboard. */
export const dashboardMainSidebarClassName = 'grid gap-4 xl:grid-cols-12 xl:items-start'
/**
* Main + sidebar: stack until the dashboard container is wide enough.
* Do not nest this inside another 2-col grid — that squeezes modules/timeline.
* @see https://reui.io/preview/base/dashboard-1
*/
export const dashboardMainSidebarClassName =
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
+1 -1
View File
@@ -62,7 +62,7 @@ export const Route = createFileRoute('/_auth')({
if (!raw || !normalizeApiToken(raw)) {
throw redirect({
to: '/settings',
search: { tab: 'connection', reason: 'token-required' },
search: { tab: 'ui', reason: 'token-required' },
})
}
},
@@ -1,31 +1,39 @@
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
import { type SettingsTab } from '@/components/settings/settings-tabs-data'
const settingsSearchSchema = z.object({
tab: z
.enum(['connection', 'session', 'appearance', 'sections'])
.catch('connection'),
reason: z.enum(['token-required']).optional(),
})
import {
parseSettingsSection,
type SettingsSection,
} from '@/components/settings/settings-tabs-data'
import { can } from '@/lib/auth'
export const Route = createFileRoute('/_auth/_settings/settings')({
component: SettingsComponent,
validateSearch: (search) => settingsSearchSchema.parse(search),
validateSearch: (search: Record<string, unknown>): {
tab: SettingsSection
reason?: 'token-required'
} => ({
tab: parseSettingsSection(search.tab),
...(search.reason === 'token-required'
? { reason: 'token-required' as const }
: {}),
}),
})
function SettingsComponent() {
const navigate = Route.useNavigate()
const { tab, reason } = Route.useSearch()
const tokenRequired = reason === 'token-required'
const showBird = !tokenRequired && can('bgp:tenant_settings:admin')
const activeTab: SettingsSection =
tokenRequired || (tab === 'bird' && !showBird) ? 'ui' : tab
function handleTabChange(nextTab: SettingsTab) {
function handleTabChange(nextTab: SettingsSection) {
void navigate({
search: (prev) => ({
...prev,
tab: nextTab,
reason: nextTab === 'ui' ? prev.reason : undefined,
}),
replace: true,
})
@@ -33,9 +41,10 @@ function SettingsComponent() {
return (
<SettingsPageShell
activeTab={tab}
activeTab={activeTab}
onTabChange={handleTabChange}
tokenRequired={tokenRequired}
showBird={showBird}
/>
)
}
@@ -1,358 +1,8 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { PanelCard } from '@/components/panel-card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { FrameDataGrid } from '@/components/reui-kit'
import { SelectField } from '@/components/select-field'
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
import { QueryState } from '@/components/query-state'
import { LoadingButton } from '@/components/loading-button'
import {
BIRD_SETTING_KEYS,
REVISION_SETTING_KEYS,
RUNTIME_LOGS_SETTING_KEYS,
buildPayload,
partitionSettings,
settingsKeys,
settingsQueryOptions,
type BirdSettingKey,
} from '@/queries/settings'
import { apiMutate } from '@/lib/api-client'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
component: TenantSettingsComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
? search.tab
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
}),
beforeLoad: () => {
throw redirect({ to: '/settings', search: { tab: 'bird' } })
},
component: () => null,
})
const RUNTIME_LOGS_ENABLED_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const RUNTIME_LOGS_MODE_ITEMS = [
{ value: 'truncate', label: 'обнулить (truncate)' },
{ value: 'delete', label: 'удалить файл (delete)' },
] as const
const BIRD_LABELS: Record<BirdSettingKey, string> = {
bird_router_id: 'Router ID',
bird_local_ipv4: 'Локальный IPv4',
bird_local_ipv6: 'Локальный IPv6',
bird_local_asn: 'Локальный ASN',
bird_bgp_source_ipv4: 'BGP source IPv4',
bird_bgp_source_ipv6: 'BGP source IPv6',
peer_discovery_enabled: 'Автообнаружение пиров',
peer_discovery_ranges_v4: 'Discovery CIDR IPv4',
peer_discovery_ranges_v6: 'Discovery CIDR IPv6',
peer_discovery_require_external: 'Только external ASN',
}
const BIRD_BOOL_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
peer_discovery_enabled:
'Dynamic neighbor range в BIRD (карантин import/export none). Требует CIDR.',
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
peer_discovery_require_external: 'neighbor range … external (любой чужой ASN)',
}
function TenantSettingsComponent() {
const search = useSearch({ from: '/_auth/_settings/tenant-settings' })
const navigate = Route.useNavigate()
const settingsQ = useQuery(settingsQueryOptions())
const qc = useQueryClient()
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
useEffect(() => {
if (partitioned) {
setBirdForm({ ...partitioned.bird })
setRevisionForm({ ...partitioned.revision })
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsQ.data])
const patchMutation = useMutation({
mutationFn: (payload: Record<string, string | number | boolean>) =>
apiMutate('/v1/settings', 'PATCH', payload),
onSuccess: () => {
toast.success('Параметры сохранены')
void qc.invalidateQueries({ queryKey: settingsKeys.all })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
function saveBird() {
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
}
function saveRevision() {
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
}
function saveRuntimeLogs() {
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
}
return (
<div className="flex flex-col gap-6">
<BadgeTabs
value={search.tab}
onValueChange={(tab) =>
navigate({
search: { tab: tab as 'bird' | 'revision' | 'runtime-logs' | 'additional' },
})
}
items={[
{ value: 'bird', label: 'BIRD' },
{ value: 'revision', label: 'Ревизии' },
{ value: 'runtime-logs', label: 'Файловые логи' },
{ value: 'additional', label: 'Дополнительно' },
]}
>
<TabsContent value="bird" className="mt-0">
<PanelCard
title="BIRD control plane"
description={
<>
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
<code className="text-xs">PATCH /v1/settings</code> (роль operator).
</>
}
contentClassName="space-y-4 py-4"
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-64" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="flex flex-col">
{BIRD_SETTING_KEYS.map((key, index) => (
<SettingsSettingField
key={key}
title={BIRD_LABELS[key]}
description={BIRD_HINTS[key] ?? key}
labelFor={key}
badge={{ label: 'BIRD', variant: 'info-light' }}
last={index === BIRD_SETTING_KEYS.length - 1}
>
{key === 'peer_discovery_enabled' ||
key === 'peer_discovery_require_external' ? (
<SelectField
id={key}
items={[...BIRD_BOOL_ITEMS]}
value={birdForm[key] || 'false'}
onValueChange={(v) =>
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
}
placeholder="Выкл"
/>
) : (
<Input
id={key}
value={birdForm[key] ?? ''}
onChange={(e) =>
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
}
placeholder={BIRD_LABELS[key]}
/>
)}
</SettingsSettingField>
))}
<div className="px-4 py-4">
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
</div>
</div>
)}
</QueryState>
</PanelCard>
</TabsContent>
<TabsContent value="revision" className="mt-0">
<PanelCard
title="Ревизии"
description="Время хранения ревизий в БД"
contentClassName="space-y-4 py-4"
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="flex max-w-sm flex-col gap-1.5">
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label>
<Input
id="revision_retention_minutes"
type="number"
value={revisionForm.revision_retention_minutes ?? ''}
onChange={(e) =>
setRevisionForm((s) => ({
...s,
revision_retention_minutes: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
revision_retention_minutes
</p>
<LoadingButton
className="mt-2 w-fit"
onClick={saveRevision}
loading={patchMutation.isPending}
>
<Save />
Сохранить
</LoadingButton>
</div>
)}
</QueryState>
</PanelCard>
</TabsContent>
<TabsContent value="runtime-logs" className="mt-0">
<PanelCard
title="Файловые логи"
description="Автоматическая очистка логов"
contentClassName="space-y-4 py-4"
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-48" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="grid gap-4 md:grid-cols-2">
<SelectField
label="Авто-очистка включена"
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
placeholder="Выберите"
description="runtime_logs_auto_enabled"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_enabled: v,
}))
}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
<Input
id="runtime_logs_max_file_mb"
type="number"
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_max_file_mb: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_max_file_mb
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_auto_schedule">Расписание (cron)</Label>
<Input
id="runtime_logs_auto_schedule"
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_schedule: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_schedule
</p>
</div>
<SelectField
label="Режим очистки"
items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
placeholder="Выберите"
description="runtime_logs_auto_mode"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_mode: v,
}))
}
/>
<div className="md:col-span-2">
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
</div>
</div>
)}
</QueryState>
</PanelCard>
</TabsContent>
<TabsContent value="additional" className="mt-0">
<FrameDataGrid
title="Дополнительные параметры"
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
>
<QueryState
data={partitioned?.additional ?? []}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
empty={(partitioned?.additional ?? []).length === 0}
emptyTitle="Нет дополнительных параметров"
skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()}
>
{(items) => (
<SettingsKvGrid
items={items}
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
/>
)}
</QueryState>
</FrameDataGrid>
</TabsContent>
</BadgeTabs>
</div>
)
}
+67 -46
View File
@@ -1,15 +1,23 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { KeyRound, MonitorIcon, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { PanelCard } from '@/components/panel-card'
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@evobgp/ui/components/item'
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/reui/badge'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { SettingsCard } from '@/components/settings/settings-card'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { sessionCanManageApiKeys } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth'
@@ -38,6 +46,7 @@ function AccessComponent() {
const kpiItems: KpiStatItem[] = useMemo(
() => [
{
id: 'keys-total',
label: 'Всего ключей',
value: keys.length,
icon: <KeyRound className="size-4" />,
@@ -48,6 +57,7 @@ function AccessComponent() {
),
},
{
id: 'keys-active',
label: 'Активных',
value: activeCount,
icon: <ShieldCheck className="size-4" />,
@@ -58,13 +68,14 @@ function AccessComponent() {
),
},
{
id: 'keys-revoked',
label: 'Отозванных',
value: revokedCount,
icon: <ShieldOff className="size-4" />,
variant: revokedCount > 0 ? 'warning' : 'default',
footer: (
<Badge variant={revokedCount > 0 ? 'warning-light' : 'outline'} size="sm">
revoked
отозваны
</Badge>
),
},
@@ -93,7 +104,7 @@ function AccessComponent() {
})()
return (
<div className="flex flex-col gap-6">
<div className="flex min-w-0 flex-col gap-6">
<PageHeader
title="Права доступа"
description="API-ключи control plane и текущая сессия Bearer-токена."
@@ -108,47 +119,55 @@ function AccessComponent() {
/>
{session ? (
<PanelCard
<SettingsCard
title="Текущая сессия"
description="Tenant и права текущего Bearer (API-ключ или portal JWT)."
contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
>
<div>
<p className="text-muted-foreground">Tenant</p>
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
</div>
<div>
<p className="text-muted-foreground">Доступ</p>
<p className="font-mono text-xs">{sessionAccessLabel}</p>
</div>
{sessionKindLabel ? (
<div>
<p className="text-muted-foreground">Тип</p>
<p className="font-mono text-xs">{sessionKindLabel}</p>
</div>
) : null}
{session.email ? (
<div>
<p className="text-muted-foreground">Email</p>
<p className="break-all text-xs">{session.email}</p>
</div>
) : null}
</PanelCard>
<ItemGroup className="gap-0">
<Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
<ItemMedia className="self-start">
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
<MonitorIcon aria-hidden="true" />
</Item>
</ItemMedia>
<ItemContent className="min-w-0 gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<ItemTitle className="leading-5">Этот браузер</ItemTitle>
{sessionKindLabel ? (
<Badge variant="info-light" size="sm">
{sessionKindLabel}
</Badge>
) : null}
</div>
<ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
<span className="break-all">
Tenant:{' '}
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
</span>
<span className="break-words">Доступ: {sessionAccessLabel}</span>
{session.email ? <span className="break-all">Email: {session.email}</span> : null}
</ItemDescription>
</ItemContent>
</Item>
</ItemGroup>
</SettingsCard>
) : (
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
Не удалось определить сессию. Укажите токен в{' '}
<Link
to="/settings"
search={{ tab: 'connection' }}
className="text-primary underline-offset-4 hover:underline"
>
настройках
</Link>{' '}
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
) : null}
</PanelCard>
<SettingsCard title="Текущая сессия">
<p className="text-muted-foreground px-5 py-4 text-sm">
Не удалось определить сессию. Укажите токен в{' '}
<Link
to="/settings"
search={{ tab: 'ui' }}
className="text-primary underline-offset-4 hover:underline"
>
настройках
</Link>{' '}
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
<span className="text-destructive mt-2 block">{sessionQuery.error.message}</span>
) : null}
</p>
</SettingsCard>
)}
{canManageKeys ? (
@@ -167,11 +186,13 @@ function AccessComponent() {
/>
</>
) : session ? (
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
</PanelCard>
<SettingsCard title="API-ключи">
<p className="text-muted-foreground px-5 py-4 text-sm break-words">
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
</p>
</SettingsCard>
) : null}
</div>
)
+25 -28
View File
@@ -7,7 +7,6 @@ import { Button } from '@evobgp/ui/components/button'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
@@ -15,7 +14,7 @@ import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-o
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
import { OpsDashboard } from '@/components/reui-kit'
import { FrameDataGrid, OpsDashboard } from '@/components/reui-kit'
import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface'
import {
moduleNameById,
@@ -37,7 +36,9 @@ function parseShowQuickActions(value: unknown): boolean {
}
/**
* Dashboard — OpsDashboard kit (KPI → QuickActions → charts → queue).
* Dashboard — OpsDashboard kit (KPI → QuickActions → modules → activity/health → queue).
* Charts slot is a vertical stack: modules stay full-width; activity shares a row
* with BGP widgets only at @5xl (container), never nested 8+4 inside a 2-col parent.
* @see https://reui.io/preview/base/dashboard-1
* @see https://reui.io/preview/base/stats-12
*/
@@ -99,51 +100,47 @@ function DashboardComponent() {
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
charts={
<>
<div className={dashboardMainSidebarClassName}>
<div className="xl:col-span-8">
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
</div>
<div className="xl:col-span-4">
<DashboardActivityTimeline
jobs={jobs}
revisions={revisions}
peers={peers}
speakers={speakers}
/>
</div>
<div className="min-w-0">
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
</div>
<div className={chartPanelGridClassName}>
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
<div className={dashboardMainSidebarClassName}>
<DashboardActivityTimeline
jobs={jobs}
revisions={revisions}
peers={peers}
speakers={speakers}
/>
<div className="grid min-w-0 items-start gap-4">
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
</div>
</div>
</>
}
queueTitle="Недавняя активность"
queueDescription="Задачи и ревизии плоскости управления"
queueTitle="Задачи и ревизии"
queueDescription="Последние фоновые операции и история конфигураций"
queue={
<div className="grid gap-4 lg:grid-cols-2">
<DashboardFramePanel
<div className={chartPanelGridClassName}>
<FrameDataGrid
title="Недавние задачи"
description="Последние фоновые операции"
className="h-full"
>
{activityLoading ? (
<Skeleton className="m-4 h-24 w-auto" />
) : (
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
<DashboardRecentJobsGrid jobs={jobs.slice(0, 8)} nameById={nameById} isLoading={refreshing} />
)}
</DashboardFramePanel>
<DashboardFramePanel
</FrameDataGrid>
<FrameDataGrid
title="Последние ревизии"
description="История конфигураций"
className="h-full"
>
{activityLoading ? (
<Skeleton className="m-4 h-24 w-auto" />
) : (
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
)}
</DashboardFramePanel>
</FrameDataGrid>
</div>
}
/>
+1 -1
View File
@@ -258,7 +258,7 @@ function MonitoringComponent() {
<IllustratedEmptyState
icon={FileText}
title="Файловые логи"
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в tenant-settings."
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в Настройках BIRD."
/>
</TabsContent>
</BadgeTabs>
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,7 +1,7 @@
# Скопируйте в .env рядом с docker-compose.yaml и подставьте своего владельца реестра (нижний регистр).
# Перед первым запуском: docker login git.shts.su
# Перед первым запуском: docker login git.shx.one
EVOBGP_REGISTRY=git.shts.su/denozord
EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest
# Для защищенного Web UI (Traefik + Let's Encrypt + Cloudflare DNS challenge)
+1 -1
View File
@@ -1,7 +1,7 @@
# Production /opt/evobgp — скопируйте в .env на сервере
# cp .env.production.example .env
EVOBGP_REGISTRY=git.shts.su/denozord
EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest
# Имя compose-проекта (должно совпадать с name: в docker-compose.yaml)
+1 -1
View File
@@ -1,7 +1,7 @@
# Скопируйте в .env.remote-speaker рядом с docker-compose.remote-speaker.yaml
# Значения agent_secret и node token — из Web UI после создания спикера.
EVOBGP_REGISTRY=git.shts.su/denozord
EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest
# Control plane (HTTPS в prod)
@@ -4,7 +4,7 @@
# docker compose --env-file .env.stack.microvps-full -f stack.microvps-full.yaml up -d
# Образы EvoBGP
EVOBGP_REGISTRY=git.shts.su/denozord
EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest
# Защищенный Web UI (Traefik + Let's Encrypt + Cloudflare DNS challenge)
+2 -2
View File
@@ -7,7 +7,7 @@ name: evobgp-bgp-stack
services:
bird2:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
cap_add:
- NET_ADMIN
@@ -17,7 +17,7 @@ services:
# network_mode: "host" # раскомментируйте при анонсе BGP с хоста
evobgp-agent:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
- bird2
@@ -1,6 +1,6 @@
# Дополнительный файл Compose: профиль microvps-full + ужатые лимиты под хост ~1 ГиБ RAM.
#
# Запуск (PowerShell, из каталога deploy\compose): сначала .env из .env.example, docker login git.shts.su, docker compose pull
# Запуск (PowerShell, из каталога deploy\compose): сначала .env из .env.example, docker login git.shx.one, docker compose pull
# docker compose -f docker-compose.yaml -f docker-compose.microvps-full.yaml --profile microvps-full up -d
#
# Остановка:
@@ -6,7 +6,7 @@
# Запуск:
# cd /opt/evobgp
# mkdir -p runtime-logs
# docker login git.shts.su
# docker login git.shx.one
# docker compose pull
# docker compose up -d
#
@@ -74,7 +74,7 @@ services:
max-file: "3"
bird2:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
cap_add:
- NET_ADMIN
@@ -93,7 +93,7 @@ services:
max-file: "3"
evobgp-agent:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
bird2:
@@ -112,7 +112,7 @@ services:
max-file: "3"
evobgp-all:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-all:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -157,7 +157,7 @@ services:
max-file: "3"
evobgp-web:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-web-all:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-web-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
evobgp-all:
@@ -22,7 +22,7 @@ x-logging: &default-logging
services:
bird2:
profiles: ["production", "plain", "fallback"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
network_mode: host
cap_add:
@@ -35,7 +35,7 @@ services:
evobgp-agent:
profiles: ["production"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
- bird2
@@ -70,7 +70,7 @@ services:
evobgp-agent-plain:
profiles: ["plain", "fallback"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
network_mode: host
depends_on:
@@ -127,7 +127,7 @@ services:
sync-bundle:
profiles: ["fallback"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-node:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-node:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
- bird2
+12 -12
View File
@@ -1,8 +1,8 @@
# EvoBGP Compose: профили `reference` (эталон) и `microvps` (одна VPS).
#
# Образы EvoBGP тянутся из Container Registry (не собираются здесь). Префикс и тег — .env (см. .env.example):
# EVOBGP_REGISTRY=git.shts.su/<owner> EVOBGP_IMAGE_TAG=latest
# Перед up: docker login git.shts.su && docker compose pull
# EVOBGP_REGISTRY=git.shx.one/<owner> EVOBGP_IMAGE_TAG=latest
# Перед up: docker login git.shx.one && docker compose pull
#
# Запуск:
# docker compose --profile reference up -d
@@ -81,7 +81,7 @@ services:
evobgp-api:
profiles: ["reference"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-api:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-api:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -115,7 +115,7 @@ services:
evobgp-scheduler:
profiles: ["reference"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-scheduler:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-scheduler:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -139,7 +139,7 @@ services:
evobgp-ingest:
profiles: ["reference"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-ingest:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-ingest:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -157,7 +157,7 @@ services:
evobgp-render:
profiles: ["reference"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-render:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-render:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -175,7 +175,7 @@ services:
evobgp-deploy:
profiles: ["reference"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-deploy:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-deploy:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -196,7 +196,7 @@ services:
evobgp-web:
profiles: ["reference"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-web:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-web:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
- evobgp-api
@@ -211,7 +211,7 @@ services:
evobgp-all:
profiles: ["microvps", "microvps-full"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-all:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -241,7 +241,7 @@ services:
# Web UI → evobgp-all (образ evobgp-web-all из реестра). Reference: сервис evobgp-web → evobgp-api.
evobgp-web-microvps:
profiles: ["microvps-full"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-web-all:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-web-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
- evobgp-all
@@ -329,7 +329,7 @@ services:
bird2:
profiles: ["reference", "microvps", "microvps-full"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
cap_add:
- NET_ADMIN
@@ -349,7 +349,7 @@ services:
evobgp-agent:
profiles: ["reference", "microvps", "microvps-full"]
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
- bird2
+4 -4
View File
@@ -78,7 +78,7 @@ services:
max-file: "3"
bird2:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
cap_add:
- NET_ADMIN
@@ -97,7 +97,7 @@ services:
max-file: "3"
evobgp-agent:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
bird2:
@@ -116,7 +116,7 @@ services:
max-file: "3"
evobgp-all:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-all:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -161,7 +161,7 @@ services:
max-file: "3"
evobgp-web:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-web-all:${EVOBGP_IMAGE_TAG:-latest}
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-web-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
evobgp-all:
+3 -3
View File
@@ -17,8 +17,8 @@
Кэш registry (переменные bake):
- `git.shts.su/<owner>/evobgp-buildcache:go-buildcache`**запись** только из target `go-build-all`
- `git.shts.su/<owner>/evobgp-buildcache:web-buildcache`**запись** только из target `web-build`
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache`**запись** только из target `go-build-all`
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache`**запись** только из target `web-build`
Остальные bake-target’ы только `cache-from` (чтение). Параллельный `cache-to` в один ref ломает manifest в registry (`content descriptor … not found`).
@@ -32,7 +32,7 @@
```bash
cd deploy/docker
export REGISTRY=git.shts.su/<owner>
export REGISTRY=git.shx.one/<owner>
export IMAGE_TAG=latest
export SHORT_SHA=$(git rev-parse --short HEAD)
export VERSION=dev
+1 -1
View File
@@ -4,7 +4,7 @@
# Переменные: REGISTRY, IMAGE_TAG, CACHE_REF_GO, CACHE_REF_WEB
variable "REGISTRY" {
default = "git.shts.su/evobgp"
default = "git.shx.one/evobgp"
}
variable "IMAGE_TAG" {
+1 -1
View File
@@ -14,7 +14,7 @@
| Раздел | Описание |
|--------|----------|
| [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) | Компоненты, потоки данных, пакеты |
| [manual.md](manual.md) | Подробное руководство по модулям, процессам и API |
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI |
+1 -1
View File
@@ -4,7 +4,7 @@
## 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)
**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**.
+4 -4
View File
@@ -118,10 +118,10 @@ EvoBGP управляет генерацией и применением BGP-к
| Маршрут | Назначение |
|---------|------------|
| `/settings` | Только браузер: API-токен, тема (localStorage). Tenant KV здесь **не** редактируются. |
| `/tenant-settings` | Все tenant-параметры из `/v1/settings`: вкладки **BIRD**, **Ревизии**, **Файловые логи** (автоочистка FS), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. |
| `/network` → Control plane | Краткая сводка BIRD + ссылка на `/tenant-settings?tab=bird`. |
| `/operations` | Ревизии, diff, jobs; вкладка «Система» перенесена в `/tenant-settings`. |
| `/settings?tab=ui` | Настройки UI: API-токен, сессия, тема. |
| `/settings?tab=bird` | Настройки BIRD: control plane, ревизии, файловые логи, дополнительные ключи `/v1/settings`. Старый `/tenant-settings` редиректит сюда. |
| `/network` → Control plane | Краткая сводка BIRD. |
| `/operations` | Ревизии, diff, jobs. |
### Web UI: файловые runtime-логи
+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 на практике.
- [architecture.md](architecture.md) — из каких процессов и пакетов это собрано.
- [evobgp-api-sketches.md](evobgp-api-sketches.md) — ранние таблицы эндпоинтов (черновик).
+20 -20
View File
@@ -11,12 +11,12 @@
## Готовые образы без сборки (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
git.shts.su/<owner>/<имя_образа>:<тег>
git.shx.one/<owner>/<имя_образа>:<тег>
```
**Теги:** `latest`, короткий SHA коммита (7 символов), `sha-<полный_sha>` — см. [.gitea/README.md](../.gitea/README.md).
@@ -28,28 +28,28 @@ git.shts.su/<owner>/<имя_образа>:<тег>
На сервере или в PowerShell перед `docker pull`:
```powershell
docker login git.shts.su
docker login git.shx.one
```
Укажите учётную запись Gitea и **PAT / пароль приложения** с правом чтения пакетов (или токен, который принимает ваш реестр).
### Ссылки и команды `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 |
|--------|------------|--------------------------|------|
| `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-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-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-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-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-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-node` | Нода на площадке | [packages/…/evobgp-node](https://git.shts.su/denozord/-/packages/container/evobgp-node/latest) | `docker pull git.shts.su/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-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-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-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-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.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.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.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.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.shx.one/denozord/-/packages/container/evobgp-deploy/latest) | `docker pull git.shx.one/denozord/evobgp-deploy: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.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.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.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.shx.one/denozord/-/packages/container/evobgp-bird2/latest) | `docker pull git.shx.one/denozord/evobgp-bird2:latest` |
На **Linux-сервере** команды `docker pull` и `docker login` такие же (выполняйте в обычном shell).
@@ -61,10 +61,10 @@ docker login git.shts.su
docker run --rm -p 8080:8080 `
-e EVOBGP_DATABASE_URL="postgres://user:pass@host:5432/evobgp?sslmode=disable" `
-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
@@ -73,7 +73,7 @@ docker run --rm -p 8080:8080 `
```powershell
cd deploy\compose
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 up -d
```
@@ -107,7 +107,7 @@ docker compose --profile microvps down -v
**Рекомендация:** на хосте с ровно **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:
@@ -193,7 +193,7 @@ docker compose --env-file .env --env-file .env.web-sec -f docker-compose.yaml -f
```powershell
cd deploy\compose
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 up -d
```
+1 -1
View File
@@ -1,6 +1,6 @@
# Релизы и версионирование 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`). Ручное повышение версии в коде не требуется.
## Как определяется версия
+10 -7
View File
@@ -24,12 +24,13 @@ function Tabs({
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 border-b border-border bg-transparent",
default:
"bg-muted p-[3px] group-data-[orientation=horizontal]/tabs:h-9",
line: "h-auto gap-1 overflow-visible border-b border-border bg-transparent p-0",
},
},
defaultVariants: {
@@ -58,10 +59,12 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
"relative inline-flex items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=default]/tabs-list:h-[calc(100%-1px)] group-data-[variant=default]/tabs-list:flex-1 group-data-[variant=default]/tabs-list:data-active:shadow-sm",
"group-data-[variant=line]/tabs-list:h-auto group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:rounded-none group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:py-1.5 group-data-[variant=line]/tabs-list:shadow-none group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:shadow-none dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"group-data-[variant=default]/tabs-list:data-active:bg-background group-data-[variant=default]/tabs-list:data-active:text-foreground dark:group-data-[variant=default]/tabs-list:data-active:border-input dark:group-data-[variant=default]/tabs-list:data-active:bg-input/30 dark:group-data-[variant=default]/tabs-list:data-active:text-foreground",
"data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-0 group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
+1 -1
View File
@@ -15,7 +15,7 @@ if [ ! -f "$COMPOSE" ]; then
fi
# 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_AGENT_SECRET="${EVOBGP_AGENT_SECRET:-ci-test-secret}"
export EVOBGP_CONTROL_PLANE_URL="${EVOBGP_CONTROL_PLANE_URL:-https://cp.example.com}"