Compare commits

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

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

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

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

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