Compare commits

..
5 Commits
Author SHA1 Message Date
Denozordec 44f70443c3 refactor(web): improve layout and responsiveness of settings components
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 53s
CI / web (push) Successful in 1m16s
CI / release (push) Successful in 4m24s
Enhanced various settings components by adding 'min-w-0' class for better overflow handling and adjusting flex properties for improved alignment. Updated card and grid structures to ensure a cohesive user experience across the settings interface. Refined descriptions and layout in several settings tabs for better clarity and usability.
2026-08-18 01:19:42 +07:00
DenozordecandCursor 357ed4ce7b refactor(web): enhance layout and styling for dashboard components
CI / changes (push) Successful in 12s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 1m18s
CI / web (push) Successful in 2m0s
CI / release (push) Successful in 6m51s
Updated various dashboard components to improve layout and responsiveness. Adjusted class names to include 'min-w-0' for better handling of overflow and added flex properties to ensure proper alignment. Refined grid structures and skeleton loading states for a more cohesive user experience.

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

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

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