Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfc33dcb99 | ||
|
|
44f70443c3 | ||
|
|
357ed4ce7b | ||
|
|
35e262b5ae | ||
|
|
8267141136 | ||
|
|
34a29ad337 |
+8
-6
@@ -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).
|
||||
|
||||
@@ -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
@@ -37,7 +37,7 @@
|
||||
[
|
||||
"@markwylde/semantic-release-gitea",
|
||||
{
|
||||
"giteaUrl": "https://git.shts.su",
|
||||
"giteaUrl": "https://git.shx.one",
|
||||
"assets": [
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
|
||||
@@ -22,8 +22,8 @@ Control plane для управления префиксами, модулями
|
||||
|
||||
```powershell
|
||||
cd deploy\compose
|
||||
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shts.su/<owner>
|
||||
docker login git.shts.su
|
||||
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shx.one/<owner>
|
||||
docker login git.shx.one
|
||||
docker compose --profile microvps pull
|
||||
docker compose --profile microvps up -d
|
||||
```
|
||||
|
||||
@@ -49,7 +49,8 @@ export function AccessApiKeysCard({
|
||||
<>
|
||||
<FrameDataGrid
|
||||
title="API-ключи"
|
||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
||||
description="Полный токен показывается только при создании и ротации."
|
||||
className="min-w-0"
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { ApiKey } from '@/types/api'
|
||||
|
||||
@@ -34,7 +35,14 @@ export function AccessApiKeysGrid({
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.name}
|
||||
accent="primary"
|
||||
subtitle={`${row.original.prefix}…`}
|
||||
/>
|
||||
),
|
||||
minSize: 160,
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
@@ -45,14 +53,6 @@ export function AccessApiKeysGrid({
|
||||
),
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'prefix',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}…</span>
|
||||
),
|
||||
meta: { headerTitle: 'Префикс' },
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
enableSorting: false,
|
||||
@@ -77,11 +77,11 @@ export function AccessApiKeysGrid({
|
||||
{
|
||||
id: 'last_used_at',
|
||||
accessorFn: (row) => row.last_used_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Последнее использование' },
|
||||
meta: { headerTitle: 'Использован' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -90,7 +90,7 @@ export function AccessApiKeysGrid({
|
||||
cell: ({ row }) => {
|
||||
const k = row.original
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
@@ -149,6 +149,7 @@ export function AccessApiKeysGrid({
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ключей"
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск API-ключей…"
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@evobgp/ui/components/chart'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
@@ -43,6 +45,7 @@ export function ChartDonutMetric({
|
||||
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
|
||||
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="count"
|
||||
|
||||
@@ -51,7 +51,7 @@ export function NetworkOverviewAnalyticsCard({
|
||||
label: 'Offline',
|
||||
percent: 100 - speakersPct,
|
||||
}}
|
||||
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -41,14 +41,17 @@ export function BadgeTabs({
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
orientation="horizontal"
|
||||
className={cn('w-full', className)}
|
||||
className={cn('w-full min-w-0', className)}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
|
||||
className={cn(
|
||||
'mb-3.5 w-full min-w-0 justify-start gap-4',
|
||||
listClassName,
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
||||
<TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{item.count !== undefined ? (
|
||||
|
||||
@@ -50,7 +50,7 @@ export function DashboardActivityTimeline({
|
||||
<DashboardFramePanel
|
||||
title="Недавняя активность"
|
||||
description="Задачи, ревизии и сетевые события"
|
||||
className="h-full"
|
||||
className="h-full min-w-0"
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
|
||||
@@ -82,7 +82,7 @@ export function DashboardActivityTimeline({
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="min-w-0 pb-1 text-foreground">
|
||||
<TimelineTitle className="text-sm leading-snug font-normal">
|
||||
<TimelineTitle className="text-sm leading-snug font-normal break-words">
|
||||
{item.message}
|
||||
</TimelineTitle>
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -82,7 +82,7 @@ function buildKpis({
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
||||
label: 'Пиры Established',
|
||||
label: 'Пиры установлены',
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
||||
@@ -94,13 +94,13 @@ function buildKpis({
|
||||
icon: <ServerCog aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
label: 'Спикеры online',
|
||||
label: 'Спикеры в сети',
|
||||
footer: (
|
||||
<Badge
|
||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'live-снимок'}
|
||||
{loading ? '…' : 'онлайн'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||
import type { ModuleRow } from '@/types/api'
|
||||
import {
|
||||
@@ -51,20 +52,19 @@ import {
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
type ModuleSort = 'name' | 'type' | 'priority' | 'last_refreshed_at'
|
||||
type ModuleSort = 'name' | 'type' | 'priority'
|
||||
type EnabledFilter = 'all' | 'enabled' | 'disabled'
|
||||
|
||||
const sortLabels: Record<ModuleSort, string> = {
|
||||
name: 'Название',
|
||||
type: 'Тип',
|
||||
priority: 'Приоритет',
|
||||
last_refreshed_at: 'Обновлено',
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
|
||||
|
||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
||||
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }]
|
||||
return [{ id: sortBy, desc: false }]
|
||||
}
|
||||
|
||||
export function DashboardModulesGrid({
|
||||
@@ -119,9 +119,10 @@ export function DashboardModulesGrid({
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
|
||||
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" />
|
||||
<DataGridPrimaryCell title={row.original.name} accent="primary" />
|
||||
</div>
|
||||
),
|
||||
minSize: 180,
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
},
|
||||
{
|
||||
@@ -138,30 +139,9 @@ export function DashboardModulesGrid({
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
|
||||
),
|
||||
size: 88,
|
||||
meta: { headerTitle: 'Приоритет' },
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
accessorFn: (row) => (row.enabled !== false ? 'enabled' : 'disabled'),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => <ModeBadge enabled={row.original.enabled !== false} />,
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
accessorFn: (row) => row.last_refreshed_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.last_refreshed_at
|
||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
sortingFn: (a, b) =>
|
||||
(a.original.last_refreshed_at ?? '').localeCompare(b.original.last_refreshed_at ?? ''),
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
@@ -185,6 +165,7 @@ export function DashboardModulesGrid({
|
||||
<DashboardFramePanel
|
||||
title="Модули"
|
||||
description="Поиск, сортировка и быстрый переход к настройке"
|
||||
className="min-w-0"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
||||
<PlusIcon />
|
||||
@@ -204,14 +185,14 @@ export function DashboardModulesGrid({
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
width: 'fixed',
|
||||
width: 'auto',
|
||||
}}
|
||||
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
|
||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<InputGroup className="w-full min-w-40 sm:max-w-xs">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-3 @3xl:flex-row @3xl:items-center @3xl:justify-between">
|
||||
<InputGroup className="w-full min-w-0 @3xl:max-w-xs">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
|
||||
</InputGroupAddon>
|
||||
@@ -337,8 +318,8 @@ export function DashboardModulesGrid({
|
||||
<div className="border-t px-4 py-3">
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridPagination
|
||||
{...DATA_GRID_PAGINATION_RU}
|
||||
sizes={[10, 15, 20]}
|
||||
info="{from}–{to} из {count}"
|
||||
className="py-0"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -57,7 +57,7 @@ export function DashboardNetworkHealth({
|
||||
}
|
||||
primary={{
|
||||
value: loading ? '—' : `${utilization}%`,
|
||||
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры online',
|
||||
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
|
||||
percent: loading ? 0 : utilization,
|
||||
badge: (
|
||||
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
|
||||
@@ -67,7 +67,7 @@ export function DashboardNetworkHealth({
|
||||
}}
|
||||
secondary={{
|
||||
value: loading ? '—' : offline,
|
||||
label: mode === 'peers' ? 'Не Established' : 'Offline',
|
||||
label: mode === 'peers' ? 'Не установлены' : 'Не в сети',
|
||||
percent: total > 0 ? Math.round((offline / total) * 100) : 0,
|
||||
}}
|
||||
footer={
|
||||
|
||||
@@ -3,13 +3,16 @@ import { useMemo, useState } from 'react'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, ModuleRow } from '@/types/api'
|
||||
|
||||
type FlowMode = 'jobs' | 'modules'
|
||||
|
||||
/** chart-13 inspired donut breakdown (Card surface). */
|
||||
/**
|
||||
* Donut distribution — chart-27 DNA: Frame + one legend (inside ChartDonutMetric).
|
||||
* @see https://reui.io/preview/base/chart-27
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function DashboardOperationsBreakdown({
|
||||
jobs,
|
||||
modules,
|
||||
@@ -32,7 +35,7 @@ export function DashboardOperationsBreakdown({
|
||||
return (
|
||||
<PanelCard
|
||||
title="Поток операций"
|
||||
description="Распределение задач и типов модулей"
|
||||
description="Доли статусов задач или типов модулей"
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
@@ -43,45 +46,16 @@ export function DashboardOperationsBreakdown({
|
||||
]}
|
||||
/>
|
||||
}
|
||||
className="h-full"
|
||||
className="min-w-0"
|
||||
contentClassName="px-5 py-4"
|
||||
>
|
||||
<div className="p-4">
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
||||
{slices.length > 0 ? (
|
||||
<ul className="mt-4 flex min-w-0 flex-col">
|
||||
{slices.map((slice, index) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
<li key={slice.key}>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
||||
style={{ backgroundColor: slice.color }}
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">{slice.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">{slice.count}</span>
|
||||
<span className="text-muted-foreground/70 w-10 text-right text-xs tabular-nums">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
{index < slices.length - 1 ? <Separator className="w-auto" /> : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex h-44 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
||||
)}
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'lookup',
|
||||
title: 'Проверка IP/домена',
|
||||
description: 'Membership в списках и community (entry + snapshot).',
|
||||
description: 'Проверка IP в списках и BGP-сообществах.',
|
||||
to: '/lookup',
|
||||
icon: <Search aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
@@ -23,7 +23,7 @@ const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'communities',
|
||||
title: 'BGP-сообщества',
|
||||
description: 'Справочник communities для политик экспорта.',
|
||||
description: 'Справочник BGP-сообществ для политик экспорта.',
|
||||
to: '/directories',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
@@ -31,7 +31,7 @@ const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'network',
|
||||
title: 'Сеть',
|
||||
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
||||
description: 'Обзор пиров, спикеров и живых BGP-сессий.',
|
||||
to: '/network',
|
||||
search: { tab: 'overview' },
|
||||
icon: <Network aria-hidden />,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
@@ -49,7 +49,7 @@ export function DashboardRecentJobsGrid({
|
||||
[nameById],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
const { table, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => {
|
||||
@@ -63,16 +63,13 @@ export function DashboardRecentJobsGrid({
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск задач…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
@@ -42,7 +42,7 @@ export function DashboardRecentRevisionsGrid({
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
const { table, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => row.id,
|
||||
@@ -51,16 +51,13 @@ export function DashboardRecentRevisionsGrid({
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ревизий"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск ревизий…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function PanelCard({
|
||||
<Frame
|
||||
dense
|
||||
spacing={spacing}
|
||||
className={cn(panelCardClassName, className)}
|
||||
className={cn(panelCardClassName, 'min-w-0', className)}
|
||||
>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
|
||||
@@ -29,10 +29,10 @@ export function FrameDataGrid({
|
||||
}) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
||||
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3 border-b">
|
||||
<FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? (
|
||||
|
||||
@@ -1,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 5–6 columns on laptop width (tiles overlap).
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function kpiCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
if (count <= 6) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
|
||||
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4'
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ export function KpiStatGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||
<Frame className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
|
||||
@@ -27,7 +20,7 @@ interface OpsDashboardProps {
|
||||
}
|
||||
|
||||
const rootClassName =
|
||||
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
||||
'text-foreground @container flex w-full min-w-0 flex-col gap-4 md:gap-6'
|
||||
|
||||
function OpsDashboardSkeleton() {
|
||||
return (
|
||||
@@ -37,7 +30,7 @@ function OpsDashboardSkeleton() {
|
||||
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
||||
</header>
|
||||
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||
<div className="grid gap-2 @3xl:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
@@ -47,9 +40,11 @@ function OpsDashboardSkeleton() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ops dashboard: KPI → optional Quick Actions → charts → attention queue.
|
||||
* Ops dashboard: KPI → optional Quick Actions → charts → queue.
|
||||
* Queue is a sibling Frame grid — never wrap FrameDataGrid inside another Frame.
|
||||
* @see https://reui.io/preview/base/dashboard-1
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function OpsDashboard({
|
||||
title = 'Панель управления',
|
||||
@@ -77,21 +72,18 @@ export function OpsDashboard({
|
||||
|
||||
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
|
||||
|
||||
<section
|
||||
aria-label="Аналитика"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
>
|
||||
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label={queueTitle}>
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>{queueTitle}</FrameTitle>
|
||||
<FrameDescription>{queueDescription}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>{queue}</FramePanel>
|
||||
</Frame>
|
||||
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||
{queueDescription ? (
|
||||
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{queue}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||
import { FILTERS_I18N_RU } from '@/lib/filters-i18n'
|
||||
import { applyFiltersToData } from './filter-utils'
|
||||
|
||||
export interface ResourcePageTab {
|
||||
@@ -322,6 +324,7 @@ export function ResourcePage<T extends object>({
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
i18n={FILTERS_I18N_RU}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
@@ -355,11 +358,8 @@ export function ResourcePage<T extends object>({
|
||||
|
||||
<FrameFooter>
|
||||
<DataGridPagination
|
||||
{...DATA_GRID_PAGINATION_RU}
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from} - {to} of {count}"
|
||||
previousPageLabel="Предыдущая"
|
||||
nextPageLabel="Следующая"
|
||||
/>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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: 'Некорректное значение',
|
||||
},
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Button } from '@evobgp/ui/components/button'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
|
||||
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
||||
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
||||
@@ -15,7 +14,7 @@ import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-o
|
||||
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
|
||||
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
|
||||
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
|
||||
import { OpsDashboard } from '@/components/reui-kit'
|
||||
import { FrameDataGrid, OpsDashboard } from '@/components/reui-kit'
|
||||
import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface'
|
||||
import {
|
||||
moduleNameById,
|
||||
@@ -37,7 +36,9 @@ function parseShowQuickActions(value: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard — OpsDashboard kit (KPI → QuickActions → charts → queue).
|
||||
* Dashboard — OpsDashboard kit (KPI → QuickActions → modules → activity/health → queue).
|
||||
* Charts slot is a vertical stack: modules stay full-width; activity shares a row
|
||||
* with BGP widgets only at @5xl (container), never nested 8+4 inside a 2-col parent.
|
||||
* @see https://reui.io/preview/base/dashboard-1
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
@@ -99,51 +100,47 @@ function DashboardComponent() {
|
||||
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
|
||||
charts={
|
||||
<>
|
||||
<div className={dashboardMainSidebarClassName}>
|
||||
<div className="xl:col-span-8">
|
||||
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
||||
</div>
|
||||
<div className="xl:col-span-4">
|
||||
<DashboardActivityTimeline
|
||||
jobs={jobs}
|
||||
revisions={revisions}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
||||
</div>
|
||||
<div className={chartPanelGridClassName}>
|
||||
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
|
||||
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
|
||||
<div className={dashboardMainSidebarClassName}>
|
||||
<DashboardActivityTimeline
|
||||
jobs={jobs}
|
||||
revisions={revisions}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
/>
|
||||
<div className="grid min-w-0 items-start gap-4">
|
||||
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
|
||||
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
queueTitle="Недавняя активность"
|
||||
queueDescription="Задачи и ревизии плоскости управления"
|
||||
queueTitle="Задачи и ревизии"
|
||||
queueDescription="Последние фоновые операции и история конфигураций"
|
||||
queue={
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<DashboardFramePanel
|
||||
<div className={chartPanelGridClassName}>
|
||||
<FrameDataGrid
|
||||
title="Недавние задачи"
|
||||
description="Последние фоновые операции"
|
||||
className="h-full"
|
||||
>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-4 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
|
||||
<DashboardRecentJobsGrid jobs={jobs.slice(0, 8)} nameById={nameById} isLoading={refreshing} />
|
||||
)}
|
||||
</DashboardFramePanel>
|
||||
<DashboardFramePanel
|
||||
</FrameDataGrid>
|
||||
<FrameDataGrid
|
||||
title="Последние ревизии"
|
||||
description="История конфигураций"
|
||||
className="h-full"
|
||||
>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-4 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
||||
)}
|
||||
</DashboardFramePanel>
|
||||
</FrameDataGrid>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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,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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -24,12 +24,13 @@ function Tabs({
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 border-b border-border bg-transparent",
|
||||
default:
|
||||
"bg-muted p-[3px] group-data-[orientation=horizontal]/tabs:h-9",
|
||||
line: "h-auto gap-1 overflow-visible border-b border-border bg-transparent p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -58,10 +59,12 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
"relative inline-flex items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=default]/tabs-list:h-[calc(100%-1px)] group-data-[variant=default]/tabs-list:flex-1 group-data-[variant=default]/tabs-list:data-active:shadow-sm",
|
||||
"group-data-[variant=line]/tabs-list:h-auto group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:rounded-none group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:py-1.5 group-data-[variant=line]/tabs-list:shadow-none group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:shadow-none dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"group-data-[variant=default]/tabs-list:data-active:bg-background group-data-[variant=default]/tabs-list:data-active:text-foreground dark:group-data-[variant=default]/tabs-list:data-active:border-input dark:group-data-[variant=default]/tabs-list:data-active:bg-input/30 dark:group-data-[variant=default]/tabs-list:data-active:text-foreground",
|
||||
"data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-0 group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user