Compare commits

...
14 Commits
Author SHA1 Message Date
DenozordecandCursor 5bdb1f1f93 ci(deploy): narrow image build and document pool settings
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 43s
quality / web (push) Successful in 1m20s
quality / go (push) Failing after 1m9s
quality / bird2 (push) Skipped
quality / postgres-integration (push) Failing after 2m29s
quality / legacy-compatibility (1.0.0) (push) Successful in 1m14s
quality / legacy-compatibility (2.56.2) (push) Successful in 1m34s
CD / quality (push) Failing after 8m40s
CD / publish (push) Skipped
Примеры Compose задают пул соединений. Контекст сборки бинарника сужен, зеркало базовых образов обновляется явно, алерты Prometheus дополнены.

Co-authored-by: Cursor <[email protected]>
2026-11-02 17:24:25 +07:00
DenozordecandCursor 240d83fc61 docs(docs): describe durable jobs, telemetry, and bundle limits
Зафиксированы очередь job, семантика метрик, лимиты бандла и исходящих запросов, а также чеклист продакшена для пула соединений.

Co-authored-by: Cursor <[email protected]>
2026-11-02 17:24:18 +07:00
DenozordecandCursor bda45d633e feat(web): show live BGP observation and deployment progress
Панель и сеть читают сохранённое наблюдение и прогресс deploy. Качество и свежесть сессии видны на спикерах и пирах, без подмены статуса неопрошенным Established.

Co-authored-by: Cursor <[email protected]>
2026-11-02 17:24:11 +07:00
DenozordecandCursor f8e0520817 feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь.

Co-authored-by: Cursor <[email protected]>
2026-11-02 17:23:59 +07:00
DenozordecandClaude Sonnet 5.5 924f111e48 fix(web): restore dev proxy target to 8080
quality / changes (push) Successful in 9s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 37s
quality / web (push) Successful in 2m14s
CD / quality (push) Successful in 5m51s
CD / publish (push) Successful in 6m42s
Случайно попавший в предыдущий коммит порт 8081 для локального мока возвращён на 8080.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 00:19:07 +07:00
DenozordecandClaude Sonnet 5.5 7b0f09e292 fix(web): soften input focus highlight
quality / changes (push) Successful in 12s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 38s
quality / web (push) Successful in 2m12s
CD / quality (push) Successful in 3m12s
CD / publish (push) Successful in 46s
Поля ввода больше не рисуют сплошной синий outline поверх собственного кольца: остаётся тонкая рамка и мягкое кольцо 18%.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 00:18:59 +07:00
DenozordecandClaude Sonnet 5.5 574e92830b fix(web): drop single Overview tab on dashboard
quality / changes (push) Successful in 7s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 21s
quality / web (push) Successful in 1m5s
CD / quality (push) Successful in 1m36s
CD / publish (push) Successful in 2m59s
На панели вкладка «Обзор» была единственной и ничего не переключала — убрана, блоки идут сразу под KPI.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 23:46:52 +07:00
DenozordecandClaude Sonnet 5.5 3b79d834cd feat(web): dashboard as on the shared mockup board
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 26s
quality / web (push) Successful in 1m24s
CD / quality (push) Successful in 2m0s
CD / publish (push) Successful in 3m7s
Панель переписана по доске BgpDashboard: действия в шапке, KPI-полоса, вкладка «Обзор», быстрые действия карточками, график префиксов по последним ревизиям, таблица модулей и лента активности. Графики загрузки BGP и потока операций убраны с панели — они живут в «Сети» и «Мониторинге».

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 22:38:11 +07:00
DenozordecandClaude Sonnet 5.5 aba18e15b7 feat(web): pages and tables as on shared mockup boards
quality / changes (push) Successful in 9s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 23s
quality / web (push) Successful in 1m22s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 3m31s
ResourcePage и FrameDataGrid получили раскладку досок: заголовок Frame с поиском и фильтрами, линейные виды со счётчиками, подвал «Показано N из M». Страница модулей — KPI-полоса, кнопка создания в шапке, вкладка. Операции: действия применения и перезагрузки BIRD в шапке, баннер при ошибках задач. Сеть: баннер пиров на одобрение. Кнопки шапок — стандартной высоты.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 21:59:12 +07:00
DenozordecandClaude Sonnet 5.5 53420b49a1 feat(web): dashboard modules grid and activity feed as on mockup boards
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 23s
quality / web (push) Successful in 1m32s
CD / quality (push) Successful in 2m11s
CD / publish (push) Successful in 3m7s
Заголовки страниц приведены к доскам (20px). На панели добавлены таблица первых 5 модулей и лента активности по последним задачам.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:47:08 +07:00
DenozordecandClaude Sonnet 5.5 55ddce43f7 fix(web): отступы контента как в MikrotikManager (gap-5, p-6)
quality / changes (push) Successful in 11s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 35s
quality / web (push) Successful in 1m27s
CD / quality (push) Successful in 3m50s
CD / publish (push) Successful in 3m4s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:40:47 +07:00
DenozordecandClaude Sonnet 5.5 bcb1c48997 fix(web): MikrotikManager palette and full-width content
Токены цвета в packages/ui синхронизированы с app/globals.css MikrotikManager (main): сине-серая палитра, тёмный сайдбар, статусные и chart-токены, шкала радиусов. Ограничение max-w 1480px у контента оболочки снято, отступ как у MikrotikManager (p-6).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:37:43 +07:00
DenozordecandClaude Sonnet 5.5 b20caef1f1 feat(web): align tables, KPI strip and tabs with shared mockup
quality / changes (push) Successful in 7s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 36s
quality / web (push) Successful in 1m4s
CD / quality (push) Successful in 1m49s
CD / publish (push) Successful in 9m35s
Таблицы ResourcePage и FrameDataGrid переведены на раскладку MikrotikManager: сегменты, фильтры и поиск в тулбаре, подложка шапки, ячейки py-3. KPI стали полосой в одном Frame, переключатели разделов — линейными вкладками. Старая оболочка (app-shell, nav-user, монитор, палитра) удалена: её заменил PortalShell. Тесты kit-раскладки обновлены под новый контракт.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 18:42:19 +07:00
DenozordecandClaude Sonnet 5.5 bf43f2846e feat(web): portal shell on shared mockup blocks
Боковое меню, шапка, монитор системы и палитра Ctrl+K переведены на общие блоки макета; данные берутся из живых запросов EvoBGP.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 18:34:42 +07:00
288 changed files with 23455 additions and 2843 deletions

No files matched your search

+71
View File
@@ -322,6 +322,66 @@ jobs:
go build -o "$out/$name" "./$d"
done
postgres-integration:
needs: [changes]
if: needs.changes.outputs.go == 'true'
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: evobgp
POSTGRES_PASSWORD: evobgp_ci
POSTGRES_DB: evobgp_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U evobgp -d evobgp_test"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: "1.24"
cache: false
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: go-hash
run: echo "key=$(sha256sum go.sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.GOMODCACHE }}
${{ env.GOCACHE }}
key: go-${{ runner.os }}-1.24-postgres-${{ steps.go-hash.outputs.key }}
restore-keys: |
go-${{ runner.os }}-1.24-postgres-
- name: Run migrations and verify PostgreSQL integration tests executed
env:
GOFLAGS: -mod=readonly
EVOBGP_TEST_DATABASE_URL: postgres://evobgp:[email protected]:5432/evobgp_test?sslmode=disable
run: bash scripts/ci/postgres-integration.sh
legacy-compatibility:
needs: [changes]
if: needs.changes.outputs.go == 'true'
runs-on: ubuntu-latest
strategy:
matrix:
legacy_version: ["2.56.2", "1.0.0"]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: "1.24"
cache: false
- name: Build pinned legacy executables and test current control plane
env:
EVOBGP_LEGACY_VERSION: ${{ matrix.legacy_version }}
run: bash scripts/ci/legacy-node-compatibility.sh
bird2:
runs-on: ubuntu-latest
needs: [changes, go]
@@ -335,6 +395,10 @@ jobs:
needs.changes.outputs.docker_go == 'true')
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: "1.24"
cache: false
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
run: |
set -euxo pipefail
@@ -358,6 +422,11 @@ jobs:
echo "==> $conf"
bird -c "$WS/$conf" -p
done
- name: Parse staged birddeploy generation with real BIRD
env:
GOFLAGS: -mod=readonly
EVOBGP_TEST_BIRD_BIN: bird
run: go test ./internal/birddeploy -run '^TestRealBirdParseGeneration$' -count=1 -v
commitlint:
if: inputs.is_pull_request
@@ -405,6 +474,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Offline mirror behavior tests
run: bash scripts/tests/mirror-base-images.test.sh
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
with:
name: evobgp
+3
View File
@@ -32,3 +32,6 @@ deploy/compose/runtime-logs/
.cursor/mcp.json
.mcp.json
.codegraph/daemon.pid
# Local backend resource snapshots (host metadata, not source artifacts)
artifacts/backend-baseline/
@@ -16,6 +16,7 @@ import {
speakerCapacityBars,
} from '@/lib/metrics'
import { runningJobCount } from '@/queries/overview'
import { peerSessionEstablished, speakerAgentOnline } from '@/lib/live-observation'
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
type CapacityMode = 'peers' | 'speakers'
@@ -50,8 +51,11 @@ export function DashboardNetworkCapacityCard({
const deltaLabel =
mode === 'peers'
? `${peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length} установлено`
: `${speakers.filter((s) => s.live?.agent_ok).length} в сети`
? `${peers.filter((p) => p.enabled !== false && peerSessionEstablished(p)).length} установлено`
: `${speakers.filter(speakerAgentOnline).length} в сети`
const allHealthy = mode === 'peers'
? peers.some((p) => p.enabled !== false) && peers.filter((p) => p.enabled !== false).every(peerSessionEstablished)
: speakers.length > 0 && speakers.every(speakerAgentOnline)
return (
<AnalyticsCardShell
@@ -73,7 +77,7 @@ export function DashboardNetworkCapacityCard({
<p className="text-3xl font-semibold tracking-tight tabular-nums">
{loading ? '—' : `${utilization}%`}
</p>
<p className="text-sm text-success">{loading ? '…' : `${deltaLabel} · снимок live`}</p>
<p className={`text-sm ${allHealthy ? 'text-success' : 'text-muted-foreground'}`}>{loading ? '…' : `${deltaLabel} · снимок live`}</p>
</div>
{loading ? (
@@ -13,6 +13,7 @@ import {
recentPlatformActivity,
} from '@/lib/metrics'
import { runningJobCount } from '@/queries/overview'
import { peerSessionCurrent, peerSessionEstablished, peerSessionKnown, speakerAgentOnline } from '@/lib/live-observation'
import type { JobRow, ModuleRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
export function DashboardPlatformCard({
@@ -35,15 +36,16 @@ export function DashboardPlatformCard({
const enabledModules = modules.filter((m) => m.enabled !== false).length
const peersEnabled = peers.filter((p) => p.enabled !== false).length
const peersEstablished = peers.filter(
(p) => p.enabled !== false && p.session_state === 'Established',
(p) => p.enabled !== false && peerSessionEstablished(p),
).length
const peersMismatch = peers.filter((p) => p.session_mismatch).length
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
const peersUnknown = peersEnabled - peers.filter((p) => p.enabled !== false && peerSessionKnown(p)).length
const peersMismatch = peers.filter((p) => p.session_mismatch && peerSessionCurrent(p)).length
const speakersOnline = speakers.filter(speakerAgentOnline).length
const failedJobs = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const running = runningJobCount(jobs)
const riskCount = peersMismatch + failedJobs + Math.max(0, speakers.length - speakersOnline)
const riskCount = peersMismatch + peersUnknown + failedJobs + Math.max(0, speakers.length - speakersOnline)
const bgpPct =
peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null
@@ -69,7 +71,7 @@ export function DashboardPlatformCard({
label: 'BGP готовность',
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
delta: {
direction: (bgpPct !== null && bgpPct >= 90 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as
direction: (bgpPct !== null && bgpPct >= 90 && peersUnknown === 0 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as
| 'up'
| 'down'
| 'neutral',
@@ -77,7 +79,7 @@ export function DashboardPlatformCard({
bgpPct === null
? 'нет включённых пиров'
: `${peersEstablished} установлено`,
tone: (bgpPct !== null && bgpPct >= 90
tone: (bgpPct !== null && bgpPct >= 90 && peersUnknown === 0
? 'success'
: bgpPct !== null && bgpPct < 70
? 'warning'
@@ -89,7 +91,7 @@ export function DashboardPlatformCard({
value: loading ? '—' : String(riskCount),
delta: {
direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down',
label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} расхождений` : 'в норме',
label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} расхождений, ${peersUnknown} без наблюдения` : 'в норме',
tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success',
},
},
@@ -16,7 +16,7 @@ export function NetworkOverviewAnalyticsCard({
const net = aggregateNetworkMetrics(peers, speakers)
const slices = peerSessionBreakdown(peers)
const speakersPct =
net.speakersTotal > 0 ? Math.round((net.speakersOnline / net.speakersTotal) * 100) : 0
net.speakersObserved > 0 ? Math.round((net.speakersOnline / net.speakersObserved) * 100) : 0
if (loading) {
return (
@@ -36,22 +36,25 @@ export function NetworkOverviewAnalyticsCard({
description="Распределение состояний пиров"
slices={slices}
centerLabel="Пиры"
badge={net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'в норме'}
badge={net.peersMismatch > 0 ? `${net.peersMismatch} расхождений`
: net.peersObserved < net.peersEnabled ? 'есть пиры без наблюдения'
: net.peersEnabled === 0 ? 'нет включённых пиров'
: net.peersEstablished === net.peersObserved ? 'в норме' : 'есть неустановленные сессии'}
/>
<SegmentedProgressCard
title="Спикеры"
description="Доступность агентов"
primary={{
value: `${net.speakersOnline}/${net.speakersTotal}`,
value: `${net.speakersOnline}/${net.speakersObserved}`,
label: 'В сети',
percent: speakersPct,
}}
secondary={{
value: net.speakersTotal - net.speakersOnline,
value: net.speakersObserved - net.speakersOnline,
label: 'Не в сети',
percent: 100 - speakersPct,
percent: net.speakersObserved > 0 ? 100 - speakersPct : 0,
}}
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersObserved} · без наблюдения: ${net.peersEnabled - net.peersObserved} пиров, ${net.speakersTotal - net.speakersObserved} спикеров`}
/>
</div>
)
@@ -35,6 +35,7 @@ function buildKpis({
const enabledModules = modules.filter((m) => m.enabled !== false).length
const network = aggregateNetworkMetrics(peers, speakers)
const peersEnabled = network.peersEnabled
const peersUnknown = Math.max(0, peersEnabled - network.peersObserved)
const bgpPct =
peersEnabled > 0 ? Math.round((network.peersEstablished / peersEnabled) * 100) : null
const running = runningJobCount(jobs)
@@ -42,7 +43,7 @@ function buildKpis({
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
const riskCount = network.peersMismatch + peersUnknown + failedJobs + offlineSpeakers
const disabledModules = Math.max(0, modules.length - enabledModules)
return [
@@ -84,7 +85,7 @@ function buildKpis({
footer: (
<Badge
variant={
bgpPct !== null && bgpPct >= 90
bgpPct !== null && bgpPct >= 90 && peersUnknown === 0
? 'success-light'
: bgpPct !== null && bgpPct < 70
? 'warning-light'
@@ -94,9 +95,9 @@ function buildKpis({
>
{loading || bgpPct === null
? 'нет включённых пиров'
: bgpPct >= 90
: bgpPct >= 90 && peersUnknown === 0
? 'сессии в норме'
: `${network.peersEstablished} установлено`}
: peersUnknown > 0 ? `${peersUnknown} без наблюдения` : `${network.peersEstablished} установлено`}
</Badge>
),
},
@@ -124,7 +125,7 @@ function buildKpis({
{loading
? '…'
: riskCount > 0
? `${failedJobs} задач · ${network.peersMismatch} расхождений`
? `${failedJobs} задач · ${network.peersMismatch} расхождений · ${peersUnknown} без наблюдения`
: 'в норме'}
</Badge>
),
@@ -9,6 +9,7 @@ import {
speakerCapacityBars,
} from '@/lib/metrics'
import { runningJobCount } from '@/queries/overview'
import { peerSessionEstablished, peerSessionKnown, speakerAgentKnown, speakerAgentOnline } from '@/lib/live-observation'
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
type CapacityMode = 'peers' | 'speakers'
@@ -35,11 +36,14 @@ export function DashboardNetworkHealth({
const established =
mode === 'peers'
? peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length
: speakers.filter((s) => s.live?.agent_ok).length
? peers.filter((p) => p.enabled !== false && peerSessionEstablished(p)).length
: speakers.filter(speakerAgentOnline).length
const total =
mode === 'peers' ? peers.filter((p) => p.enabled !== false).length : speakers.length
mode === 'peers' ? peers.filter((p) => p.enabled !== false && peerSessionKnown(p)).length : speakers.filter(speakerAgentKnown).length
const offline = Math.max(0, total - established)
const unknown = mode === 'peers'
? peers.filter((p) => p.enabled !== false).length - total
: speakers.length - total
return (
<SegmentedProgressCard
@@ -64,7 +68,7 @@ export function DashboardNetworkHealth({
variant={
loading || total === 0
? 'outline'
: offline === 0
: offline === 0 && unknown === 0
? 'success-light'
: 'warning-light'
}
@@ -75,10 +79,12 @@ export function DashboardNetworkHealth({
? '…'
: total === 0
? 'нет данных'
: offline === 0
: offline === 0 && unknown === 0
? mode === 'peers'
? 'все установлены'
: 'все в сети'
: unknown > 0
? `${unknown} без наблюдения`
: mode === 'peers'
? `${offline} не установлены`
: `${offline} офлайн`}
@@ -93,7 +99,7 @@ export function DashboardNetworkHealth({
footer={
loading
? 'Загрузка…'
: `Активных задач: ${queued} · сегментов в графике: ${bars.length}`
: `Активных задач: ${queued} · сегментов в графике: ${bars.length} · без наблюдения: ${unknown}`
}
/>
)
@@ -28,8 +28,10 @@ export function DashboardNetworkPanel({
const m = aggregateNetworkMetrics(peers, speakers)
return (
<div className="flex flex-col gap-2 p-3">
<Row label="Пиры с установленной сессией" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
<Row label="Спикеры в сети" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
<Row label="Пиры с установленной сессией" value={`${m.peersEstablished} / ${m.peersObserved}`} />
<Row label="Пиры без наблюдения" value={String(m.peersEnabled - m.peersObserved)} />
<Row label="Спикеры в сети" value={`${m.speakersOnline} / ${m.speakersObserved}`} />
<Row label="Спикеры без наблюдения" value={String(m.speakersTotal - m.speakersObserved)} />
{m.peersMismatch > 0 ? (
<Row label="Расхождения сессий" value={String(m.peersMismatch)} variant="warning" />
) : null}
@@ -0,0 +1,55 @@
import { useMemo } from 'react'
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@evobgp/ui/components/chart'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { fmtWhen } from '@/components/portal/format'
import type { RevisionRow } from '@/types/api'
const chartConfig = {
prefixes: { label: 'Префиксов', color: 'var(--chart-1)' },
} satisfies ChartConfig
/** Префиксов в опубликованной конфигурации по последним ревизиям (доска «Панель»). */
export function DashboardPrefixTrend({ revisions, loading }: { revisions: RevisionRow[]; loading?: boolean }) {
const data = useMemo(
() =>
[...revisions]
.sort((a, b) => a.created_at.localeCompare(b.created_at))
.slice(-12)
.map((r) => ({ id: r.id, when: fmtWhen(r.created_at), prefixes: r.materialized_prefix_count })),
[revisions],
)
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-col items-start gap-0.5">
<FrameTitle>Префиксов в опубликованной конфигурации</FrameTitle>
<FrameDescription className="text-pretty">Последние {data.length || 12} ревизий</FrameDescription>
</FrameHeader>
<FramePanel className="p-3">
{data.length < 2 ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
{loading ? 'Загрузка…' : 'Недостаточно ревизий для графика'}
</div>
) : (
<ChartContainer config={chartConfig} className="aspect-auto h-64 w-full">
<AreaChart data={data} margin={{ left: 4, right: 12, top: 8 }}>
<defs>
<linearGradient id="prefix-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-prefixes)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--color-prefixes)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="id" tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} />
<YAxis domain={([min, max]) => [Math.floor(min * 0.98), Math.ceil(max * 1.01)]} tickLine={false} axisLine={false} width={48} tickFormatter={(v: number) => v.toLocaleString('ru-RU')} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={(_, p) => p?.[0]?.payload?.when ?? ''} />} />
<Area dataKey="prefixes" type="monotone" stroke="var(--color-prefixes)" strokeWidth={2} fill="url(#prefix-fill)" />
</AreaChart>
</ChartContainer>
)}
</FramePanel>
</Frame>
)
}
@@ -1,233 +0,0 @@
import {
LayoutDashboard,
Boxes,
Network,
Cog,
ListChecks,
Activity,
Settings,
BookText,
KeyRound,
Search,
} from 'lucide-react'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@evobgp/ui/components/sidebar'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@evobgp/ui/components/breadcrumb'
import { Separator } from '@evobgp/ui/components/separator'
import { TooltipProvider } from '@evobgp/ui/components/tooltip'
import { Link, useRouterState } from '@tanstack/react-router'
import type { ComponentType, CSSProperties, ReactNode } from 'react'
import { AppSwitcher } from '@/components/app-switcher'
import { AppsMenu } from '@/components/layout/apps-menu'
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
import { NavUser } from '@/components/layout/nav-user'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
interface NavItem {
to: string
label: string
icon: ComponentType<{ className?: string }>
description?: string
search?: Record<string, string>
/** Explicit permission override; when omitted derived from `to`. */
permission?: string
}
interface NavGroup {
label: string
items: NavItem[]
}
const NAV_GROUPS: NavGroup[] = [
{
label: 'Обзор',
items: [
{
to: '/dashboard',
label: 'Панель',
icon: LayoutDashboard,
description: 'Метрики, модули и активность',
},
],
},
{
label: 'Маршрутизация',
items: [
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'BGP community и DoH' },
],
},
{
label: 'Операции',
items: [
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD' },
],
},
{
label: 'Система',
items: [
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
],
},
]
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
/** Nav visible for the current claim (or all items when portal auth is off). */
function useVisibleNavGroups(): NavGroup[] {
if (!isAuthEnabled()) return NAV_GROUPS
return NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => {
const perm = item.permission ?? permissionForPath(item.to)
return !perm || can(perm)
}),
})).filter((group) => group.items.length > 0)
}
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
)
const PARENT_ROUTE: Record<string, string> = {
'/modules/new': '/modules',
}
const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
id: item.to,
label: item.label,
description: item.description,
to: item.to,
search: item.search,
keywords: [item.to.replace(/^\//, '')],
}))
/**
* Shared ops chrome etalon for CFDM / vps-tracker.
* @see https://reui.io/preview/base/app-shell-12
* @see docs/ui-design-contract.md — Shared App Shell chrome
*/
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const visibleGroups = useVisibleNavGroups()
const activeItem =
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
ALL_NAV_ITEMS[0]
const parentTo = PARENT_ROUTE[pathname] ?? PARENT_ROUTE[activeItem.to]
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
const pageTitle =
pathname.startsWith('/modules/') && pathname !== '/modules/new'
? 'Модуль'
: (ROUTE_LABELS[activeItem.to] ?? activeItem.label)
return (
<TooltipProvider delay={0}>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
{visibleGroups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} search={item.search} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
<SidebarInset id="main-content">
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
{parentLabel && parentTo ? (
<>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
</>
) : null}
<BreadcrumbItem>
<BreadcrumbPage>{pageTitle}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
</div>
</header>
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</div>
</SidebarInset>
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
</SidebarProvider>
</TooltipProvider>
)
}
@@ -1,104 +0,0 @@
import { Link } from '@tanstack/react-router'
import { LayoutGridIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */
export function AppsMenu() {
const { config, isLoading } = useAppSwitcherConfig()
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={8}
className="w-72"
>
<DropdownMenuGroup>
<DropdownMenuLabel>
{isLoading ? 'Загрузка…' : config.menuLabel}
</DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1">
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon]
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem
key={app.id}
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator />
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={authPortalUrl()} />}
className="justify-center text-sm font-medium"
>
Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'ui' }} />}
className="justify-center text-sm font-medium"
>
Настройки
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -1,147 +0,0 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Search } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Kbd } from '@evobgp/ui/components/kbd'
import {
SidebarGroup,
SidebarGroupContent,
} from '@evobgp/ui/components/sidebar'
export type CommandPaletteItem = {
id: string
label: string
description?: string
to: string
search?: Record<string, string>
keywords?: string[]
}
interface CommandPaletteProps {
items: CommandPaletteItem[]
className?: string
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
hotkeyOnly?: boolean
}
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputId = useId()
const navigate = useNavigate()
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setOpen(true)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return items
return items.filter((item) => {
const haystack = [item.label, item.description, ...(item.keywords ?? [])]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(q)
})
}, [items, query])
function go(item: CommandPaletteItem) {
setOpen(false)
setQuery('')
void navigate({ to: item.to, search: item.search })
}
return (
<>
{!hotkeyOnly ? (
<SidebarGroup className="p-0">
<SidebarGroupContent className="relative">
<Button
type="button"
variant="outline"
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
onClick={() => setOpen(true)}
>
Поиск…
</Button>
<Search
aria-hidden
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
/>
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
⌘K
</Kbd>
</SidebarGroupContent>
</SidebarGroup>
) : null}
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setQuery('')
}}
>
<DialogHeader className="sr-only">
<DialogTitle>Быстрый переход</DialogTitle>
<DialogDescription>Навигация по разделам EvoBGP</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-md gap-0 px-0 py-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3">
<div className="flex items-center gap-3 border-b px-4 py-3">
<Search aria-hidden className="size-4 shrink-0 opacity-60" />
<Input
id={searchInputId}
className="h-9 border-none p-0 shadow-none outline-none focus-visible:ring-0"
autoFocus
placeholder="Раздел или действие…"
aria-label="Поиск разделов"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered[0]) go(filtered[0])
}}
/>
</div>
<ul className="max-h-72 overflow-y-auto p-2" role="listbox">
{filtered.length === 0 ? (
<li className="text-muted-foreground px-2 py-6 text-center text-sm">Ничего не найдено</li>
) : (
filtered.map((item) => (
<li key={item.id}>
<button
type="button"
role="option"
className="hover:bg-accent flex w-full flex-col items-start gap-0.5 rounded-md px-3 py-2 text-left text-sm transition-colors"
onClick={() => go(item)}
>
<span className="font-medium">{item.label}</span>
{item.description ? (
<span className="text-muted-foreground text-xs">{item.description}</span>
) : null}
</button>
</li>
))
)}
</ul>
</DialogContent>
</Dialog>
</>
)
}
-229
View File
@@ -1,229 +0,0 @@
import { Link } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
import {
ChevronsUpDownIcon,
ExternalLinkIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
SettingsIcon,
SunIcon,
} from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@evobgp/ui/components/sidebar'
import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import {
authPortalUrl,
clearPortalToken,
getClaims,
isAuthEnabled,
redirectToPortalLogout,
resetPortalHandoff,
} from '@/lib/auth'
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
const THEMES = [
{
value: 'light',
label: 'Светлая',
icon: <SunIcon className="size-3.5" aria-hidden />,
},
{
value: 'dark',
label: 'Тёмная',
icon: <MoonIcon className="size-3.5" aria-hidden />,
},
{
value: 'system',
label: 'Системная',
icon: <MonitorIcon className="size-3.5" aria-hidden />,
},
] as const
function ThemeSegmentedToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const currentTheme = mounted ? (theme ?? 'system') : 'system'
return (
<div
role="radiogroup"
aria-label="Тема"
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{THEMES.map(({ value, label, icon }) => {
const isActive = currentTheme === value
return (
<Button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={label}
variant="ghost"
size="icon-xs"
onClick={() => setTheme(value)}
className={cn(
'rounded-full',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{icon}
</Button>
)
})}
</div>
)
}
function initials(source: string): string {
const base = source.trim()
if (!base) return '?'
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const { isMobile } = useSidebar()
const authOn = isAuthEnabled()
const claims = getClaims()
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
const email =
claims?.email?.trim() || (authOn ? '' : 'локальный API-токен')
const fallback = initials(name || email)
function handleSignOut() {
if (authOn) {
clearPortalToken()
resetPortalHandoff()
redirectToPortalLogout()
return
}
setApiToken(null)
if (typeof window !== 'undefined') {
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
window.location.assign('/settings?tab=ui&reason=token-required')
}
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'ui' }} />}
>
<SettingsIcon aria-hidden />
Настройки
</DropdownMenuItem>
{authOn ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={authPortalUrl()} />}
>
<ExternalLinkIcon aria-hidden />
Открыть Auth Portal
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleSignOut}>
<LogOutIcon aria-hidden />
{authOn ? 'Выйти' : 'Сбросить токен'}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -0,0 +1,243 @@
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
ActivityIcon,
BookTextIcon,
BoxesIcon,
CogIcon,
KeyRoundIcon,
LayoutDashboardIcon,
ListChecksIcon,
NetworkIcon,
PlusIcon,
SearchIcon,
SettingsIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome'
import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context'
import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store'
import { can, getClaims, isAuthEnabled, permissionForPath, redirectToPortalLogout, clearPortalToken, resetPortalHandoff } from '@/lib/auth'
import { useLiveObservationState } from '@/hooks/use-live-observation-state'
import { peerSessionEstablished, peerSessionKnown, peersForDisplay, snapshotUnavailable } from '@/lib/live-observation'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
import { monitoringHealthQueryOptions } from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network'
import {
aggregateNetworkMetrics,
overviewJobsQueryOptions,
overviewModulesQueryOptions,
overviewPeersQueryOptions,
} from '@/queries/overview'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { SidebarInset, SidebarProvider } from '@evobgp/ui/components/sidebar'
import { TooltipProvider } from '@evobgp/ui/components/tooltip'
const FRESH: Record<string, { badge: string; text: string }> = {
'/dashboard': { badge: 'Live', text: 'GET /v1/modules · /v1/peers · /v1/jobs · данные обновляются при открытии страницы' },
'/modules': { badge: 'Каталог', text: 'GET /v1/modules · списки префиксов и AS' },
'/lookup': { badge: 'Проверка', text: 'GET /v1/lookup · IP или домен по спискам и community' },
'/network': { badge: 'BGP', text: 'GET /v1/peers · /v1/speakers · сохранённые снимки состояния сессий' },
'/directories': { badge: 'Справочники', text: 'BGP community и DoH-профили' },
'/operations': { badge: 'Ревизии', text: 'GET /v1/revisions · /v1/jobs · применение и откат' },
'/schedule': { badge: 'Задачи', text: 'Расписание обновления модулей' },
'/monitoring': { badge: 'Live', text: 'GET /health · /ready · BIRD через birdc' },
'/access': { badge: 'API', text: 'API-ключи и роли доступа' },
'/settings': { badge: 'Настройки', text: 'Интерфейс и BIRD' },
}
function freshFor(pathname: string) {
const key = Object.keys(FRESH)
.sort((a, b) => b.length - a.length)
.find((k) => pathname === k || pathname.startsWith(`${k}/`))
return FRESH[key ?? '/dashboard']!
}
function initialsOf(name: string) {
const parts = name.trim().split(/[\s@._-]+/).filter(Boolean)
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase()
}
const NAV: Array<Omit<NavItem, 'problem'>> = [
{ id: 'dashboard', label: 'Панель', to: '/dashboard', icon: LayoutDashboardIcon, group: 'Обзор' },
{ id: 'modules', label: 'Модули', to: '/modules', icon: BoxesIcon, group: 'Маршрутизация' },
{ id: 'lookup', label: 'Проверка', to: '/lookup', icon: SearchIcon, group: 'Маршрутизация' },
{ id: 'network', label: 'Сеть', to: '/network', icon: NetworkIcon, group: 'Маршрутизация' },
{ id: 'directories', label: 'Справочники', to: '/directories', icon: BookTextIcon, group: 'Маршрутизация' },
{ id: 'operations', label: 'Операции', to: '/operations', icon: CogIcon, group: 'Операции' },
{ id: 'schedule', label: 'Задачи', to: '/schedule', icon: ListChecksIcon, group: 'Операции' },
{ id: 'monitoring', label: 'Мониторинг', to: '/monitoring', icon: ActivityIcon, group: 'Операции' },
{ id: 'access', label: 'Доступ', to: '/access', icon: KeyRoundIcon, group: 'Система' },
{ id: 'settings', label: 'Настройки', to: '/settings', icon: SettingsIcon, group: 'Система' },
]
function logout() {
if (isAuthEnabled()) {
clearPortalToken()
resetPortalHandoff()
redirectToPortalLogout()
}
}
/** Меню и данные оболочки EvoBGP: пункты по правам, точки проблем и монитор из живых запросов. */
function useChromeData(): ChromeConfig {
const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 })
const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 })
const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 })
const modulesQ = useQuery(overviewModulesQueryOptions())
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const { offline, disconnectedAt, revision } = useLiveObservationState(peersQ.data?.items ?? [], [])
return useMemo<ChromeConfig>(() => {
const peersUnavailable = snapshotUnavailable(peersQ.isError, offline, disconnectedAt, peersQ.dataUpdatedAt)
const healthUnavailable = snapshotUnavailable(healthQ.isError, offline, disconnectedAt, healthQ.dataUpdatedAt)
const birdUnavailable = snapshotUnavailable(birdQ.isError, offline, disconnectedAt, birdQ.dataUpdatedAt)
const peers = peersForDisplay(peersQ.data?.items ?? [], peersUnavailable)
const jobs = jobsQ.data?.items ?? []
const modules = modulesQ.data?.items ?? []
const net = aggregateNetworkMetrics(peers, [])
const failedJobs = jobs.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase())).length
const peersDown = Math.max(0, net.peersObserved - net.peersEstablished)
const peersUnknown = Math.max(0, net.peersEnabled - net.peersObserved)
const healthOk = !healthUnavailable && (healthQ.data?.ok ?? false)
const bird = birdUnavailable ? undefined : birdQ.data
const birdRatio =
bird && bird.bgp_sessions_total > 0
? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100)
: null
const bgpPct = net.peersEnabled > 0 ? Math.round((net.peersEstablished / net.peersEnabled) * 100) : 0
const visible = NAV.filter((item) => {
if (!isAuthEnabled()) return true
const perm = permissionForPath(item.to)
return !perm || can(perm)
})
const problems: Record<string, NavItem['problem']> = {
network: peersUnavailable ? { tone: 'wn', label: 'Снимок сети недоступен' } : peersDown > 0 ? { tone: 'er', label: `${peersDown} пиров не в Established` } : peersUnknown > 0 ? { tone: 'wn', label: `${peersUnknown} пиров без наблюдения` } : undefined,
schedule: failedJobs > 0 ? { tone: 'wn', label: `Ошибок задач: ${failedJobs}` } : undefined,
monitoring: healthQ.isSuccess && !healthOk ? { tone: 'er', label: 'API не отвечает как норма' } : undefined,
}
const navItems: NavItem[] = visible.map((i) => ({ ...i, problem: problems[i.id] }))
const groups = [...new Set(navItems.map((i) => i.group))]
const monitorItems: MonitorItem[] = []
if (healthQ.isSuccess && !healthOk) {
monitorItems.push({ id: 'health', tone: 'er', title: 'API EvoBGP не в норме', text: 'GET /health вернул ошибку', age: 'сейчас', to: '/monitoring' })
}
if (peersDown > 0) {
monitorItems.push({ id: 'peers', tone: 'er', title: `Пиров не в Established: ${peersDown}`, text: `Наблюдается ${net.peersObserved}, поднято ${net.peersEstablished}`, age: 'сейчас', to: '/network' })
}
if (peersUnknown > 0) {
monitorItems.push({ id: 'peers-unknown', tone: 'wn', title: `Пиров без наблюдения: ${peersUnknown}`, text: 'Состояние сессий пока неизвестно', age: 'сейчас', to: '/network' })
}
if (failedJobs > 0) {
monitorItems.push({ id: 'jobs', tone: 'wn', title: `Ошибок задач: ${failedJobs}`, text: 'Проверьте расписание и журнал ревизий', age: 'недавно', to: '/schedule' })
}
if (monitorItems.length === 0) {
if (healthUnavailable || peersUnavailable || birdUnavailable) {
monitorItems.push({ id: 'unavailable', tone: 'wn', title: 'Данные мониторинга недоступны', text: 'Проверьте соединение и повторите запрос', age: 'сейчас', to: '/monitoring' })
} else {
monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, BGP-сессии и задачи без проблем', age: 'сейчас', to: '/monitoring' })
}
}
const metrics: MonitorMetric[] = [
{ l: 'Состояние API', v: healthOk ? 'норма' : '—', p: healthOk ? 100 : 0, tone: healthOk ? 'success' : 'warning' },
{ l: 'BGP пиры', v: net.peersObserved === 0 && net.peersEnabled > 0 ? '—' : `${bgpPct}%`, p: bgpPct, tone: peersUnknown === 0 && bgpPct >= 90 ? 'success' : 'warning' },
{ l: 'BIRD сессии', v: birdRatio === null ? '—' : `${birdRatio}%`, p: birdRatio ?? 0, tone: birdRatio === 100 ? 'success' : 'warning' },
{ l: 'Ошибки задач', v: String(failedJobs), p: Math.min(100, failedJobs * 10), tone: failedJobs > 0 ? 'warning' : 'success' },
]
const claims = getClaims()
const name = claims?.name?.trim() || (isAuthEnabled() ? 'Пользователь' : 'Гость')
const email = claims?.email?.trim() || (isAuthEnabled() ? '' : 'локальный API-токен')
const objects: PaletteObject[] = [
...modules.map<PaletteObject>((m) => ({
id: `module-${m.id}`,
group: 'Модули',
label: m.name,
hint: `${m.type}${m.enabled ? '' : ' · выключен'}`,
dot: m.enabled ? 'ok' : 'mu',
to: `/modules/${m.id}`,
})),
...peers.map<PaletteObject>((p) => ({
id: `peer-${p.id}`,
group: 'BGP-пиры',
label: p.name ?? p.neighbor,
hint: `${p.neighbor}${p.remote_asn ? ` · AS${p.remote_asn}` : ''}`,
dot: !peerSessionKnown(p) ? 'mu' : peerSessionEstablished(p) ? 'ok' : 'er',
to: '/network',
})),
]
return {
navItems,
navGroups: groups,
brand: <AppSwitcher />,
user: { name, email, initials: initialsOf(name || email) },
securityTo: '/settings',
onLogout: logout,
fresh: freshFor,
monitor: { items: monitorItems, metrics, updated: healthUnavailable || peersUnavailable || birdUnavailable ? 'данные недоступны' : 'обновлено сейчас', footerTo: '/monitoring', footerLabel: 'Мониторинг' },
palette: {
actions: [
{ id: 'act-module', label: 'Создать модуль', hint: 'M', icon: PlusIcon, to: '/modules/new' },
{ id: 'act-lookup', label: 'Проверить IP или домен', hint: 'L', icon: SearchIcon, to: '/lookup' },
{ id: 'act-ops', label: 'Открыть ревизии', hint: 'R', icon: CogIcon, to: '/operations' },
],
objects,
assistantTo: '/lookup',
example: 'Подсказка: «пиры не в Established»',
},
}
// Recompute cached metrics when the server-provided observation deadline passes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [healthQ.data, healthQ.isSuccess, healthQ.isError, healthQ.dataUpdatedAt, peersQ.data, peersQ.isError, peersQ.dataUpdatedAt, jobsQ.data, modulesQ.data, birdQ.data, birdQ.isError, birdQ.dataUpdatedAt, offline, disconnectedAt, revision])
}
/** Оболочка EvoBGP: боковое меню, шапка, монитор, палитра ⌘K — блоки общего макета. */
export function PortalShell({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(true)
const [mounted, setMounted] = useState(false)
useEffect(() => {
// До 1100px меню сворачивается в колонку иконок (на телефоне его заменяет Sheet).
const mq = window.matchMedia('(min-width: 1100px)')
const sync = () => setOpen(mq.matches)
sync()
mq.addEventListener('change', sync)
setMounted(true)
return () => mq.removeEventListener('change', sync)
}, [])
const config = useChromeData()
if (!mounted) {
return (
<div className="flex min-h-svh flex-col gap-4 p-6" aria-busy>
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
return (
<PortalUiProvider>
<ChromeConfigProvider value={config}>
<TooltipProvider delay={0}>
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<PortalSidebar />
<SidebarInset id="main-content" className="min-w-0">
<PortalHeader />
<div className="flex w-full min-w-0 flex-1 flex-col gap-5 p-4 md:p-6">{children}</div>
</SidebarInset>
<CommandPalette />
</SidebarProvider>
</TooltipProvider>
</ChromeConfigProvider>
</PortalUiProvider>
)
}
@@ -1,213 +0,0 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Activity, Bird, HeartPulse, ListChecks, Network } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@evobgp/ui/components/popover'
import { Progress } from '@evobgp/ui/components/progress'
import { monitoringHealthQueryOptions } from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network'
import { aggregateNetworkMetrics, overviewJobsQueryOptions, overviewPeersQueryOptions } from '@/queries/overview'
type MonitorMetric = {
id: string
label: string
value: string
unit: string
percent: number
icon: ReactNode
tone: 'success' | 'warning' | 'destructive' | 'info'
alert: boolean
}
function toneColor(tone: MonitorMetric['tone']) {
switch (tone) {
case 'success':
return 'var(--color-success)'
case 'warning':
return 'var(--color-warning)'
case 'destructive':
return 'var(--color-destructive)'
default:
return 'var(--color-info)'
}
}
function MetricCell({ metric }: { metric: MonitorMetric }) {
const color = toneColor(metric.tone)
return (
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<Item
className="flex size-5 shrink-0 items-center justify-center p-0"
style={{ backgroundColor: `${color}18` }}
>
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
{metric.icon}
</ItemMedia>
</Item>
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
{metric.value}
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
</span>
</div>
<Progress
value={metric.percent}
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
style={{ '--bar-color': color } as CSSProperties}
/>
</div>
)
}
/** Live system monitor popover (app-shell-7 pattern, EvoBGP API data). */
export function SystemMonitorPopover() {
const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 })
const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 })
const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const peers = peersQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const network = aggregateNetworkMetrics(peers, [])
const bgpPct =
network.peersEnabled > 0
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
: 0
const failedJobs = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const runningJobs = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const healthOk = healthQ.data?.ok ?? false
const birdRatio =
birdQ.data && birdQ.data.bgp_sessions_total > 0
? Math.round((birdQ.data.bgp_established / birdQ.data.bgp_sessions_total) * 100)
: null
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: 'api',
label: 'Состояние API',
value: healthOk ? 'норма' : '—',
unit: '',
percent: healthOk ? 100 : 0,
icon: <HeartPulse aria-hidden />,
tone: healthOk ? 'success' : 'destructive',
alert: !healthOk,
},
{
id: 'bgp',
label: 'BGP пиры',
value: String(bgpPct),
unit: '%',
percent: bgpPct,
icon: <Network aria-hidden />,
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'destructive',
alert: bgpPct < 70 && network.peersEnabled > 0,
},
{
id: 'bird',
label: 'BIRD сессии',
value: birdRatio !== null ? String(birdRatio) : '—',
unit: birdRatio !== null ? '%' : '',
percent: birdRatio ?? 0,
icon: <Bird aria-hidden />,
tone:
birdRatio === null
? 'info'
: birdRatio >= 100
? 'success'
: birdRatio >= 50
? 'warning'
: 'destructive',
alert: birdRatio !== null && birdRatio < 100,
},
{
id: 'jobs',
label: 'Ошибки задач',
value: String(failedJobs),
unit: 'шт.',
percent: Math.min(100, failedJobs * 10),
icon: <ListChecks aria-hidden />,
tone: failedJobs > 0 ? 'warning' : 'success',
alert: failedJobs > 0,
},
],
[bgpPct, birdRatio, failedJobs, healthOk, network.peersEnabled],
)
const spiking = metrics.some((m) => m.alert) || runningJobs > 5
return (
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label="Монитор системы"
className={cn(
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
)}
/>
}
>
<span className="relative flex size-3.5 items-center justify-center">
<Activity
aria-hidden
className={cn(
'size-3.5 transition-colors',
spiking ? 'text-destructive' : 'text-muted-foreground',
)}
/>
{spiking ? (
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
) : null}
</span>
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
<Badge
variant={spiking ? 'destructive-light' : 'success-light'}
size="xs"
className="h-4 px-1.5 text-[10px]"
>
{spiking ? 'Внимание' : 'Норма'}
</Badge>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')}
</span>
</div>
<div className="grid grid-cols-2">
{metrics.map((metric, i) => (
<div
key={metric.id}
className={cn(
i % 2 === 1 && 'border-border border-l',
i >= 2 && 'border-border border-t',
)}
>
<MetricCell metric={metric} />
</div>
))}
</div>
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
Активных задач: <span className="text-foreground font-medium tabular-nums">{runningJobs}</span>
{' · '}
Пиров в каталоге:{' '}
<span className="text-foreground font-medium tabular-nums">{network.peersTotal}</span>
</div>
</PopoverContent>
</Popover>
)
}
@@ -17,8 +17,8 @@ import type { ModuleRow } from '@/types/api'
const MODULE_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'enabled', label: 'Вкл' },
{ id: 'disabled', label: 'Выкл' },
{ id: 'enabled', label: 'Включённые' },
{ id: 'disabled', label: 'Выключенные' },
]
const TYPE_OPTIONS = [
@@ -66,7 +66,7 @@ function tabFilter(item: ModuleRow, tabId: string): boolean {
export function ModulesListGrid({
items,
isLoading = false,
title = 'Все модули',
title = 'Маршрутные модули',
actions,
}: {
items: ModuleRow[]
@@ -130,7 +130,7 @@ export function ModulesListGrid({
return (
<ResourcePage
title={title}
description="Маршрутные списки"
description="Поиск по имени · фильтр по типу · клик открывает модуль"
tabs={MODULE_TABS}
tabFilter={tabFilter}
filterFields={filterFields}
@@ -0,0 +1,39 @@
import { StatusBadge } from '@/components/status-badge'
import { observationHint, observationQuality, peerSessionCurrent, speakerAgentKnown } from '@/lib/live-observation'
import { bgpSessionStateRu, speakerOnlineLabel } from '@/lib/ui-labels'
import type { PeerRow, SpeakerRow } from '@/types/api'
export function PeerObservationBadge({ peer }: { peer: PeerRow }) {
const current = peerSessionCurrent(peer)
const state = current ? peer.session_state : null
const quality = peer.live_observation && observationQuality(peer.live_observation)
const lastKnown = peer.last_known_session_state
const hint = [
observationHint(peer.live_observation),
!current && lastKnown ? `последнее: ${bgpSessionStateRu(lastKnown)}` : undefined,
].filter(Boolean).join(' · ')
return (
<StatusBadge
status={current && state ? state : quality === 'stale' ? 'stale' : 'unknown'}
label={state ? bgpSessionStateRu(state) : quality === 'stale' ? 'Устарело' : 'Нет данных'}
hint={hint || undefined}
/>
)
}
export function AgentObservationBadge({ speaker }: { speaker: SpeakerRow }) {
const observation = speaker.live?.agent_observation
const quality = observation && observationQuality(observation)
const ok = speaker.live?.agent_ok
const reportedUnhealthy = observation?.error_code === 'reported_unhealthy' && ok === false && speakerAgentKnown(speaker)
const current = !observation || quality === 'ok' || reportedUnhealthy
const label = reportedUnhealthy
? 'Агент сообщил об ошибке'
: !current
? quality === 'stale' ? 'Устарело' : 'Нет данных'
: ok == null ? 'Нет данных' : speakerOnlineLabel(ok)
const status = !current
? quality === 'stale' ? 'stale' : 'unknown'
: ok == null ? 'unknown' : ok ? 'ok' : 'error'
return <StatusBadge status={status} label={label} hint={observationHint(observation)} />
}
@@ -21,6 +21,7 @@ export function NetworkKpi({
loading?: boolean
}) {
const net = aggregateNetworkMetrics(peers, speakers)
const peersHealthy = net.peersEnabled > 0 && net.peersObserved === net.peersEnabled && net.peersEstablished === net.peersObserved
const birdHealthy = bird?.healthy
const birdSessions =
bird != null ? `${bird.bgp_established} / ${bird.bgp_sessions_total}` : '—'
@@ -29,12 +30,12 @@ export function NetworkKpi({
{
id: 'peers-established',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
iconClassName: peersHealthy ? 'text-success' : 'text-warning',
label: 'Пиры установлены',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
value: loading ? '—' : `${net.peersEstablished}/${net.peersObserved}`,
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${net.peersTotal} в каталоге`}
<Badge variant={peersHealthy ? 'success-light' : 'warning-light'} size="sm">
{loading ? '…' : `${net.peersEnabled - net.peersObserved} без наблюдения`}
</Badge>
),
to: '/network',
@@ -45,17 +46,17 @@ export function NetworkKpi({
icon: <ServerCog aria-hidden />,
iconClassName: 'text-info',
label: 'Спикеры в сети',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
value: loading ? '—' : `${net.speakersOnline}/${net.speakersObserved}`,
footer: (
<Badge
variant={
net.speakersOnline === net.speakersTotal && net.speakersTotal > 0
net.speakersOnline === net.speakersObserved && net.speakersObserved > 0
? 'success-light'
: 'warning-light'
}
size="sm"
>
{loading ? '…' : 'в сети'}
{loading ? '…' : `${net.speakersTotal - net.speakersObserved} без наблюдения`}
</Badge>
),
to: '/network',
@@ -70,7 +71,7 @@ export function NetworkKpi({
variant: net.peersMismatch > 0 ? 'warning' : 'default',
footer: (
<Badge variant={net.peersMismatch > 0 ? 'warning-light' : 'outline'} size="sm">
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : 'в норме'}
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : net.peersObserved === 0 && net.peersEnabled > 0 ? 'нет наблюдения' : 'в норме'}
</Badge>
),
to: '/network',
@@ -2,10 +2,10 @@ import { Share2 } from 'lucide-react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { PeerObservationBadge } from '@/components/network/live-observation-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import type { DataGridColumnDef } from '@/components/reui-kit'
import { bgpSessionStateRu } from '@/lib/ui-labels'
import { peerSessionEstablished, peerSessionKnown } from '@/lib/live-observation'
import type { PeerRow } from '@/types/api'
export const peerColumns: DataGridColumnDef<PeerRow>[] = [
@@ -41,10 +41,7 @@ export const peerColumns: DataGridColumnDef<PeerRow>[] = [
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<StatusBadge
status={row.original.session_state ?? '—'}
label={bgpSessionStateRu(row.original.session_state)}
/>
<PeerObservationBadge peer={row.original} />
{row.original.session_mismatch ? (
<CategoryBadge tone="warning">расхождение</CategoryBadge>
) : null}
@@ -61,7 +58,7 @@ export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
case 'neighbor':
return item.neighbor
case 'session_state':
return item.session_state
return peerSessionKnown(item) ? item.session_state : undefined
default:
return undefined
}
@@ -70,7 +67,7 @@ export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
export function peerTabFilter(item: PeerRow, tabId: string): boolean {
if (tabId === 'disabled') return item.enabled === false
const enabled = item.enabled !== false
if (tabId === 'established') return enabled && item.session_state === 'Established'
if (tabId === 'pending') return enabled && item.session_state !== 'Established'
if (tabId === 'established') return enabled && peerSessionEstablished(item)
if (tabId === 'pending') return enabled && peerSessionKnown(item) && !peerSessionEstablished(item)
return true
}
@@ -2,11 +2,11 @@ import { ServerCog } from 'lucide-react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { AgentObservationBadge } from '@/components/network/live-observation-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import type { DataGridColumnDef } from '@/components/reui-kit'
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
import { observationHint, speakerAgentKnown, speakerAgentOnline, speakerBgpCounts } from '@/lib/live-observation'
import { speakerRoleRu } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api'
export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
@@ -32,20 +32,7 @@ export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
id: 'agent',
enableSorting: false,
header: 'Агент',
cell: ({ row }) => {
const live = row.original.live
if (live?.agent_ok === true) {
return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
}
if (live?.agent_ok === false) {
return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
}
return (
<Badge variant="outline" size="sm" radius="full">
—
</Badge>
)
},
cell: ({ row }) => <AgentObservationBadge speaker={row.original} />,
meta: { headerTitle: 'Агент' },
},
{
@@ -53,12 +40,16 @@ export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
enableSorting: false,
header: 'BGP',
cell: ({ row }) => {
const live = row.original.live
if (!live) return '—'
const count = speakerBgpCounts(row.original)
return (
<DataGridMonoCell className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</DataGridMonoCell>
<div className="flex flex-col gap-0.5">
<DataGridMonoCell className="text-xs">{count ?? '—'}</DataGridMonoCell>
{row.original.live?.bgp_observation ? (
<span className="text-xs text-muted-foreground">
{observationHint(row.original.live.bgp_observation)}
</span>
) : null}
</div>
)
},
meta: { headerTitle: 'BGP' },
@@ -77,7 +68,7 @@ export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unk
}
export function speakerTabFilter(item: SpeakerRow, tabId: string): boolean {
if (tabId === 'online') return item.live?.agent_ok === true
if (tabId === 'offline') return item.live?.agent_ok !== true
if (tabId === 'online') return speakerAgentOnline(item)
if (tabId === 'offline') return speakerAgentKnown(item) && !speakerAgentOnline(item)
return true
}
@@ -8,6 +8,7 @@ import {
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { Badge } from '@/components/reui/badge'
import { jobStatusRu, jobTriggerRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
@@ -30,11 +31,46 @@ function resultSubtitle(job: JobRow): string | undefined {
return undefined
}
function applyCounts(job: JobRow) {
const value = job.meta?.apply_summary
if (!value || typeof value !== 'object') return undefined
const summary = value as Record<string, unknown>
const count = (key: string) => typeof summary[key] === 'number' ? summary[key] as number : undefined
return {
published: count('published_count'),
applied: count('applied_count'),
unconfirmed: count('unconfirmed_count'),
failed: count('failed_count'),
}
}
function dispatchConfirmations(job: JobRow): string[] {
const dispatch = job.meta?.node_dispatch
if (!dispatch || typeof dispatch !== 'object') return []
const results = (dispatch as Record<string, unknown>).results
if (!Array.isArray(results)) return []
return results.flatMap((item) => {
if (!item || typeof item !== 'object') return []
const result = item as Record<string, unknown>
const speaker = typeof result.speaker_id === 'string' ? result.speaker_id.slice(0, 8) : 'Спикер'
const label = result.confirmation === 'agent_reported'
? 'агент подтвердил'
: result.confirmation === 'local_applied'
? 'применено локально'
: result.confirmation === 'unconfirmed'
? 'без подтверждения агента'
: 'подтверждение не указано'
return [`${speaker}: ${label}`]
})
}
export function JobTimeline({ job }: { job: JobRow }) {
const trigger = jobTriggerRu(job.meta?.trigger)
const completed = job.finished_at ? 3 : job.started_at ? 2 : 1
const failed = ['failed', 'error'].includes(job.status.toLowerCase())
const subtitle = resultSubtitle(job)
const counts = applyCounts(job)
const confirmations = dispatchConfirmations(job)
return (
<div className="py-4 ps-3 pe-2">
@@ -65,7 +101,26 @@ export function JobTimeline({ job }: { job: JobRow }) {
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
{subtitle ? <TimelineContent>{subtitle}</TimelineContent> : null}
{subtitle || counts || confirmations.length > 0 ? (
<TimelineContent>
<div className="flex flex-col gap-2">
{subtitle ? <span>{subtitle}</span> : null}
{counts ? (
<div className="flex flex-wrap gap-2">
{counts.published != null ? <Badge variant="info-light" size="sm">Опубликовано: {counts.published}</Badge> : null}
{counts.applied != null ? <Badge variant="success-light" size="sm">Подтверждено: {counts.applied}</Badge> : null}
{counts.unconfirmed != null && counts.unconfirmed > 0 ? <Badge variant="warning-light" size="sm">Без подтверждения агента: {counts.unconfirmed}</Badge> : null}
{counts.failed != null && counts.failed > 0 ? <Badge variant="destructive-light" size="sm">Ошибок: {counts.failed}</Badge> : null}
</div>
) : null}
{confirmations.length > 0 ? (
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
{confirmations.map((confirmation, index) => <li key={`${index}-${confirmation}`}>{confirmation}</li>)}
</ul>
) : null}
</div>
</TimelineContent>
) : null}
</TimelineItem>
</Timeline>
</div>
+8 -11
View File
@@ -2,22 +2,19 @@ import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: string
description?: ReactNode
actions?: ReactNode
}
/** Заголовок страницы как на досках макета: название 20px, описание 14px muted, действия справа. */
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<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-muted-foreground max-w-prose text-sm break-words">{description}</p>
) : null}
<header className="flex min-w-0 flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
{description ? <p className="text-muted-foreground text-sm text-pretty">{description}</p> : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
</header>
)
}
+481
View File
@@ -0,0 +1,481 @@
import { useState, type ReactNode } from 'react'
import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@evobgp/ui/components/alert-dialog'
import { Button } from '@evobgp/ui/components/button'
import { cn } from '@evobgp/ui/lib/utils'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evobgp/ui/components/sheet'
import type { Tone } from './tone'
import { TONE_BADGE } from './tone'
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */
export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) {
const glyph = TONE_GLYPH[tone]
return (
<Badge variant={TONE_BADGE[tone]} size="sm" className="whitespace-nowrap">
{glyph ? <span aria-hidden className="text-[0.55rem]">{glyph}</span> : null}
{children}
</Badge>
)
}
/** Заголовок страницы: название, описание, метка свежести и действия. */
export function PageHead({
title,
sub,
actions,
}: {
title: string
sub: string
actions?: ReactNode
}) {
return (
<header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
<p className="text-muted-foreground text-sm">{sub}</p>
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
)
}
const ALERT_ICON = {
er: <CircleAlertIcon aria-hidden />,
wn: <TriangleAlertIcon aria-hidden />,
in: <InfoIcon aria-hidden />,
} as const
const ALERT_VARIANT = {
er: 'destructive',
wn: 'warning',
in: 'info',
} as const
export function SignalAlert({
tone,
title,
children,
action,
}: {
tone: 'er' | 'wn' | 'in'
title: string
children: ReactNode
action?: ReactNode
}) {
return (
<Alert variant={ALERT_VARIANT[tone]}>
{ALERT_ICON[tone]}
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{children}</AlertDescription>
{action ? <AlertAction>{action}</AlertAction> : null}
</Alert>
)
}
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
export function PortalSheet({
open,
onOpenChange,
title,
description,
children,
footer,
size = 'md',
dirty,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
children: ReactNode
footer?: ReactNode
/** Ширина панели: md — форма, lg — редактор с двумя колонками. */
size?: 'md' | 'lg'
/** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
dirty?: boolean
}) {
const [touchedState, setTouched] = useState(false)
const touched = dirty ?? touchedState
const [guard, setGuard] = useState(false)
function requestChange(next: boolean) {
if (!next && touched) {
setGuard(true)
return
}
if (!next) setTouched(false)
onOpenChange(next)
}
return (
<>
<Sheet open={open} onOpenChange={requestChange}>
<SheetContent
side="right"
showCloseButton={false}
className={cn(
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
'data-[side=right]:sm:max-w-none',
size === 'lg'
? 'data-[side=right]:w-[min(45rem,100vw)]'
: 'data-[side=right]:w-[min(30rem,100vw)]',
)}
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">{title}</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className="text-muted-foreground hover:text-foreground"
/>
}
>
<XIcon aria-hidden="true" />
</SheetClose>
</div>
<SheetDescription className="text-muted-foreground px-4 pt-3 text-sm">
{description}
</SheetDescription>
</SheetHeader>
<div
className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4"
onInput={() => setTouched(true)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true)
}}
>
{children}
</div>
{footer ? (
<SheetFooter
className="flex-row flex-wrap justify-end gap-2 border-t px-4 py-3"
onClickCapture={(e) => {
// «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение.
const label = (e.target as HTMLElement).closest('button')?.textContent?.trim()
if (touched && (label === 'Закрыть' || label === 'Отмена')) {
e.stopPropagation()
setGuard(true)
}
}}
>{footer}</SheetFooter>
) : null}
</SheetContent>
</Sheet>
<AlertDialog open={guard} onOpenChange={setGuard}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Закрыть без сохранения?</AlertDialogTitle>
<AlertDialogDescription>Изменения в форме будут потеряны.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Остаться</AlertDialogCancel>
<Button
type="button"
variant="destructive"
onClick={() => {
setGuard(false)
setTouched(false)
onOpenChange(false)
}}
>
Закрыть без сохранения
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */
export function TypedConfirm({
open,
onOpenChange,
title,
description,
expected,
confirmLabel,
onConfirm,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: ReactNode
expected: string
confirmLabel: string
onConfirm: () => void
}) {
const [value, setValue] = useState('')
const matches = value.trim().toLowerCase() === expected.toLowerCase()
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!next) setValue('')
onOpenChange(next)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="typed-confirm">
Введите <span className="font-mono font-semibold">{expected}</span> для подтверждения
</Label>
<Input
id="typed-confirm"
value={value}
autoComplete="off"
onChange={(e) => setValue(e.target.value)}
aria-invalid={value.length > 0 && !matches}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<Button
type="button"
variant="destructive"
disabled={!matches}
onClick={() => {
setValue('')
onConfirm()
}}
>
{confirmLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export function KvFrame({
title,
description,
items,
}: {
title: string
description: string
items: Array<{ label: string; value: ReactNode }>
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<dl className="grid divide-y @container sm:grid-cols-2 sm:divide-y-0">
{items.map((item) => (
<div
key={item.label}
className="flex flex-col gap-0.5 border-b px-4 py-3 last:border-b-0 sm:odd:border-r"
>
<dt className="text-muted-foreground text-xs">{item.label}</dt>
<dd className="text-sm font-medium break-words">{item.value}</dd>
</div>
))}
</dl>
</FramePanel>
</Frame>
)
}
/** Список «значение + подпись + копировать» (параметры issuer). */
export function CopyList({
title,
description,
items,
}: {
title: string
description: string
items: Array<{ label: string; value: string }>
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<ul className="flex flex-col">
{items.map((item) => (
<li
key={item.label}
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
>
<code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
<span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
<Button
type="button"
variant="outline"
aria-label={`Скопировать: ${item.label}`}
onClick={() => {
void navigator.clipboard?.writeText(item.value).catch(() => undefined)
toast.success(`${item.label} скопирован`)
}}
>
Копировать
</Button>
</li>
))}
</ul>
</FramePanel>
</Frame>
)
}
/** Компактный список «ключ — значение» для боковых окон. */
export function KvList({ items }: { items: Array<[string, string]> }) {
return (
<dl className="flex flex-col divide-y rounded-lg border">
{items.map(([k, v]) => (
<div key={k} className="flex flex-col gap-0.5 px-3 py-2">
<dt className="text-muted-foreground text-xs">{k}</dt>
<dd className="text-sm break-words">{v}</dd>
</div>
))}
</dl>
)
}
export function CodeBlock({ label, children }: { label: string; children: string }) {
return (
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs">{label}</span>
<pre className="bg-muted/40 overflow-x-auto [scrollbar-width:none] rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
</div>
)
}
/** Сегментированный переключатель на Tabs (вид таблицы, период). */
export function Segmented({
label,
options,
value,
onChange,
}: {
label: string
options: string[]
value: number
onChange: (index: number) => void
}) {
return (
<Tabs value={String(value)} onValueChange={(v) => onChange(Number(v))} aria-label={label} className="max-w-full">
<TabsList className="max-w-full justify-start overflow-x-auto [scrollbar-width:none]">
{options.map((o, i) => (
<TabsTrigger key={o} value={String(i)}>
{o}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)
}
/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
return (
<Badge variant={mark === 'deleted' ? 'destructive-light' : 'warning-light'} size="sm" className="whitespace-nowrap">
{mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
</Badge>
)
}
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
export function PageTabsList({ children }: { children: ReactNode }) {
return (
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto [scrollbar-width:none]">
{children}
</TabsList>
)
}
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
return (
<TabsTrigger value={value} className="flex-none gap-1.5 px-4 text-sm">
{label}
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
</TabsTrigger>
)
}
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3.5 last:border-b-0">
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
<b className="text-sm font-medium">{title}</b>
<span className="text-muted-foreground text-[13px]">{description}</span>
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
export function FormFrame({
title,
description,
badge,
tone = 'ok',
children,
}: {
title: string
description: string
badge: string
tone?: Tone
children: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</div>
<StatusBadge tone={tone}>{badge}</StatusBadge>
</FrameHeader>
<FramePanel className="p-0">{children}</FramePanel>
</Frame>
)
}
@@ -0,0 +1,142 @@
import type { ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@evobgp/ui/lib/utils'
import type { Tone } from './tone'
import { StatusBadge } from './bits'
export type AppCardItem = {
id: string
title: string
desc: string
badge: string
badgeVariant?: 'info-light' | 'warning-light' | 'outline'
meta: string
icon: ReactNode
onSelect?: () => void
}
/**
* Плитки приложений как на доске «Приложения»: Frame с шапкой, внутри сетка auto-fit от 260px,
* в плитке иконка, название, плашка действия, описание и адрес моноширинным шрифтом.
*/
export function AppCards({
title,
description,
items,
action,
}: {
title: string
description: string
items: AppCardItem[]
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="p-3">
<ul className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,16.25rem),1fr))] gap-3">
{items.map((c) => {
const body = (
<>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2.5">
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
{c.icon}
</IconTile>
<b className="min-w-0 text-sm leading-tight font-semibold">{c.title}</b>
</span>
<Badge variant={c.badgeVariant ?? 'info-light'} size="sm" className="shrink-0">
{c.badge}
</Badge>
</div>
<p className="text-muted-foreground mt-1.5 text-[13px] text-pretty">{c.desc}</p>
<p className="text-muted-foreground mt-2 truncate font-mono text-[11px]">{c.meta}</p>
</>
)
const cls = 'bg-muted/25 w-full rounded-lg border p-3.5 text-left'
return (
<li key={c.id} className="min-w-0">
{c.onSelect ? (
<button
type="button"
onClick={c.onSelect}
aria-label={`${c.title}: ${c.badge}`}
className={cn(cls, 'hover:bg-muted/50 focus-visible:ring-ring transition-colors outline-none focus-visible:ring-2')}
>
{body}
</button>
) : (
<div className={cls}>{body}</div>
)}
</li>
)
})}
</ul>
</FramePanel>
</Frame>
)
}
const DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
mu: 'bg-muted-foreground',
in: 'bg-chart-4',
}
/**
* Лента событий как на доске «Изменения»: время, точка важности с линией, плашка, заголовок
* и строка «кто · источник · IP».
*/
export function ActivityFeed({
title,
description,
items,
action,
}: {
title: string
description: string
items: Array<{ t: string; title: string; text: string; label: string; tone: Tone }>
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="py-1.5">
<ol>
{items.map((e) => (
<li key={`${e.t}-${e.title}`} className="grid grid-cols-[3.9rem_1rem_minmax(0,1fr)] items-start gap-2.5 px-4 py-2 text-[13px]">
<span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{e.t}</span>
<span aria-hidden className="flex h-full flex-col items-center">
<i className={cn('mt-1 size-[9px] rounded-full', DOT[e.tone])} />
<u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" />
</span>
<span className="min-w-0">
<span className="mr-2 inline-block align-middle">
<StatusBadge tone={e.tone}>{e.label}</StatusBadge>
</span>
<b className="font-medium">{e.title}</b>
<span className="text-muted-foreground mt-0.5 block text-xs">{e.text}</span>
</span>
</li>
))}
</ol>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store'
export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) {
return <ChromeConfigContext.Provider value={value}>{children}</ChromeConfigContext.Provider>
}
function readStored<T>(key: string, fallback: T): T {
try {
const raw = window.localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : fallback
} catch {
return fallback
}
}
function writeStored(key: string, value: unknown) {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {
/* без сохранения настроек */
}
}
/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */
export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) {
const [paletteOpen, setPaletteOpen] = useState(false)
const [density, setDensityState] = useState<Density>(() => readStored(`${storageKey}:density`, 'comfortable'))
const [contrast, setContrastState] = useState<Contrast>(() => readStored(`${storageKey}:contrast`, 'standard'))
const [pins, setPins] = useState<string[]>(() => readStored(`${storageKey}:pins`, []))
const setDensity = useCallback(
(v: Density) => {
setDensityState(v)
writeStored(`${storageKey}:density`, v)
},
[storageKey],
)
const setContrast = useCallback(
(v: Contrast) => {
setContrastState(v)
writeStored(`${storageKey}:contrast`, v)
},
[storageKey],
)
const togglePin = useCallback(
(id: string) => {
setPins((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
writeStored(`${storageKey}:pins`, next)
return next
})
},
[storageKey],
)
useEffect(() => {
const root = document.documentElement
root.dataset.density = density
root.dataset.contrast = contrast
return () => {
delete root.dataset.density
delete root.dataset.contrast
}
}, [density, contrast])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setPaletteOpen((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const value = useMemo<UiStore>(
() => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }),
[paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast],
)
return <UiStoreContext.Provider value={value}>{children}</UiStoreContext.Provider>
}
@@ -0,0 +1,86 @@
import { createContext, useContext, type ComponentType, type ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
export type NavItem = {
id: string
label: string
to: string
icon: LucideIcon
group: string
/** Точка проблемы в углу иконки. */
problem?: { tone: 'er' | 'wn'; label: string }
}
export type MonitorItem = {
id: string
tone: 'er' | 'wn' | 'ok'
title: string
text: string
age: string
to: string
}
export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' }
export type PaletteAction = {
id: string
label: string
/** Горячая буква справа. */
hint: string
icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
to: string
}
export type PaletteObject = {
id: string
group: string
label: string
hint: string
dot?: 'ok' | 'er' | 'mu'
to: string
}
/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */
export type ChromeConfig = {
navItems: NavItem[]
navGroups: string[]
/** Верх бокового меню: бренд и переключатель приложений. */
brand: ReactNode
user: { name: string; email: string; initials: string }
securityTo: string
onLogout: () => void
fresh: (pathname: string) => { badge: string; text: string }
crumbFor?: (pathname: string, label: string | undefined) => string
monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string }
palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string }
}
export const ChromeConfigContext = createContext<ChromeConfig | null>(null)
export function useChromeConfig(): ChromeConfig {
const ctx = useContext(ChromeConfigContext)
if (!ctx) throw new Error('ChromeConfigProvider отсутствует')
return ctx
}
export type Density = 'comfortable' | 'compact'
export type Contrast = 'standard' | 'high'
export type UiStore = {
paletteOpen: boolean
setPaletteOpen: (open: boolean) => void
pins: string[]
togglePin: (id: string) => void
density: Density
setDensity: (v: Density) => void
contrast: Contrast
setContrast: (v: Contrast) => void
}
export const UiStoreContext = createContext<UiStore | null>(null)
export function useUiStore(): UiStore {
const ctx = useContext(UiStoreContext)
if (!ctx) throw new Error('PortalUiProvider отсутствует')
return ctx
}
+569
View File
@@ -0,0 +1,569 @@
import {
useMemo,
useState,
type ComponentType,
} from 'react'
import {
Link,
useNavigate,
useRouterState,
} from '@tanstack/react-router'
import {
ActivityIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronsUpDownIcon,
FingerprintIcon,
InfoIcon,
LogOutIcon,
SearchIcon,
StarIcon,
MoonIcon,
} from 'lucide-react'
import {
useTheme,
} from 'next-themes'
import {
Badge,
} from '@/components/reui/badge'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from '@evobgp/ui/components/breadcrumb'
import {
Button,
} from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import {
Kbd,
} from '@evobgp/ui/components/kbd'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@evobgp/ui/components/popover'
import {
Separator,
} from '@evobgp/ui/components/separator'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
SidebarTrigger,
useSidebar,
} from '@evobgp/ui/components/sidebar'
import {
cn,
} from '@evobgp/ui/lib/utils'
import {
Segmented,
} from './bits'
import {
useChromeConfig,
useUiStore,
type NavItem,
} from './chrome-store'
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
function PortalNavUser() {
const { isMobile } = useSidebar()
const { theme, setTheme, resolvedTheme } = useTheme()
const { density, setDensity, contrast, setContrast } = useUiStore()
const { user, securityTo, onLogout } = useChromeConfig()
const navigate = useNavigate()
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
return (
<SidebarMenu>
<SidebarMenuItem>
<Popover>
<PopoverTrigger
render={
<SidebarMenuButton
size="lg"
aria-label={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
className="bg-muted/40 aria-expanded:bg-muted"
/>
}
>
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
{user.initials}
</div>
<div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate text-sm font-semibold">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
</PopoverTrigger>
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
<div className="flex items-center gap-2.5">
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
{user.initials}
</div>
<div className="grid leading-tight">
<span className="text-sm font-semibold">{user.name}</span>
<span className="text-muted-foreground text-xs">{user.email}</span>
</div>
</div>
<Separator />
<button
type="button"
onClick={() => void navigate({ to: securityTo })}
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
>
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
Безопасность
</button>
<div className="flex flex-col gap-1.5">
<span className={LABEL_CLASS}>Тема</span>
<Segmented
label="Тема"
options={['Тёмная', 'Светлая']}
value={current === 'light' ? 1 : 0}
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
/>
<span className={LABEL_CLASS}>Плотность</span>
<Segmented
label="Плотность"
options={['Обычная', 'Компактная']}
value={density === 'compact' ? 1 : 0}
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
/>
<span className={LABEL_CLASS}>Контраст</span>
<Segmented
label="Контраст"
options={['Стандартный', 'Высокий']}
value={contrast === 'high' ? 1 : 0}
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
/>
</div>
<Separator />
<button
type="button"
onClick={onLogout}
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
>
<LogOutIcon className="size-4" aria-hidden />
Выйти
</button>
</PopoverContent>
</Popover>
</SidebarMenuItem>
</SidebarMenu>
)
}
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
const Icon = item.icon
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem>
<SidebarMenuButton
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
isActive={active}
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
render={<Link to={item.to} />}
>
<Icon className="size-4" />
<span>{item.label}</span>
{item.problem ? (
<span
role="img"
aria-label={item.problem.label}
className={cn(
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
DOT_CLASS[item.problem.tone],
)}
/>
) : null}
</SidebarMenuButton>
<SidebarMenuAction
showOnHover={!pinned}
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
aria-pressed={pinned}
onClick={onTogglePin}
>
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
</SidebarMenuAction>
</SidebarMenuItem>
)
}
export function PortalSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { pins, togglePin } = useUiStore()
const { navItems, navGroups, brand } = useChromeConfig()
const [closed, setClosed] = useState<Record<string, boolean>>({})
const pinned = navItems.filter((i) => pins.includes(i.id))
return (
<Sidebar collapsible="icon">
<SidebarHeader className="pb-1.5">
{brand}
</SidebarHeader>
<SidebarContent>
{pinned.length > 0 ? (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{pinned.map((item) => (
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) : null}
{navGroups.map((group, gi) => (
<div key={group} className="flex flex-col">
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
<SidebarGroup className="py-0">
<SidebarGroupLabel
render={
<button
type="button"
aria-expanded={!closed[group]}
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
/>
}
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
>
{group}
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
</SidebarGroupLabel>
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
<SidebarMenu className="gap-px">
{navItems.filter((i) => i.group === group).map((item) => (
<NavEntry
key={item.id}
item={item}
pathname={pathname}
pinned={pins.includes(item.id)}
onTogglePin={() => togglePin(item.id)}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</div>
))}
</SidebarContent>
<SidebarFooter>
<PortalNavUser />
</SidebarFooter>
</Sidebar>
)
}
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
function MonitorPopover() {
const { monitor } = useChromeConfig()
const MONITOR_ITEMS = monitor.items
const MONITOR_METRICS = monitor.metrics
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
const errors = bad.filter((m) => m.tone === 'er').length
const warnings = bad.length - errors
const ok = MONITOR_ITEMS.length - bad.length
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
type="button"
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
/>
}
>
<ActivityIcon className="text-warning size-4" aria-hidden />
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
{errors}
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
{warnings}
</span>
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
{bad.length}
</span>
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="grid leading-tight">
<b className="text-sm font-semibold">Состояние системы</b>
<span className="text-muted-foreground text-xs">{monitor.updated}</span>
</div>
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</Badge>
</div>
<div className="grid grid-cols-3 gap-1.5">
{[
{ n: errors, l: 'сбой', c: 'text-destructive' },
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
{ n: ok, l: 'в норме', c: 'text-success' },
].map((x) => (
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
{x.l}
</span>
))}
</div>
<ul className="flex flex-col gap-0.5">
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
<li key={m.id}>
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
<span className="grid min-w-0 flex-1 leading-tight">
<b className="truncate text-[13px] font-medium">{m.title}</b>
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
</span>
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
</Link>
</li>
))}
</ul>
<div className="grid grid-cols-2 gap-1.5">
{MONITOR_METRICS.map((m) => (
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
</div>
</div>
))}
</div>
<Link to={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
{monitor.footerLabel}
<ArrowRightIcon className="size-3.5" aria-hidden />
</Link>
</PopoverContent>
</Popover>
)
}
function FreshnessPopover() {
const pathname = useRouterState({ select: (st) => st.location.pathname })
const { fresh: freshFor } = useChromeConfig()
const fresh = freshFor(pathname)
return (
<Popover>
<PopoverTrigger
render={
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
}
>
<InfoIcon aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
<p className="text-muted-foreground text-sm">{fresh.text}</p>
</PopoverContent>
</Popover>
)
}
export function PortalHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { setPaletteOpen } = useUiStore()
const { navItems, crumbFor } = useChromeConfig()
const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
return (
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex shrink-0 items-center gap-2">
<FreshnessPopover />
<Button
type="button"
variant="outline"
size="icon"
aria-label="Командная палитра (⌘K)"
className="md:hidden"
onClick={() => setPaletteOpen(true)}
>
<SearchIcon aria-hidden />
</Button>
<MonitorPopover />
</div>
</header>
)
}
type PaletteEntry = {
id: string
group: string
label: string
hint: string
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
dot?: 'ok' | 'er' | 'mu'
run: () => void
}
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
export function CommandPalette() {
const { paletteOpen, setPaletteOpen } = useUiStore()
const { navItems, palette } = useChromeConfig()
const navigate = useNavigate()
const { theme, setTheme, resolvedTheme } = useTheme()
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
const go = (to: string) => () => void navigate({ to })
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
const list: PaletteEntry[] = [
...[
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
]
.filter((e) => match(e.label))
.map((e) => ({ ...e, group: 'Действия' })),
...navItems.filter((i) => match(i.label, i.group))
.slice(0, 7)
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
...Object.values(
palette.objects
.filter((o) => match(o.label, o.hint))
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
]
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
return [
{
id: 'ai',
group: 'Ассистент · запрос на естественном языке',
label: `Понял так: «${query.trim()}»`,
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
run: () => void navigate({ to: palette.assistantTo }),
} as PaletteEntry,
...list,
]
}
return list
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
const groups = useMemo(() => {
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
shown.forEach((e, index) => {
const list = map.get(e.group) ?? []
list.push({ ...e, index })
map.set(e.group, list)
})
return [...map.entries()]
}, [shown])
function close() {
setPaletteOpen(false)
setQuery('')
setActive(0)
}
return (
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
<input
autoFocus
value={query}
aria-label="Поиск и команды"
placeholder="Раздел, пользователь, приложение или действие…"
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive((a) => Math.min(a + 1, shown.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((a) => Math.max(a - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
shown[active]?.run()
close()
}
}}
/>
<Kbd>Esc</Kbd>
</div>
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
{groups.length === 0 ? (
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
) : (
groups.map(([group, list]) => (
<div key={group} className="flex flex-col">
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
{list.map((e) => {
const Icon = e.icon
return (
<button
key={e.id}
type="button"
role="option"
aria-selected={e.index === active}
onMouseEnter={() => setActive(e.index)}
onClick={() => {
e.run()
close()
}}
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
>
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
<span className="min-w-0 truncate">{e.label}</span>
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
</button>
)
})}
</div>
))
)}
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
<span>↑↓ выбор</span>
<span>↵ открыть</span>
<span>Esc закрыть</span>
<span className="ml-auto">{palette.example}</span>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,26 @@
import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid'
export const DATA_GRID_CELL_PAD = 'py-3'
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
export const DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['tableClassNames']> = {
headerRow: 'border-b border-border',
bodyRow: 'group/row',
}
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
export function cellPad(index: number, count: number) {
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
return DATA_GRID_CELL_PAD
}
@@ -0,0 +1,123 @@
/**
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
*/
import type { ReactNode } from 'react'
import type { Column } from '@tanstack/react-table'
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@evobgp/ui/components/input-group'
import { cn } from '@evobgp/ui/lib/utils'
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
return (
<Frame dense className={cn('w-full min-w-0', className)}>
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
</Frame>
)
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
label,
}: {
value: T
onChange: (value: T) => void
options: { value: T; label: string; count?: number }[]
label?: string
}) {
return (
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
{options.map((o) => (
<button
key={o.value}
type="button"
aria-pressed={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
{o.label}
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
</button>
))}
</div>
)
}
export function DataPageToolbar({
leading,
segmented,
filterControl,
search,
onSearchChange,
searchPlaceholder = 'Поиск…',
countLabel,
actions,
}: {
leading?: ReactNode
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
filterControl?: ReactNode
search?: string
onSearchChange?: (value: string) => void
searchPlaceholder?: string
countLabel?: string
actions?: ReactNode
}) {
return (
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
{leading}
{segmented ? <SegmentedControl {...segmented} /> : null}
{filterControl}
{onSearchChange ? (
<InputGroup className="max-w-sm min-w-[220px]">
<InputGroupAddon>
<SearchIcon className="size-3.5" aria-hidden />
</InputGroupAddon>
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
</InputGroup>
) : null}
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
{actions}
</div>
)
}
export function DataGridSortHeader<TData extends object>({
column,
title,
className,
}: {
column: Column<DataGridFeatures, TData, unknown>
title: string
className?: string
}) {
const sorted = column.getIsSorted()
if (!column.getCanSort()) {
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
}
return (
<button
type="button"
className={cn(
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
className,
)}
onClick={column.getToggleSortingHandler()}
>
{title}
{sorted === 'asc' ? (
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
) : sorted === 'desc' ? (
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
) : (
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
)}
</button>
)
}
+45
View File
@@ -0,0 +1,45 @@
const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' })
const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' })
function parse(iso: string | null | undefined): Date | null {
if (!iso) return null
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? null : d
}
/** 12.09.2026 */
export function fmtDate(iso: string | null | undefined, empty = '—') {
const d = parse(iso)
return d ? DATE.format(d) : empty
}
/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */
export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) {
const d = parse(iso)
if (!d) return empty
const dayMs = 24 * 60 * 60 * 1000
const startOfToday = new Date(now)
startOfToday.setHours(0, 0, 0, 0)
const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0)
if (diff === 0) return `сегодня ${TIME.format(d)}`
if (diff === dayMs) return `вчера ${TIME.format(d)}`
return `${SHORT.format(d)} ${TIME.format(d)}`
}
/** Время дня 14:02. */
export function fmtTime(iso: string | null | undefined, empty = '—') {
const d = parse(iso)
return d ? TIME.format(d) : empty
}
/** «5 дн назад», «3 ч назад», «только что». */
export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) {
const d = parse(iso)
if (!d) return empty
const sec = Math.max(0, Math.round((now - d.getTime()) / 1000))
if (sec < 60) return 'только что'
if (sec < 3600) return `${Math.floor(sec / 60)} мин назад`
if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад`
return `${Math.floor(sec / 86400)} дн назад`
}
@@ -0,0 +1,74 @@
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { Frame, FramePanel } from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
import type { KpiItem, KpiTone } from './kpi'
const VALUE: Record<KpiTone, string> = {
neutral: 'text-foreground',
ok: 'text-foreground',
in: 'text-foreground',
wn: 'text-warning',
er: 'text-destructive',
}
/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
const COLS: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-2 max-[640px]:grid-cols-1',
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
}
const TILE: Record<KpiTone, string> = {
neutral: 'text-muted-foreground',
ok: 'text-success',
in: 'text-info',
wn: 'text-warning',
er: 'text-destructive',
}
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
const BADGE = {
neutral: 'outline',
ok: 'success-light',
in: 'info-light',
wn: 'warning-light',
er: 'destructive-light',
} as const
/**
* Полоса показателей как на досках: один Frame, ячейки разделены линиями,
* в ячейке подпись, крупное значение и плашка-подсказка.
*/
export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) {
return (
<Frame spacing="sm" className="w-full min-w-0" aria-label={ariaLabel}>
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', COLS[Math.min(Math.max(items.length, 1), 4)])}>
{items.map((k) => {
const Icon = k.icon
return (
<div key={k.id} className="bg-card flex min-w-0 items-center gap-3 px-[18px] py-3.5">
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[k.tone])}>
<Icon />
</IconTile>
<div className="flex min-w-0 flex-col gap-1">
<div className="text-muted-foreground text-[13px]">{k.label}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
{k.hint}
</Badge>
</div>
</div>
</div>
)
})}
</div>
</FramePanel>
</Frame>
)
}
+24
View File
@@ -0,0 +1,24 @@
import type { LucideIcon } from 'lucide-react'
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
export type KpiItem = {
id: string
label: string
value: string
/** Плашка-подсказка рядом со значением. */
hint: string
tone: KpiTone
/** Иконка показателя: плитка слева от подписи. */
icon: LucideIcon
}
/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
export function kpi(
id: string,
label: string,
value: string,
opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
): KpiItem {
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
}
@@ -0,0 +1,488 @@
import { useMemo, useState, type ReactNode } from 'react'
import { Columns3Icon, MoreHorizontalIcon } from 'lucide-react'
import { toast } from 'sonner'
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
import { DATA_GRID_I18N_RU as DATA_GRID_RU } from '@/lib/reui-i18n-ru'
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
DataGridTableDndRowHandle,
DataGridTableDndRows,
} from '@/components/reui/data-grid/data-grid-table-dnd-rows'
import type { DragEndEvent } from '@dnd-kit/core'
import {
DataGridTable,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import { Switch } from '@evobgp/ui/components/switch'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
import { StatusBadge } from './bits'
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
DATA_GRID_CLASS_NAMES,
DATA_GRID_LAYOUT,
} from './data-page-layout'
import {
DataGridSortHeader,
DataPageCard,
DataPageToolbar,
} from './data-page'
import type { Tone } from './tone'
import type { RowMark } from './tone'
import { TONE_DOT } from './tone'
export type GridColumn<T> = {
id: string
title: string
/** Ширина колонки; сумма задаёт минимальную ширину таблицы. */
size: number
cell: (row: T) => ReactNode
/** Ключ сортировки; без него колонка не сортируется. */
sortValue?: (row: T) => string | number
}
export type GridView<T> = { label: string; match: (row: T) => boolean }
/** Карточка строки для режима «Карточки» (как на доске «Пользователи»). */
export type GridCard = {
name: string
sub?: string
tone: Tone
status: string
cells: Array<{ l: string; v: string }>
}
/** Действия меню строки «⋮»: на досках — Открыть, Включить/Выключить, Дублировать, Удалить…. */
export type RowMenuConfig = {
onOpen?: () => void
toggleLabel?: string
onToggle?: () => void
onDuplicate?: () => void
onDelete?: () => void
}
export type GridBulk = {
onLabel?: string
offLabel?: string
onRun?: (ids: string[]) => void
offRun?: (ids: string[]) => void
onDelete?: (ids: string[]) => void
}
const SELECT_COLUMN_SIZE = 44
const DRAG_COLUMN_SIZE = 44
const ACTIONS_COLUMN_SIZE = 120
/**
* Секция-таблица по доскам Auth Portal: шапка (название, действия, «Таблица/Карточки», поиск, «Колонки»),
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
*/
export function SectionGrid<T extends object>({
title,
description,
rows,
columns,
getRowId,
views,
searchText,
searchPlaceholder = 'Поиск…',
onRowClick,
selectable = true,
bulk,
tools,
card,
rowSwitch,
rowMenu,
onReorder,
rowStatus,
pageSize = 50,
emptyMessage = 'Нет записей по текущим фильтрам.',
className,
}: {
title: string
description: string
rows: T[]
columns: GridColumn<T>[]
getRowId: (row: T) => string
views?: GridView<T>[]
searchText?: (row: T) => string
searchPlaceholder?: string
onRowClick?: (row: T) => void
selectable?: boolean
bulk?: GridBulk
/** Кнопки действий в шапке; функция получает id выбранных строк. */
tools?: ReactNode | ((selectedIds: string[]) => ReactNode)
/** Включает переключатель «Таблица / Карточки». */
card?: (row: T) => GridCard
/** Переключатель «включено» в последней колонке (если у строки он есть). */
rowSwitch?: { value: (row: T) => boolean | undefined; onChange: (row: T, on: boolean) => void }
rowMenu?: (row: T) => RowMenuConfig
/** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */
onReorder?: (activeId: string, overId: string) => void
/** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */
rowStatus?: (row: T) => RowMark | undefined
pageSize?: number
/** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */
footer?: string
emptyMessage?: string
className?: string
}) {
const storageKey = `design-preview:view:${title}`
const [view, setViewState] = useState(() => {
try {
const v = Number(window.localStorage.getItem(storageKey))
return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0
} catch {
return 0
}
})
const setView = (i: number) => {
setViewState(i)
try {
window.localStorage.setItem(storageKey, String(i))
} catch {
/* без сохранения вида */
}
}
const [query, setQuery] = useState('')
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
const [hidden, setHidden] = useState<Record<string, boolean>>({})
const [cards, setCards] = useState(false)
const data = useMemo(() => {
const matchView = views?.[view]?.match
const q = query.trim().toLowerCase()
return rows.filter((row) => {
if (matchView && !matchView(row)) return false
if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false
return true
})
}, [rows, views, view, query, searchText])
const visibleColumns = useMemo(() => columns.filter((c, i) => i === 0 || !hidden[c.id]), [columns, hidden])
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
const list: ColumnDef<DataGridFeatures, T>[] = []
if (onReorder) {
list.push({
id: 'drag',
header: () => <span className="sr-only">Порядок</span>,
cell: () => <DataGridTableDndRowHandle />,
enableSorting: false,
enableResizing: false,
size: DRAG_COLUMN_SIZE,
})
}
if (selectable) {
list.push({
id: 'select',
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelect row={row} />
</div>
),
enableSorting: false,
enableResizing: false,
size: SELECT_COLUMN_SIZE,
meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' },
})
}
visibleColumns.forEach((col, i) => {
const pad = i === 0 && !selectable && !onReorder ? DATA_GRID_CELL_PAD_FIRST : DATA_GRID_CELL_PAD
list.push({
id: col.id,
accessorFn: col.sortValue ?? (() => ''),
header: ({ column }) => <DataGridSortHeader column={column} title={col.title} />,
cell: ({ row }) => col.cell(row.original),
enableSorting: Boolean(col.sortValue),
enableResizing: false,
size: col.size,
meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad },
})
})
list.push({
id: '__actions',
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const r = row.original
const on = rowSwitch?.value(r)
const menu: RowMenuConfig = rowMenu?.(r) ?? {}
const soon = (what: string) => () => toast(`${what} (макет)`)
return (
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
{on !== undefined && rowSwitch ? (
<Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" />
) : null}
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon" aria-label="Действия" />}>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem onClick={menu.onOpen ?? (() => onRowClick?.(r))}>Открыть</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onToggle ?? soon('Действие')}>
{menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')}
</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onDuplicate ?? soon('Дублирование')}>Дублировать</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={menu.onDelete ?? soon('Удаление')}>
Удалить…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
enableSorting: false,
enableResizing: false,
size: ACTIONS_COLUMN_SIZE,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
})
return list
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
const table = useTable({
features: dataGridFeatures,
columns: defs,
data,
getRowId,
state: { sorting, rowSelection, pagination },
enableRowSelection: selectable,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
})
const dataIds = table.getRowModel().rows.map((r) => r.id)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!onReorder || !active || !over || active.id === over.id) return
onReorder(String(active.id), String(over.id))
}
// Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
const minWidth = Math.round(
visibleColumns.reduce(
(sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
) * 0.7,
)
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
const clear = () => setRowSelection({})
const paged = data.length > pageSize
const showCards = Boolean(card) && cards
const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}`
return (
<DataGrid
table={table}
i18n={DATA_GRID_RU}
recordCount={data.length}
onRowClick={onRowClick}
getRowStatus={rowStatus}
emptyMessage={emptyMessage}
tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }}
tableClassNames={DATA_GRID_CLASS_NAMES}
>
<DataPageCard className={className}>
<h2 className="sr-only">{title}</h2>
<DataPageToolbar
segmented={
views
? {
value: String(view),
label: description,
onChange: (v) => {
setView(Number(v))
setPagination((p) => ({ ...p, pageIndex: 0 }))
},
options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })),
}
: undefined
}
leading={
card ? (
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
<TabsList className="h-9! gap-0.5 p-[3px]">
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
Таблица
</TabsTrigger>
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
Карточки
</TabsTrigger>
</TabsList>
</Tabs>
) : undefined
}
search={query}
onSearchChange={
searchText
? (v) => {
setQuery(v)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}
: undefined
}
searchPlaceholder={searchPlaceholder}
countLabel={countLabel}
actions={
<>
{typeof tools === 'function' ? tools(selectedIds) : tools}
{columns.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
>
<Columns3Icon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
{columns.slice(1).map((c) => (
<DropdownMenuCheckboxItem key={c.id} checked={!hidden[c.id]} onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}>
{c.title}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</>
}
/>
{selectable && selectedIds.length > 0 ? (
<div
role="toolbar"
aria-label="Массовые действия"
className="bg-primary/10 flex flex-wrap items-center gap-2 border-b px-3.5 py-2 text-[13px]"
>
<b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
<Button
type="button"
variant="outline"
onClick={() => {
if (bulk?.onRun) bulk.onRun(selectedIds)
else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.onLabel ?? 'Включить'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
if (bulk?.offRun) bulk.offRun(selectedIds)
else toast(`${bulk?.offLabel ?? 'Выключить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.offLabel ?? 'Выключить'}
</Button>
<Button
type="button"
variant="outline"
className="text-destructive"
onClick={() => {
if (bulk?.onDelete) bulk.onDelete(selectedIds)
else toast(`Удаление: ${selectedIds.length} (макет)`)
}}
>
Удалить…
</Button>
<span className="flex-1" />
<Button type="button" variant="outline" onClick={clear}>
Снять выбор
</Button>
</div>
) : null}
{showCards && card ? (
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,15rem),1fr))] gap-3 p-3">
{data.map((r) => {
const c = card(r)
const id = getRowId(r)
const marked = rowStatus?.(r)
return (
<li
key={id}
className={cn('bg-muted/25 flex flex-col gap-2 rounded-lg border p-3', marked === 'deleted' && 'opacity-60')}
>
<div className="flex items-center justify-between gap-2">
<input
type="checkbox"
aria-label={`Выбрать ${c.name}`}
checked={Boolean(rowSelection[id])}
onChange={(e) =>
setRowSelection((s) => {
const next = { ...s }
if (e.target.checked) next[id] = true
else delete next[id]
return next
})
}
className="accent-primary size-4"
/>
<StatusBadge tone={c.tone}>{c.status}</StatusBadge>
</div>
<button
type="button"
onClick={() => onRowClick?.(r)}
className="flex min-w-0 items-start gap-2 text-left"
aria-label={`Открыть: ${c.name}`}
>
<span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[c.tone])} />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{c.name}</span>
{c.sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{c.sub}</span> : null}
</span>
</button>
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
{c.cells.map((x) => (
<div key={x.l} className="min-w-0">
<dt className="text-muted-foreground">{x.l}</dt>
<dd className="truncate font-mono">{x.v}</dd>
</div>
))}
</dl>
</li>
)
})}
</ul>
) : (
<DataGridScrollArea>
<div style={{ minWidth }}>
{onReorder ? <DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
</div>
</DataGridScrollArea>
)}
{paged && !showCards ? <DataGridPagination className="px-5 py-3" sizes={[8, 16, 32]} /> : null}
</DataPageCard>
</DataGrid>
)
}
/** Двухстрочная ячейка: заголовок + подпись. */
export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) {
return (
<div className="flex min-w-0 items-start gap-2">
{dot ? <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[dot])} /> : null}
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{title}</span>
{sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{sub}</span> : null}
</div>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in'
/** Пометка строки с накопленной правкой. */
export type RowMark = 'new' | 'dirty' | 'deleted'
type BadgeVariant =
| 'success-light'
| 'warning-light'
| 'destructive-light'
| 'info-light'
| 'outline'
export const TONE_BADGE: Record<Tone, BadgeVariant> = {
ok: 'success-light',
wn: 'warning-light',
er: 'destructive-light',
in: 'info-light',
mu: 'outline',
}
export const TONE_DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
in: 'bg-success',
mu: 'bg-muted-foreground/50',
}
@@ -9,18 +9,18 @@ import {
} from './frame-data-grid'
describe('kitDataGridTableLayout', () => {
it('filtering-2 defaults: dense, headerBackground false, width fixed', () => {
it('defaults макета: не dense, headerBackground true, width fixed', () => {
const layout = kitDataGridTableLayout()
expect(layout.dense).toBe(true)
expect(layout.headerBackground).toBe(false)
expect(layout.dense).toBe(false)
expect(layout.headerBackground).toBe(true)
expect(layout.width).toBe('fixed')
expect(layout.headerSticky).toBe(false)
expect('stripped' in layout ? layout.stripped : undefined).toBeUndefined()
expect(layout.columnsPinnable).toBe(false)
})
it('kit tableClassNames совпадает с filtering-2 edgeCell', () => {
expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-3 last:pe-3')
it('kit tableClassNames: поля краёв как в MikrotikManager', () => {
expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-5 last:pe-4')
expect(kitDataGridTableClassNames.base).toBe(
'[&_[data-pinned]]:bg-(--frame-panel-bg)',
)
@@ -33,7 +33,7 @@ describe('kitDataGridTableLayout', () => {
})
expect(layout.width).toBe('auto')
expect(layout.columnsPinnable).toBe(true)
expect(layout.headerBackground).toBe(false)
expect(layout.headerBackground).toBe(true)
})
})
@@ -66,10 +66,10 @@ export function kitDataGridTableLayout(
} = {},
) {
return {
dense: opts.dense ?? true,
dense: opts.dense ?? false,
rowBorder: true,
headerSticky: false,
headerBackground: false,
headerBackground: true,
headerBorder: true,
width: opts.width ?? ('fixed' as const),
columnsVisibility: opts.columnsVisibility ?? false,
@@ -84,7 +84,8 @@ export function kitDataGridTableLayout(
/** filtering-2 edge inset + Frame-surface pinned cells (not page --background). */
export const kitDataGridTableClassNames = {
base: '[&_[data-pinned]]:bg-(--frame-panel-bg)',
edgeCell: 'first:ps-3 last:pe-3',
edgeCell: 'first:ps-5 last:pe-4',
headerRow: 'border-b border-border',
} as const
/**
@@ -317,7 +318,10 @@ function FrameDataGridBody<TData extends object>({
{showPagination ? (
<>
<Separator />
<FrameFooter>
<FrameFooter className="flex-wrap justify-between gap-2">
<span className="text-muted-foreground text-xs tabular-nums">
Показано {table.getRowModel().rows.length} из {data.length}
</span>
<DataGridPagination sizes={[10, 25, 50]} />
</FrameFooter>
</>
@@ -260,39 +260,61 @@ interface KpiStatGridProps {
'aria-label'?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
/** Колонки полосы по ширине контейнера: 4 → 1/2/4, 3 → 1/3. */
function stripCols(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 @2xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-4'
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
}
/** Ячейка полосы показателей как на досках макета: плитка-иконка, подпись, значение и плашка рядом. */
function KpiStripCell({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const footer = resolveFooter(item)
const valueVariant = item.variant ?? 'default'
const clickable = Boolean(item.to || onActivate)
const body = (
<div className="flex min-w-0 items-center gap-3 px-[18px] py-3.5">
{item.icon ? (
<IconTile variant="outline" aria-hidden="true" className={cn('shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-col gap-1">
<div className="text-muted-foreground truncate text-[13px]">{item.label}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE_VARIANT_CLASS[valueVariant])}>
{item.value}
</span>
{footer ? <span className="min-w-0 max-w-full">{footer}</span> : null}
</div>
</div>
</div>
)
const cellClass = cn(
'bg-card relative min-w-0',
clickable && 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
isSelected(item) && 'bg-muted/30',
)
if (item.to) {
return (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
<div className={cellClass}>
<Link to={item.to} search={item.search} className="block focus-visible:outline-none">
{body}
</Link>
</FramePanel>
</div>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
<div className={cellClass} onClick={onActivate} role="button" tabIndex={0} onKeyDown={(e) => handleCardKeyDown(onActivate, e)}>
{body}
</div>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
return <div className={cellClass}>{body}</div>
}
/**
@@ -342,12 +364,14 @@ export function KpiStatGrid({
}
return (
<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} />
))}
</div>
<Frame spacing="sm" className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', stripCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStripCell key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</FramePanel>
</Frame>
)
}
@@ -11,7 +11,6 @@ import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-rea
import { EmptyState } from '@/components/empty-state'
import {
StatusToggleGroup,
type StatusToggleTone,
} from '@/components/status-toggle-group'
import { Badge } from '@/components/reui/badge'
@@ -43,6 +42,7 @@ import {
} from '@evobgp/ui/components/input-group'
import { Separator } from '@evobgp/ui/components/separator'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
import {
@@ -540,100 +540,75 @@ function ResourcePageFiltered<T extends object>({
tableClassNames={kitDataGridTableClassNames}
>
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
{!hideHeader ? (
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
<FrameTitle className="text-balance">{title}</FrameTitle>
{description ? (
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>{description}</span>
<span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
<span className="tabular-nums">{filteredData.length} записей</span>
{selectedCount > 0 ? (
<>
<span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
<span>{selectedCount} выбрано</span>
</>
) : null}
</FrameDescription>
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
) : null}
</div>
{headerActions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{headerActions}
</div>
) : null}
</FrameHeader>
) : null}
) : (
<div className="flex-1" />
)}
{headerActions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{headerActions}</div> : null}
{toolbarExtra}
{showFiltersPopover ? (
<Filters
query={extraQuery}
fields={extraFilterFields}
onQueryChange={handleFiltersChange}
variant="advanced"
advancedMode="popover"
size="default"
labels={FILTERS_LABELS_RU}
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
{extraActiveCount > 0 ? (
<Badge size="xs" variant="secondary" radius="full">
{extraActiveCount}
</Badge>
) : null}
</Button>
}
/>
) : null}
{showSearch && primaryTextField ? (
<InputGroup className="w-[220px] max-w-full">
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchText}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder="Поиск"
aria-label={primaryTextField.label || 'Поиск'}
/>
</InputGroup>
) : null}
{hasDirtyFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
</FrameHeader>
<FramePanel className="p-0 shadow-none!">
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{showSearch && primaryTextField ? (
<InputGroup className="h-8 w-full min-w-[12rem] max-w-sm">
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchText}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder={primaryTextField.placeholder ?? 'Поиск…'}
aria-label={primaryTextField.label || 'Поиск'}
/>
</InputGroup>
) : null}
{showFiltersPopover ? (
<Filters
query={extraQuery}
fields={extraFilterFields}
onQueryChange={handleFiltersChange}
variant="advanced"
advancedMode="popover"
size="default"
labels={FILTERS_LABELS_RU}
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
{extraActiveCount > 0 ? (
<Badge size="xs" variant="secondary" radius="full">
{extraActiveCount}
</Badge>
) : null}
</Button>
}
/>
) : null}
{!showSearch && !showFiltersPopover && countedTabs.length === 0 ? (
<div />
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{countedTabs.length > 0 ? (
<StatusToggleGroup
items={countedTabs}
value={activeTab}
onValueChange={handleTabChange}
aria-label="Фильтр по статусу"
/>
) : null}
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
</Badge>
) : null}
{hasDirtyFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
</div>
</div>
<Separator />
{countedTabs.length > 0 ? (
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList variant="line" aria-label="Сохранённые виды" className="h-10! w-full justify-start gap-4 overflow-x-auto [scrollbar-width:none] px-4">
{countedTabs.map((t) => (
<TabsTrigger key={t.id} value={t.id} className="flex-none px-1 text-[13px]">
{t.label} · {t.count}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{virtualization ? (
<DataGridScrollArea orientation="vertical" style={{ height }}>
@@ -645,7 +620,10 @@ function ResourcePageFiltered<T extends object>({
<Separator />
<FrameFooter>
<FrameFooter className="flex-wrap justify-between gap-2">
<span className="text-muted-foreground text-xs tabular-nums">
{selectedCount > 0 ? `Выбрано ${selectedCount} · ` : ''}Показано {table.getRowModel().rows.length} из {filteredData.length}
</span>
<DataGridPagination sizes={[5, 10, 20, 50]} />
</FrameFooter>
</FramePanel>
+7 -10
View File
@@ -1,15 +1,14 @@
import type { ComponentProps, ReactNode } from 'react'
import type { ReactNode } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
export type SegmentedTabItem = {
value: string
label: string
count?: number
badgeVariant?: ComponentProps<typeof Badge>['variant']
/** @deprecated Счётчик теперь без плашки (линейные вкладки макета). */
badgeVariant?: string
icon?: ReactNode
}
@@ -25,7 +24,7 @@ interface SegmentedTabsProps {
}
/**
* Page-level section switcher — native shadcn Tabs, default (segmented) list.
* Page-level section switcher — line tabs as on the shared mockup boards.
* @see https://reui.io/preview/base/components/c-tabs-9
* @see https://reui.io/components/tabs
*/
@@ -47,15 +46,13 @@ export function SegmentedTabs({
orientation="horizontal"
className={cn('w-full min-w-0 gap-4', className)}
>
<TabsList className={cn('h-auto w-full min-w-0 justify-start', listClassName)}>
<TabsList variant="line" className={cn('h-10! w-full min-w-0 justify-start gap-0.5 overflow-x-auto [scrollbar-width:none]', listClassName)}>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
<TabsTrigger key={item.value} value={item.value} className="flex-none shrink-0 gap-1.5 px-4 text-sm">
{item.icon}
{item.label}
{item.count !== undefined ? (
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
{item.count}
</Badge>
<span className="text-muted-foreground font-mono text-xs font-normal">{item.count}</span>
) : null}
</TabsTrigger>
))}
@@ -0,0 +1,79 @@
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
import { useLiveObservationState } from '@/hooks/use-live-observation-state'
import { peersForDisplay, speakerAgentKnown, speakersForDisplay } from '@/lib/live-observation'
import type { LiveObservation, PeerRow, SpeakerRow } from '@/types/api'
const deadline = '2026-10-26T10:00:10.000Z'
function observation(quality: LiveObservation['quality'], errorCode?: LiveObservation['error_code']): LiveObservation {
return {
observed_at: '2026-10-26T10:00:00.000Z',
received_at: '2026-10-26T10:00:00.000Z',
last_success_at: '2026-10-26T10:00:00.000Z',
expires_at: deadline,
source: 'agent_bird_protocols',
quality,
error_code: errorCode,
}
}
const peer = { id: 'peer-1', enabled: true, session_state: 'Established', live_observation: observation('ok') } as PeerRow
const unhealthySpeaker = { id: 'speaker-1', live: { agent_ok: false, agent_observation: observation('stale', 'reported_unhealthy') } } as SpeakerRow
function NetworkHarness() {
useLiveObservationState([peer], [])
// New array identities let memoized chart bars recompute at the deadline.
return <DashboardNetworkHealth peers={[...peersForDisplay([peer], false)]} speakers={[]} jobs={[]} />
}
function SpeakerHarness() {
useLiveObservationState([], [unhealthySpeaker])
return <span>{speakerAgentKnown(speakersForDisplay([unhealthySpeaker], false)[0]) ? 'known' : 'unknown'}</span>
}
describe('live observation deadline rendering', () => {
beforeEach(() => vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true))
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('rerenders memoized network bars when a fresh peer snapshot expires', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.setSystemTime(new Date('2026-10-26T10:00:00.000Z'))
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
try {
await act(async () => root.render(<NetworkHarness />))
expect(host.textContent).toContain('100%')
await act(async () => vi.advanceTimersByTime(10_001))
expect(host.textContent).toContain('0%')
expect(host.textContent).toContain('нет данных')
} finally {
await act(async () => root.unmount())
host.remove()
}
})
it('rerenders a reported unhealthy speaker at its deadline', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.setSystemTime(new Date('2026-10-26T10:00:00.000Z'))
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
try {
await act(async () => root.render(<SpeakerHarness />))
expect(host.textContent).toBe('known')
await act(async () => vi.advanceTimersByTime(10_001))
expect(host.textContent).toBe('unknown')
} finally {
await act(async () => root.unmount())
host.remove()
}
})
})
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react'
import { nextObservationExpiry } from '@/lib/live-observation'
import type { PeerRow, SpeakerRow } from '@/types/api'
/** Rerender at the server-provided observation deadline and on browser connectivity changes. */
export function useLiveObservationState(peers: PeerRow[], speakers: SpeakerRow[]): { offline: boolean; disconnectedAt: number | null; revision: number } {
const [offline, setOffline] = useState(() => typeof navigator !== 'undefined' && !navigator.onLine)
const [disconnectedAt, setDisconnectedAt] = useState<number | null>(() =>
typeof navigator !== 'undefined' && !navigator.onLine ? Date.now() : null,
)
const [clockTick, setClockTick] = useState(0)
useEffect(() => {
const update = () => {
const disconnected = !navigator.onLine
setOffline(disconnected)
if (disconnected) setDisconnectedAt(Date.now())
}
window.addEventListener('online', update)
window.addEventListener('offline', update)
return () => {
window.removeEventListener('online', update)
window.removeEventListener('offline', update)
}
}, [])
useEffect(() => {
const next = nextObservationExpiry(peers, speakers)
if (next === undefined) return
const delay = Math.min(2_147_483_647, Math.max(1, next - Date.now() + 1))
const timer = window.setTimeout(() => setClockTick((tick) => tick + 1), delay)
return () => window.clearTimeout(timer)
}, [peers, speakers, clockTick])
return { offline, disconnectedAt, revision: clockTick }
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from 'vitest'
import {
peerSessionEstablished,
peerSessionKnown,
speakerAgentKnown,
speakerAgentOnline,
speakerBgpCounts,
observationQuality,
nextObservationExpiry,
peersForDisplay,
speakersForDisplay,
snapshotUnavailable,
} from './live-observation'
import type { LiveObservation, PeerRow, SpeakerRow } from '@/types/api'
import { aggregateNetworkMetrics } from '@/queries/overview'
import { deploymentProgress } from './metrics/deployment-progress'
import { speakerCapacityBars } from './metrics/peer-capacity-bars'
const observation = (quality: LiveObservation['quality']): LiveObservation => ({
observed_at: null,
received_at: null,
last_success_at: null,
source: 'agent_bird_protocols',
quality,
})
describe('live observation compatibility', () => {
it('uses legacy live fields when observation metadata is absent', () => {
const peer = { session_state: 'Established' } as PeerRow
const speaker = { live: { agent_ok: true, bgp_established: 2, bgp_sessions_total: 3 } } as SpeakerRow
expect(peerSessionEstablished(peer)).toBe(true)
expect(speakerAgentOnline(speaker)).toBe(true)
expect(speakerBgpCounts(speaker)).toBe('2 / 3')
})
it('keeps stale and missing observations separate from down or zero', () => {
const peer = { session_state: null, last_known_session_state: 'Established', live_observation: observation('stale') } as PeerRow
const speaker = { live: { agent_ok: false, agent_observation: observation('unknown'), bgp_established: null, bgp_sessions_total: null, bgp_observation: observation('unreachable') } } as SpeakerRow
expect(peerSessionKnown(peer)).toBe(false)
expect(peerSessionEstablished(peer)).toBe(false)
expect(speakerAgentKnown(speaker)).toBe(false)
expect(speakerAgentOnline(speaker)).toBe(false)
expect(speakerBgpCounts(speaker)).toBeUndefined()
})
it('counts only enabled, observed peers in established metrics', () => {
const peers = [
{ enabled: false, session_state: 'Established' },
{ enabled: true, session_state: 'Established', live_observation: observation('stale') },
{ enabled: true, session_state: 'Established', live_observation: observation('ok') },
] as PeerRow[]
const metrics = aggregateNetworkMetrics(peers, [])
expect(metrics.peersObserved).toBe(1)
expect(metrics.peersEstablished).toBe(1)
})
it('includes published but unconfirmed speakers in deployment denominator', () => {
const speakers = [
{ published_revision_id: 'rev-1', last_applied_revision_id: 'rev-1' },
{ published_revision_id: 'rev-1', last_applied_revision_id: null },
] as SpeakerRow[]
expect(deploymentProgress(speakers)).toMatchObject({ mode: 'revision', synced: 1, total: 2, percent: 50 })
expect(deploymentProgress([speakers[1]])).toMatchObject({ mode: 'revision', synced: 0, total: 1, percent: 0 })
})
it('treats explicit agent health failure as a known negative result', () => {
const speaker = { live: {
agent_ok: false,
agent_observation: { ...observation('stale'), error_code: 'reported_unhealthy' },
} } as SpeakerRow
expect(speakerAgentKnown(speaker)).toBe(true)
expect(speakerAgentOnline(speaker)).toBe(false)
})
it('masks cached fresh status after a snapshot API failure', () => {
const peer = { session_state: 'Established', live_observation: observation('ok'), session_mismatch: true } as PeerRow
const speaker = { live: { agent_ok: true, agent_observation: observation('ok'), bgp_established: 2, bgp_sessions_total: 2, bgp_observation: observation('ok') } } as SpeakerRow
expect(aggregateNetworkMetrics([peer], [speaker])).toMatchObject({ peersEstablished: 1, speakersOnline: 1 })
const peers = peersForDisplay([peer], true)
const speakers = speakersForDisplay([speaker], true)
expect(aggregateNetworkMetrics(peers, speakers)).toMatchObject({ peersEstablished: 0, peersMismatch: 0, speakersOnline: 0 })
expect(peerSessionKnown(peers[0])).toBe(false)
expect(speakerAgentKnown(speakers[0])).toBe(false)
expect(speakerBgpCounts(speakers[0])).toBeUndefined()
expect(snapshotUnavailable(true, false, null, 100)).toBe(true)
expect(snapshotUnavailable(false, true, 200, 100)).toBe(true)
expect(snapshotUnavailable(false, false, 200, 100)).toBe(true)
expect(snapshotUnavailable(false, false, 200, 201)).toBe(false)
})
it('expires an ok observation at the server deadline', () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-10-26T10:00:00.000Z'))
const live = { ...observation('ok'), expires_at: '2026-10-26T10:00:10.000Z' }
const peer = { session_state: 'Established', live_observation: live } as PeerRow
const speaker = { id: 'speaker-1', endpoint: 'speaker.example', live: { agent_ok: true, agent_observation: live, bgp_established: 2, bgp_sessions_total: 2, bgp_observation: live } } as SpeakerRow
expect(nextObservationExpiry([peer], [speaker])).toBe(Date.parse(live.expires_at))
expect(peerSessionEstablished(peer)).toBe(true)
expect(speakerAgentOnline(speaker)).toBe(true)
expect(speakerCapacityBars([speaker])).toHaveLength(1)
vi.setSystemTime(new Date(live.expires_at))
expect(observationQuality(live)).toBe('stale')
expect(peerSessionEstablished(peer)).toBe(false)
expect(speakerAgentOnline(speaker)).toBe(false)
expect(speakerCapacityBars([speaker])).toHaveLength(0)
expect(nextObservationExpiry([peer], [speaker])).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
})
+120
View File
@@ -0,0 +1,120 @@
import type { LiveObservation, PeerRow, SpeakerRow } from '@/types/api'
const QUALITY_LABEL: Record<LiveObservation['quality'], string> = {
ok: 'актуально',
stale: 'данные устарели',
unreachable: 'опрос недоступен',
unknown: 'нет наблюдения',
disabled: 'опрос выключен',
}
function expired(observation: LiveObservation, now = Date.now()): boolean {
if (!observation.expires_at) return false
const deadline = Date.parse(observation.expires_at)
return !Number.isFinite(deadline) || deadline <= now
}
export function observationQuality(observation: LiveObservation, now = Date.now()): LiveObservation['quality'] {
return observation.quality === 'ok' && expired(observation, now) ? 'stale' : observation.quality
}
const unavailableObservation: LiveObservation = {
observed_at: null,
received_at: null,
last_success_at: null,
source: 'none',
quality: 'unknown',
}
/** A successful GET after reconnect is required before cached live values are trusted again. */
export function snapshotUnavailable(queryFailed: boolean, offline: boolean, disconnectedAt: number | null, dataUpdatedAt: number): boolean {
return queryFailed || offline || (disconnectedAt !== null && dataUpdatedAt <= disconnectedAt)
}
/** Hide cached live values when their snapshot request fails or the browser goes offline. */
export function peersForDisplay(peers: PeerRow[], unavailable: boolean): PeerRow[] {
if (!unavailable) return peers
return peers.map((peer) => ({
...peer,
session_state: null,
live_observation: unavailableObservation,
session_on_speakers: [],
established_on_speakers: [],
session_mismatch: false,
}))
}
export function speakersForDisplay(speakers: SpeakerRow[], unavailable: boolean): SpeakerRow[] {
if (!unavailable) return speakers
return speakers.map((speaker) => ({
...speaker,
live: speaker.live && {
...speaker.live,
agent_ok: undefined,
agent_observation: unavailableObservation,
bgp_poll_ok: undefined,
bgp_established: null,
bgp_sessions_total: null,
sessions: [],
bgp_observation: unavailableObservation,
},
}))
}
/** Earliest server deadline; callers can schedule a render without inventing a client TTL. */
export function nextObservationExpiry(peers: PeerRow[], speakers: SpeakerRow[], now = Date.now()): number | undefined {
let next: number | undefined
const visit = (observation?: LiveObservation) => {
if (!observation?.expires_at || (observation.quality !== 'ok' && observation.error_code !== 'reported_unhealthy')) return
const deadline = Date.parse(observation.expires_at)
if (Number.isFinite(deadline) && deadline > now) next = Math.min(next ?? deadline, deadline)
}
for (const peer of peers) visit(peer.live_observation)
for (const speaker of speakers) {
visit(speaker.live?.agent_observation)
visit(speaker.live?.bgp_observation)
}
return next
}
function observedTime(observation: LiveObservation): string | undefined {
const timestamp = observation.observed_at ?? observation.received_at ?? observation.last_success_at
if (!timestamp) return undefined
const date = new Date(timestamp)
return Number.isNaN(date.getTime()) ? undefined : date.toLocaleString('ru-RU')
}
export function observationHint(observation?: LiveObservation): string | undefined {
if (!observation) return undefined
const at = observedTime(observation)
const label = QUALITY_LABEL[observationQuality(observation)]
return at ? `${label} · ${at}` : label
}
export function peerSessionCurrent(peer: PeerRow): boolean {
return !peer.live_observation || observationQuality(peer.live_observation) === 'ok'
}
export function peerSessionEstablished(peer: PeerRow): boolean {
return peerSessionCurrent(peer) && peer.session_state === 'Established'
}
export function peerSessionKnown(peer: PeerRow): boolean {
return peerSessionCurrent(peer) && peer.session_state != null && peer.session_state !== ''
}
export function speakerAgentKnown(speaker: SpeakerRow): boolean {
const observation = speaker.live?.agent_observation
return (!observation || (!expired(observation) && (observationQuality(observation) === 'ok' || observation.error_code === 'reported_unhealthy'))) && speaker.live?.agent_ok != null
}
export function speakerAgentOnline(speaker: SpeakerRow): boolean {
return speakerAgentKnown(speaker) && speaker.live?.agent_ok === true
}
export function speakerBgpCounts(speaker: SpeakerRow): string | undefined {
const live = speaker.live
if (!live || (live.bgp_observation && observationQuality(live.bgp_observation) !== 'ok')) return undefined
if (live.bgp_established == null || live.bgp_sessions_total == null) return undefined
return `${live.bgp_established} / ${live.bgp_sessions_total}`
}
@@ -1,4 +1,5 @@
import type { SpeakerRow } from '@/types/api'
import { speakerAgentOnline } from '@/lib/live-observation'
import type { DeploymentProgress } from './types'
@@ -7,9 +8,7 @@ export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress {
return { percent: 0, synced: 0, total: 0, mode: 'online' }
}
const withRevision = speakers.filter(
(s) => s.published_revision_id && s.last_applied_revision_id,
)
const withRevision = speakers.filter((s) => s.published_revision_id)
if (withRevision.length > 0) {
const synced = withRevision.filter(
(s) => s.published_revision_id === s.last_applied_revision_id,
@@ -22,7 +21,7 @@ export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress {
}
}
const online = speakers.filter((s) => s.live?.agent_ok).length
const online = speakers.filter(speakerAgentOnline).length
return {
percent: Math.round((online / speakers.length) * 100),
synced: online,
@@ -1,4 +1,5 @@
import type { PeerRow, SpeakerRow } from '@/types/api'
import { observationQuality, peerSessionEstablished, peerSessionKnown } from '@/lib/live-observation'
import type { CapacityBar } from './types'
@@ -12,25 +13,27 @@ function speakerLabel(speaker: SpeakerRow): string {
export function peerCapacityBars(peers: PeerRow[], max = 24): CapacityBar[] {
return peers
.filter((p) => p.enabled !== false)
.filter((p) => p.enabled !== false && peerSessionKnown(p))
.slice(0, max)
.map((peer) => ({
id: peer.id,
name: peerLabel(peer),
value: peer.session_state === 'Established' ? 100 : peer.session_state ? 40 : 10,
value: peerSessionEstablished(peer) ? 100 : 40,
}))
}
export function speakerCapacityBars(speakers: SpeakerRow[], max = 24): CapacityBar[] {
return speakers.slice(0, max).map((speaker) => {
const online = speaker.live?.agent_ok === true
return speakers.filter((speaker) =>
(!speaker.live?.bgp_observation || observationQuality(speaker.live.bgp_observation) === 'ok') &&
speaker.live?.bgp_established != null && speaker.live.bgp_sessions_total != null,
).slice(0, max).map((speaker) => {
const established = speaker.live?.bgp_established ?? 0
const total = speaker.live?.bgp_sessions_total ?? 0
const ratio = total > 0 ? Math.round((established / total) * 100) : online ? 100 : 15
const ratio = total > 0 ? Math.round((established / total) * 100) : 0
return {
id: speaker.id,
name: speakerLabel(speaker),
value: online ? ratio : 10,
value: ratio,
}
})
}
@@ -1,13 +1,15 @@
import type { PeerRow } from '@/types/api'
import { peerSessionEstablished, peerSessionKnown } from '@/lib/live-observation'
import type { BreakdownSlice } from './types'
export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
const enabled = peers.filter((p) => p.enabled !== false)
const established = enabled.filter((p) => p.session_state === 'Established').length
const established = enabled.filter(peerSessionEstablished).length
const pending = enabled.filter(
(p) => p.session_state && p.session_state !== 'Established',
(p) => peerSessionKnown(p) && !peerSessionEstablished(p),
).length
const unknown = enabled.length - established - pending
const disabled = peers.length - enabled.length
const slices: BreakdownSlice[] = []
@@ -27,6 +29,14 @@ export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
color: 'var(--color-warning)',
})
}
if (unknown > 0) {
slices.push({
key: 'unknown',
label: 'Нет наблюдения',
count: unknown,
color: 'var(--color-muted-foreground)',
})
}
if (disabled > 0) {
slices.push({
key: 'disabled',
@@ -1,6 +1,7 @@
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
import { jobKindRu, jobStatusRu } from '@/lib/ui-labels'
import { speakerAgentKnown } from '@/lib/live-observation'
import type { PlatformActivityItem } from './types'
@@ -45,11 +46,11 @@ export function recentPlatformActivity(
})
}
for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || s.live?.agent_ok === false).slice(0, 2)) {
for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || (speakerAgentKnown(s) && s.live?.agent_ok === false)).slice(0, 2)) {
items.push({
id: `speaker-${speaker.id}`,
message: `Нода недоступна: ${speaker.live?.label ?? speaker.endpoint}`,
status: speaker.live?.agent_ok === false ? 'error' : 'warning',
message: `Проблема опроса ноды: ${speaker.live?.label ?? speaker.endpoint}`,
status: speakerAgentKnown(speaker) && speaker.live?.agent_ok === false ? 'error' : 'warning',
kind: 'network',
})
}
+1
View File
@@ -5,6 +5,7 @@ import { RouterProvider } from '@tanstack/react-router'
import { Toaster } from '@evobgp/ui/components/sonner'
import '@evobgp/ui/globals.css'
import './portal-theme.css'
import { queryClient } from '@/lib/queryClient'
import { createRouter } from '@/lib/router'
+44
View File
@@ -0,0 +1,44 @@
/* Плотность портала. Палитра, шрифты и таблицы — нативные токены ReUI из @authportal/ui/globals.css. */
/* Ячейки таблиц как в MikrotikManager: py-3, поля первой и последней колонки pl-5 / pr-4. */
html [data-slot='data-grid-table'] :is(th, td) {
padding-block: 0.75rem;
}
html [data-slot='data-grid-table'] :is(th, td):first-child {
padding-inline-start: 1.25rem;
}
html [data-slot='data-grid-table'] :is(th, td):last-child {
padding-inline-end: 1rem;
}
html[data-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
padding-block: 0.25rem;
}
html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
gap: 0.75rem;
}
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
html [data-slot='sidebar-wrapper'] :focus-visible:not(input, textarea, select, [data-slot='input-group-control']),
html #main-content :focus-visible:not(input, textarea, select, [data-slot='input-group-control']) {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* Поля ввода: спокойная подсветка фокуса — тонкая рамка и мягкое кольцо вместо яркого синего контура. */
html input:focus-visible,
html textarea:focus-visible,
html [data-slot='input-group']:has([data-slot='input-group-control']:focus-visible) {
border-color: color-mix(in oklch, var(--ring) 45%, var(--input));
--tw-ring-color: color-mix(in oklch, var(--ring) 18%, transparent);
}
@media (prefers-reduced-motion: reduce) {
html #main-content *,
html [data-slot='sidebar-wrapper'] * {
animation: none !important;
transition: none !important;
}
}
+29
View File
@@ -2,6 +2,8 @@ import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query
import { toast } from 'sonner'
import { apiJSON, apiMutate } from '@/lib/api-client'
import { overviewKeys } from '@/queries/overview'
import type { operations } from '@/types/api.gen'
import type {
BgpPeerCreate,
BgpPeerPatch,
@@ -27,6 +29,33 @@ export const networkKeys = {
bird: () => [...networkKeys.all, 'bird'] as const,
}
type TelemetryRefreshResponse = operations['requestTelemetryRefresh']['responses'][202]['content']['application/json']
/** Request a background poll; GET queries continue reading the latest saved snapshots. */
export function useRequestTelemetryRefreshMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: () =>
apiMutate<TelemetryRefreshResponse>('/v1/telemetry/refresh', 'POST', undefined, {
idempotent: false,
}),
onSuccess: ({ requested }) => {
if (requested) {
toast.success('Запрос на обновление телеметрии принят. Данные появятся после фонового опроса.')
} else {
toast.message('Запрос объединён с недавним обновлением или нет доступных целей.')
}
void qc.invalidateQueries({ queryKey: networkKeys.peers() })
void qc.invalidateQueries({ queryKey: networkKeys.discovered() })
void qc.invalidateQueries({ queryKey: networkKeys.speakers() })
void qc.invalidateQueries({ queryKey: overviewKeys.peers() })
void qc.invalidateQueries({ queryKey: overviewKeys.speakers() })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось запросить обновление телеметрии'),
})
}
export function networkPeersQueryOptions() {
return queryOptions<PeersResponse>({
queryKey: networkKeys.peers(),
+10 -3
View File
@@ -1,5 +1,6 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import { peerSessionCurrent, peerSessionEstablished, peerSessionKnown, speakerAgentKnown, speakerAgentOnline } from '@/lib/live-observation'
import type {
JobRow,
JobsResponse,
@@ -78,9 +79,11 @@ export function overviewHealthQueryOptions() {
export type NetworkMetrics = {
peersTotal: number
peersEnabled: number
peersObserved: number
peersEstablished: number
peersMismatch: number
speakersTotal: number
speakersObserved: number
speakersOnline: number
}
@@ -89,15 +92,19 @@ export function aggregateNetworkMetrics(
speakers: SpeakerRow[],
): NetworkMetrics {
const peersEnabled = peers.filter((p) => p.enabled !== false).length
const peersEstablished = peers.filter((p) => p.session_state === 'Established').length
const peersMismatch = peers.filter((p) => p.session_mismatch).length
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
const peersObserved = peers.filter((p) => p.enabled !== false && peerSessionKnown(p)).length
const peersEstablished = peers.filter((p) => p.enabled !== false && peerSessionEstablished(p)).length
const peersMismatch = peers.filter((p) => p.session_mismatch && peerSessionCurrent(p)).length
const speakersOnline = speakers.filter(speakerAgentOnline).length
const speakersObserved = speakers.filter(speakerAgentKnown).length
return {
peersTotal: peers.length,
peersEnabled,
peersObserved,
peersEstablished,
peersMismatch,
speakersTotal: speakers.length,
speakersObserved,
speakersOnline,
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import { AppShell } from '@/components/layout/app-shell'
import { PortalShell } from '@/components/layout/portal-shell'
interface RouterContext {
queryClient: import('@tanstack/react-query').QueryClient
@@ -11,8 +11,8 @@ export const Route = createRootRouteWithContext<RouterContext>()({
function RootComponent() {
return (
<AppShell>
<PortalShell>
<Outlet />
</AppShell>
</PortalShell>
)
}
+1 -1
View File
@@ -109,7 +109,7 @@ function AccessComponent() {
description="API-ключи плоскости управления и текущая сессия."
actions={
canManageKeys ? (
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
+106 -42
View File
@@ -1,20 +1,30 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQueries, useQuery } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { Boxes, Play, RefreshCw, Search, Share2 } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
import { OpsDashboard } from '@/components/reui-kit'
import { chartPanelGridClassName } from '@/lib/ui-surface'
import { DashboardPrefixTrend } from '@/components/dashboard/dashboard-prefix-trend'
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
import { ActivityFeed } from '@/components/portal/canvas-blocks'
import { fmtWhen } from '@/components/portal/format'
import type { Tone } from '@/components/portal/tone'
import { KpiStatGrid } from '@/components/reui-kit'
import { PageHeader } from '@/components/page-header'
import { AppCards, type AppCardItem } from '@/components/portal/canvas-blocks'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { can } from '@/lib/auth'
import { useLiveObservationState } from '@/hooks/use-live-observation-state'
import { peersForDisplay, snapshotUnavailable, speakersForDisplay } from '@/lib/live-observation'
import { useRequestTelemetryRefreshMutation } from '@/queries/network'
import {
overviewJobsQueryOptions,
overviewModulesQueryOptions,
overviewPeersQueryOptions,
overviewRevisionsQueryOptions,
overviewSpeakersQueryOptions,
} from '@/queries/overview'
import { settingsQueryOptions } from '@/queries/settings'
@@ -37,7 +47,10 @@ function parseShowQuickActions(value: unknown): boolean {
* @see https://reui.io/preview/base/chart-27
*/
function DashboardComponent() {
const navigate = useNavigate()
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const telemetryRefresh = useRequestTelemetryRefreshMutation()
const canRefreshTelemetry = can('bgp:network:read')
const settingsQ = useQuery(settingsQueryOptions())
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
@@ -50,10 +63,11 @@ function DashboardComponent() {
],
})
const revisionsQ = useQuery(overviewRevisionsQueryOptions())
const [modulesQ, peersQ, speakersQ, jobsQ] = results
const initialLoading =
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || jobsQ.isLoading
const refreshing = results.some((r) => r.isFetching && !r.isLoading)
const refreshing = telemetryRefresh.isPending || results.some((r) => r.isFetching && !r.isLoading)
if (!lastUpdated && !initialLoading && results.every((r) => r.isSuccess || r.isError)) {
setTimeout(() => setLastUpdated(new Date()), 0)
@@ -61,47 +75,97 @@ function DashboardComponent() {
function refetchAll() {
setLastUpdated(null)
results.forEach((r) => r.refetch())
void modulesQ.refetch()
void jobsQ.refetch()
void revisionsQ.refetch()
if (canRefreshTelemetry) {
telemetryRefresh.mutate()
} else {
void peersQ.refetch()
void speakersQ.refetch()
}
}
const modules = modulesQ.data?.items ?? []
const peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? []
const rawPeers = peersQ.data?.items ?? []
const rawSpeakers = speakersQ.data?.items ?? []
const { offline, disconnectedAt } = useLiveObservationState(rawPeers, rawSpeakers)
const peersUnavailable = snapshotUnavailable(peersQ.isError, offline, disconnectedAt, peersQ.dataUpdatedAt)
const speakersUnavailable = snapshotUnavailable(speakersQ.isError, offline, disconnectedAt, speakersQ.dataUpdatedAt)
const peers = [...peersForDisplay(rawPeers, peersUnavailable)]
const speakers = [...speakersForDisplay(rawSpeakers, speakersUnavailable)]
const jobs = jobsQ.data?.items ?? []
const kpiCards = buildDashboardKpiCards({ modules, peers, speakers, jobs })
const activity = jobs.slice(0, 4).map((j) => {
const failed = ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase())
const running = j.status === 'running' || j.status === 'queued'
const tone: Tone = failed ? 'er' : running ? 'in' : 'ok'
return {
t: fmtWhen(j.finished_at ?? j.started_at ?? j.created_at).replace(/^сегодня /, ''),
title: j.kind,
text: j.error ?? `Статус: ${j.status}`,
label: failed ? 'Error' : running ? 'Running' : 'Info',
tone,
}
})
const quickActions: AppCardItem[] = [
{ id: 'new-module', title: 'Создать модуль', desc: 'AS, CDN, домены или IP-диапазоны', badge: 'Модули', meta: '', icon: <Boxes />, onSelect: () => void navigate({ to: '/modules', search: { create: true } }) },
{ id: 'lookup', title: 'Проверить IP или домен', desc: 'Есть ли адрес в списках и community', badge: 'Проверка', meta: '', icon: <Search />, onSelect: () => void navigate({ to: '/lookup', search: { q: '' } }) },
{ id: 'add-peer', title: 'Добавить пира', desc: 'Новый BGP-сосед на спикере', badge: 'Сеть', meta: '', icon: <Share2 />, onSelect: () => void navigate({ to: '/network', search: { tab: 'peers' } }) },
{ id: 'deploy', title: 'Деплой', desc: 'Публикация ревизии на спикеры', badge: 'Операции', badgeVariant: 'warning-light', meta: '', icon: <Play />, onSelect: () => void navigate({ to: '/operations', search: { tab: 'revisions' } }) },
]
if (initialLoading) {
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader title="Обзор" description="Метрики, модули и активность" />
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<Skeleton className="h-48 w-full rounded-xl" />
<Skeleton className="h-72 w-full rounded-xl" />
</div>
)
}
return (
<OpsDashboard
title="Обзор"
description={
lastUpdated
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Состояние панели управления EvoBGP.'
}
headerActions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
kpiCards={kpiCards}
isLoading={initialLoading}
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
charts={
<div className={chartPanelGridClassName}>
<DashboardNetworkHealth
peers={peers}
speakers={speakers}
jobs={jobs}
loading={refreshing && peers.length === 0 && speakers.length === 0}
/>
<DashboardOperationsBreakdown
jobs={jobs}
modules={modules}
loading={refreshing && jobs.length === 0 && modules.length === 0}
/>
</div>
}
/>
<div className="flex min-w-0 flex-col gap-4 md:gap-6">
<PageHeader
title="Обзор"
description={
lastUpdated
? `Метрики, модули и активность · данные проверены ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Метрики, модули и активность'
}
actions={
<>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<Button onClick={() => void navigate({ to: '/operations', search: { tab: 'revisions' } })}>Применить конфигурацию</Button>
</>
}
/>
<KpiStatGrid cards={kpiCards} aria-label="Ключевые метрики" />
{showQuickActions ? (
<AppCards title="Быстрые действия" description="Частые операции в один клик" items={quickActions} />
) : null}
<DashboardPrefixTrend revisions={revisionsQ.data?.items ?? []} loading={revisionsQ.isLoading} />
<ModulesListGrid items={modules.slice(0, 5)} isLoading={modulesQ.isLoading} title="Модули" />
{activity.length > 0 ? (
<ActivityFeed
title="Активность"
description="Последние события плоскости управления"
items={activity}
action={
<Button variant="outline" onClick={() => void navigate({ to: '/operations', search: { tab: 'jobs' } })}>
Весь журнал
</Button>
}
/>
) : null}
</div>
)
}
+3 -3
View File
@@ -97,14 +97,14 @@ function DirectoriesComponent() {
}
const addCommunityButton = canWrite ? (
<Button size="sm" type="button" onClick={openCreateCommunity}>
<Button type="button" onClick={openCreateCommunity}>
<Plus />
Добавить
</Button>
) : null
const addDohButton = canWrite ? (
<Button size="sm" type="button" onClick={openCreateDoh}>
<Button type="button" onClick={openCreateDoh}>
<Plus />
Добавить
</Button>
@@ -118,7 +118,7 @@ function DirectoriesComponent() {
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void communitiesQ.refetch()
void dohQ.refetch()
@@ -69,19 +69,19 @@ function ModuleDetailComponent() {
const headerActions = (
<>
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
<Button variant="outline" render={<Link to="/modules" />}>
<ArrowLeft />
К списку
</Button>
{canWrite && mod ? (
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Button variant="outline" onClick={() => setEditOpen(true)}>
<Pencil />
Редактировать
</Button>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => void refreshAll()}
disabled={detail.isFetching}
>
+38 -22
View File
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Plus, RefreshCw } from 'lucide-react'
import { BoxesIcon, CircleAlertIcon, PlusIcon, PowerIcon, RefreshCwIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
@@ -8,6 +8,10 @@ import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
import { ModuleCreateDialog } from '@/components/modules/module-create-dialog'
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
import { PageHeader } from '@/components/page-header'
import { PageTab, PageTabsList } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Tabs, TabsContent } from '@evobgp/ui/components/tabs'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { sessionCanWriteModules } from '@/lib/auth'
@@ -52,15 +56,16 @@ function ModulesListComponent() {
description="Маршрутные списки: AS, CDN, домены, IP-диапазоны"
actions={
<>
<Button
variant="outline"
size="sm"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw className={query.isFetching ? 'animate-spin' : ''} />
<Button variant="outline" onClick={() => query.refetch()} disabled={query.isFetching}>
<RefreshCwIcon className={query.isFetching ? 'animate-spin' : ''} />
Обновить
</Button>
{canWrite ? (
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon />
Создать модуль
</Button>
) : null}
</>
}
/>
@@ -80,20 +85,31 @@ function ModulesListComponent() {
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()}
>
{(items) => (
<ModulesListGrid
items={items}
isLoading={query.isFetching && !query.isLoading}
actions={
canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus />
Создать
</Button>
) : undefined
}
/>
)}
{(items) => {
const enabled = items.filter((m) => m.enabled !== false).length
const never = items.filter((m) => !m.last_refreshed_at).length
return (
<>
<KpiStrip
aria-label="Показатели"
items={[
kpi('all', 'Модулей', String(items.length), { icon: BoxesIcon, badge: 'в арендаторе' }),
kpi('on', 'Включено', String(enabled), { icon: PowerIcon, tone: 'ok', badge: 'участвуют в ревизии' }),
kpi('off', 'Выключено', String(items.length - enabled), { icon: PowerIcon, badge: 'не в ревизии' }),
kpi('never', 'Не обновлялись', String(never), { icon: CircleAlertIcon, tone: never ? 'wn' : 'neutral', badge: 'нет первого refresh' }),
]}
/>
<Tabs defaultValue="list" className="min-w-0">
<PageTabsList>
<PageTab value="list" label="Модули" count={items.length} />
</PageTabsList>
<TabsContent value="list" className="min-w-0">
<ModulesListGrid items={items} isLoading={query.isFetching && !query.isLoading} />
</TabsContent>
</Tabs>
</>
)
}}
</QueryState>
<ModuleCreateDialog
+1 -1
View File
@@ -66,7 +66,7 @@ function MonitoringComponent() {
: ''
}`}
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
+29 -18
View File
@@ -9,11 +9,15 @@ import { NetworkKpi } from '@/components/network/network-kpi'
import { NetworkPeersCard } from '@/components/network/network-peers-card'
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
import { PageHeader } from '@/components/page-header'
import { SignalAlert } from '@/components/portal/bits'
import { useLiveObservationState } from '@/hooks/use-live-observation-state'
import { peersForDisplay, snapshotUnavailable, speakersForDisplay } from '@/lib/live-observation'
import {
networkBirdQueryOptions,
networkDiscoveredPeersQueryOptions,
networkPeersQueryOptions,
networkSpeakersQueryOptions,
useRequestTelemetryRefreshMutation,
} from '@/queries/network'
type NetworkTab = 'peers' | 'discovered' | 'speakers'
@@ -49,41 +53,48 @@ function NetworkComponent() {
})
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const telemetryRefresh = useRequestTelemetryRefreshMutation()
const refreshing =
peersQ.isFetching ||
discoveredQ.isFetching ||
speakersQ.isFetching ||
birdQ.isFetching
const peers = peersQ.data?.items ?? []
const rawPeers = peersQ.data?.items ?? []
const discovered = discoveredQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? []
const rawSpeakers = speakersQ.data?.items ?? []
const { offline, disconnectedAt } = useLiveObservationState(rawPeers, rawSpeakers)
const peers = [...peersForDisplay(rawPeers, snapshotUnavailable(peersQ.isError, offline, disconnectedAt, peersQ.dataUpdatedAt))]
const speakers = [...speakersForDisplay(rawSpeakers, snapshotUnavailable(speakersQ.isError, offline, disconnectedAt, speakersQ.dataUpdatedAt))]
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
function refetchAll() {
void peersQ.refetch()
void discoveredQ.refetch()
void speakersQ.refetch()
void birdQ.refetch()
}
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Сеть"
description="BGP-пиры, автообнаружение и спикеры"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
Обновить
<Button variant="outline" onClick={() => telemetryRefresh.mutate()} disabled={telemetryRefresh.isPending}>
<RefreshCw className={telemetryRefresh.isPending ? 'animate-spin' : undefined} />
Запросить обновление
</Button>
}
/>
{discovered.length > 0 ? (
<SignalAlert
tone="wn"
title={`Есть пиры на одобрение: ${discovered.length}.`}
action={
<Button variant="outline" onClick={() => navigate({ search: { tab: 'discovered' } })}>
Показать
</Button>
}
>
{discovered[0]?.neighbor}
{discovered[0]?.remote_asn ? ` · AS${discovered[0].remote_asn}` : ''} обнаружен автоматически · после одобрения пир попадёт в конфигурацию BIRD.
</SignalAlert>
) : null}
<NetworkKpi
peers={peers}
speakers={speakers}
bird={birdQ.data}
bird={snapshotUnavailable(birdQ.isError, offline, disconnectedAt, birdQ.dataUpdatedAt) ? undefined : birdQ.data}
loading={overviewLoading || birdQ.isLoading}
/>
+30 -37
View File
@@ -10,9 +10,7 @@ import { SegmentedTabs, TabsContent } from '@/components/segmented-tabs'
import {
FrameSection,
KpiStatGrid,
QuickActionGrid,
type KpiStatItem,
type QuickActionItem,
} from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import {
@@ -27,6 +25,7 @@ import { SelectMenu } from '@/components/select-field'
import { OperationsJobsCard } from '@/components/operations/operations-jobs-card'
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
import { PageHeader } from '@/components/page-header'
import { SignalAlert } from '@/components/portal/bits'
import { QueryState } from '@/components/query-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
@@ -111,6 +110,7 @@ function OperationsComponent() {
const revisions = useMemo(() => revisionsQ.data?.items ?? [], [revisionsQ.data?.items])
const jobs = useMemo(() => jobsQ.data?.items ?? [], [jobsQ.data?.items])
const failedJobs = jobs.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
const nameById = moduleNameById(modulesQ.data?.items ?? [])
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
@@ -157,51 +157,44 @@ function OperationsComponent() {
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось перезагрузить'),
})
const quickActions: QuickActionItem[] = useMemo(
() => [
{
id: 'apply',
title: 'Применить',
description: 'Деплой текущей ревизии на все BIRD-агенты.',
icon: <Play aria-hidden />,
iconClassName: 'text-primary',
badge: 'Выполнить',
disabled: applyMutation.isPending || revisions.length === 0,
onClick: () => setApplyOpen(true),
},
{
id: 'bird-reload',
title: 'Перезагрузка BIRD',
description: 'Выполнить birdc configure на локальном демоне.',
icon: <RotateCcw aria-hidden />,
iconClassName: 'text-warning',
badge: 'Выполнить',
disabled: birdReloadMutation.isPending,
onClick: () => setReloadOpen(true),
},
],
[applyMutation.isPending, birdReloadMutation.isPending, revisions.length],
)
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Ревизии и операции"
description="Деплой конфигурации, управление ревизиями и задачами"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<Button variant="outline" disabled={birdReloadMutation.isPending} onClick={() => setReloadOpen(true)}>
<RotateCcw />
Перезагрузить BIRD
</Button>
<Button disabled={applyMutation.isPending || revisions.length === 0} onClick={() => setApplyOpen(true)}>
<Play />
Применить на всех спикерах
</Button>
</>
}
/>
<KpiStatGrid items={kpiItems} isLoading={kpiLoading} skeletonCount={3} />
{failedJobs.length > 0 ? (
<SignalAlert
tone="er"
title={`Задач с ошибкой: ${failedJobs.length}.`}
action={
<Button variant="outline" onClick={() => navigate({ search: { tab: 'jobs' } })}>
Показать
</Button>
}
>
{failedJobs[0]?.error ?? 'Проверьте вкладку «Задачи» и журнал применения.'}
</SignalAlert>
) : null}
<QuickActionGrid
actions={quickActions}
description="Деплой конфигурации и управление локальным BIRD"
/>
<KpiStatGrid items={kpiItems} isLoading={kpiLoading} skeletonCount={3} />
<ConfirmDialog
open={applyOpen}
+1 -1
View File
@@ -91,7 +91,7 @@ function ScheduleComponent() {
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void modulesQ.refetch()
void jobsQ.refetch()
+265 -9
View File
@@ -141,6 +141,29 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/telemetry/refresh": {
parameters: {
query?: never;
header?: {
/** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */
"X-Tenant-Id"?: components["parameters"]["TenantId"];
};
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Запросить фоновое обновление телеметрии tenant
* @description Требует bgp:network:read. Объединяет повторные запросы в пределах 10 секунд; HTTP handler не опрашивает агентов или BIRD. GET speakers/peers/discoveries читает сохранённые snapshots, включая live=1. Запрос во время выполняющегося poll сохраняется для следующего цикла. Не изменяет конфигурацию speaker и не требует нового протокола агента.
*/
post: operations["requestTelemetryRefresh"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/router-lists/catalog": {
parameters: {
query?: never;
@@ -457,6 +480,9 @@ export interface paths {
* - `ipRange,community`
*
* В поле `community` можно передавать либо ID community, либо её значение.
* Предел запроса — 8 MiB (8388608 байт), 10000 строк данных без заголовка и 64 поля в записи.
* Превышение возвращает 413 до создания записей; большой импорт следует разбить на части.
* Ошибка в данных или БД после начала создания записей может оставить частичный импорт.
*/
post: operations["importModuleEntriesCsv"];
delete?: never;
@@ -647,7 +673,7 @@ export interface paths {
};
/**
* Список обнаруженных (неодобренных) пиров
* @description Dynamic BGP-сессии (`evobgp_dyn_*`), которых ещё нет в `bgp_peer`. По умолчанию возвращает `status=pending`. При листинге выполняет live-опрос birdc/agent и upsert pending.
* @description Dynamic BGP-сессии (`evobgp_dyn_*`), которых ещё нет в `bgp_peer`. По умолчанию возвращает `status=pending`. Читает сохранённые discoveries. Фоновый collector выполняет опрос и fenced upsert; GET не опрашивает ноды и не изменяет данные.
*/
get: operations["listDiscoveredPeers"];
put?: never;
@@ -1111,6 +1137,31 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/jobs/{job_id}/report": {
parameters: {
query?: never;
header?: {
/** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */
"X-Tenant-Id"?: components["parameters"]["TenantId"];
};
path: {
job_id: components["parameters"]["JobId"];
};
cookie?: never;
};
/**
* Отчёт задачи и сохранённый прогресс доставки
* @description Требует bgp:operations:read. Прогресс хранится независимо от meta задачи. Для reconciliation без собственного прогресса возвращается прогресс исходной задачи в том же tenant; после начала восстановления приоритет имеет собственный прогресс. agent_reported не означает проверку состояния BIRD.
*/
get: operations["getJobReport"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/jobs/{job_id}/cancel": {
parameters: {
query?: never;
@@ -1136,6 +1187,31 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/jobs/{job_id}/retry": {
parameters: {
query?: never;
header?: {
/** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */
"X-Tenant-Id"?: components["parameters"]["TenantId"];
};
path: {
job_id: components["parameters"]["JobId"];
};
cookie?: never;
};
get?: never;
put?: never;
/**
* Повторить завершившуюся ошибкой задачу
* @description Операторский повтор сохраняет ID и исходный запрос, сбрасывая бюджет попыток.
*/
post: operations["postJobRetry"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/speakers/{speaker_id}/revisions/latest": {
parameters: {
query?: never;
@@ -2228,11 +2304,19 @@ export interface components {
connected_speaker_id?: string | null;
/** @description Человекочитаемая метка ноды из live-опроса. */
connected_speaker_label?: string;
/** @description Последнее сохранённое состояние; не является текущим наблюдением. */
last_known_session_state?: string | null;
/** @description При live-ответе текущее наблюдение: BGP state, absent после успешного пустого опроса или null, если актуальное состояние неизвестно. */
session_state?: string | null;
/** @description Качество BGP-наблюдений при GET /v1/peers?live=1. Для назначенного пира учитывается только bgp_speaker_id; для общего — все speakers. Смешанный успех и сбой не дают quality=ok или session_state=absent. Timestamps относятся только к ожидаемым speakers. */
live_observation?: components["schemas"]["LiveObservation"];
/** @description Состояние протокола пира на каждой опрошенной ноде. */
session_on_speakers?: {
speaker_id?: string;
label?: string;
state?: string;
poll_error?: string;
observation?: components["schemas"]["LiveObservation"];
}[];
/** @description Ноды, где сессия в состоянии Established (один пир может быть на нескольких). */
established_on_speakers?: {
@@ -2281,8 +2365,10 @@ export interface components {
SpeakerLiveStatus: {
/** @description Человекочитаемая метка ноды (agent domain или CP master). */
label?: string;
/** @description true если agent /v1/agent/health успешен (master — local birdc poll). */
/** @description true только после успешного agent /v1/agent/health (master — local birdc poll). */
agent_ok?: boolean;
agent_observation?: components["schemas"]["LiveObservation"];
bgp_observation?: components["schemas"]["LiveObservation"];
agent_error?: string;
/** Format: date-time */
agent_last_sync_at?: string;
@@ -2290,12 +2376,34 @@ export interface components {
/** @description true если birdc (CP) или GET /v1/agent/bird/protocols (replica) успешен. */
bgp_poll_ok?: boolean;
bgp_poll_error?: string;
bgp_sessions_total?: number;
bgp_established?: number;
/** @description null при отсутствии успешного опроса. */
bgp_sessions_total?: number | null;
/** @description null при отсутствии успешного опроса. */
bgp_established?: number | null;
sessions?: components["schemas"]["BgpSessionLive"][];
} & {
[key: string]: unknown;
};
/** @description Метаданные конкретного опроса. observed_at — время завершения опроса; received_at — время получения результата CP; last_success_at — время последнего успешного опроса. Старые агенты не обязаны передавать эти поля: CP формирует их по собственным часам. При отсутствии опроса timestamps null. */
LiveObservation: {
/** Format: date-time */
observed_at: string | null;
/** Format: date-time */
received_at: string | null;
/** Format: date-time */
last_success_at: string | null;
/**
* Format: date-time
* @description Срок актуальности snapshot по часам CP и настроенному интервалу collector; клиент не должен считать cached quality=ok актуальным после этого времени. Для агрегата — самый ранний срок источников.
*/
expires_at?: string | null;
/** @enum {string} */
source: "none" | "cp_birdc" | "agent_bird_protocols" | "agent_health" | "mixed";
/** @enum {string} */
quality: "ok" | "stale" | "unreachable" | "unknown" | "disabled";
/** @enum {string} */
error_code?: "not_configured" | "not_polled" | "poll_failed" | "reported_unhealthy" | "snapshot_expired" | "configuration_changed" | "ambiguous_authority";
};
BgpSessionLive: {
name?: string;
neighbor?: string;
@@ -2312,8 +2420,10 @@ export interface components {
speaker_id?: string;
label?: string;
ok?: boolean;
session_count?: number;
/** @description null, если успешного опроса не было. */
session_count?: number | null;
poll_error?: string;
observation?: components["schemas"]["LiveObservation"];
} & {
[key: string]: unknown;
};
@@ -2831,6 +2941,17 @@ export interface components {
};
};
responses: {
/** @description Локальная квота процесса API — по умолчанию 4 активных preview/lookup, 2 на tenant; 120 lookup и по 30 CDN/revision preview в минуту на tenant. Квоты умножаются на число реплик. Повторить после Retry-After, без очереди ожидания. */
ExpensiveOperationLimited: {
headers: {
/** @description Минимальная задержка повторной попытки в секундах; не гарантия свободного места. */
"Retry-After"?: number;
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["Problem"];
};
};
/** @description Нет или невалидный Bearer-токен. */
Unauthorized: {
headers: {
@@ -2885,7 +3006,7 @@ export interface components {
"application/problem+json": components["schemas"]["Problem"];
};
};
/** @description Ошибка (см. тело Problem). */
/** @description Ошибка (см. тело Problem). Общие для authenticated `/v1` ответы middleware: 413 при request body свыше 8 MiB, 400 при ошибке чтения тела, 408 при отмене/таймауте чтения. 429 с Retry-After: 1 при занятых слотах обработки request body. */
DefaultProblem: {
headers: {
[name: string]: unknown;
@@ -3085,6 +3206,34 @@ export interface operations {
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
429: components["responses"]["ExpensiveOperationLimited"];
default: components["responses"]["DefaultProblem"];
};
};
requestTelemetryRefresh: {
parameters: {
query?: never;
header?: {
/** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */
"X-Tenant-Id"?: components["parameters"]["TenantId"];
};
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Запрос принят либо объединён с уже принятым. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @description true при постановке нового запроса; false при объединении или отсутствии целей. */
requested: boolean;
};
};
};
default: components["responses"]["DefaultProblem"];
};
};
@@ -3903,6 +4052,15 @@ export interface operations {
};
};
404: components["responses"]["NotFound"];
/** @description Превышен лимит байт, строк или полей CSV; записи не созданы. */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["Problem"];
};
};
422: components["responses"]["UnprocessableEntity"];
default: components["responses"]["DefaultProblem"];
};
@@ -4002,6 +4160,7 @@ export interface operations {
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
422: components["responses"]["UnprocessableEntity"];
429: components["responses"]["ExpensiveOperationLimited"];
default: components["responses"]["DefaultProblem"];
};
};
@@ -4291,7 +4450,7 @@ export interface operations {
/** @description Размер страницы (по умолчанию 50). */
limit?: components["parameters"]["Limit"];
speaker_id?: components["parameters"]["SpeakerFilter"];
/** @description Опрос birdc на CP и GET /v1/agent/bird/protocols на репликах; обогащает session_state и connected_speaker_*. */
/** @description Сохранённые результаты фонового опроса birdc на CP и agent protocols на репликах; обогащает session_state и connected_speaker_*. GET не запускает опрос; live=1 сохранён для совместимости. */
live?: "1";
};
header?: {
@@ -4313,7 +4472,7 @@ export interface operations {
items: components["schemas"]["BgpPeer"][];
next_cursor?: string | null;
has_more: boolean;
/** @description При live=1 — результат опроса каждой ноды (CP birdc + agent protocols). */
/** @description Сохранённый результат фонового опроса каждой ноды (CP birdc + agent protocols); live=1 не запускает новый опрос. */
live_speaker_poll?: components["schemas"]["LiveSpeakerPoll"][];
};
};
@@ -4557,7 +4716,7 @@ export interface operations {
cursor?: components["parameters"]["Cursor"];
/** @description Размер страницы (по умолчанию 50). */
limit?: components["parameters"]["Limit"];
/** @description Live-опрос agent /v1/agent/health и BGP protocols на репликах; CP — local birdc. Обогащает каждый item полем `live`. */
/** @description Сохранённые результаты фоновых опросов agent health/BGP и local birdc. GET не запускает опрос. Обогащает каждый item полем `live`. */
live?: "1";
};
header?: {
@@ -4884,6 +5043,7 @@ export interface operations {
};
};
404: components["responses"]["NotFound"];
429: components["responses"]["ExpensiveOperationLimited"];
default: components["responses"]["DefaultProblem"];
};
};
@@ -5144,6 +5304,66 @@ export interface operations {
default: components["responses"]["DefaultProblem"];
};
};
getJobReport: {
parameters: {
query?: never;
header?: {
/** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */
"X-Tenant-Id"?: components["parameters"]["TenantId"];
};
path: {
job_id: components["parameters"]["JobId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
job_id: string;
kind: string;
status: string;
meta?: {
[key: string]: unknown;
} | null;
error?: string | null;
/** Format: date-time */
created_at?: string;
revision_id?: string;
log_entries?: {
[key: string]: unknown;
}[];
log_total?: number;
source_deployment_job_id?: string;
deployment_progress: {
job_id: string;
speaker_id: string;
revision_id: string;
/** Format: int64 */
generation: number;
state: string;
confirmation: string;
error?: string;
dispatch_attempts: number;
retry_round: number;
/** Format: date-time */
health_received_at?: string | null;
health_revision_id?: string;
health_source_at?: string;
health_error?: string;
}[];
};
};
};
404: components["responses"]["NotFound"];
default: components["responses"]["DefaultProblem"];
};
};
postJobCancel: {
parameters: {
query?: never;
@@ -5173,6 +5393,42 @@ export interface operations {
default: components["responses"]["DefaultProblem"];
};
};
postJobRetry: {
parameters: {
query?: never;
header?: {
/** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */
"X-Tenant-Id"?: components["parameters"]["TenantId"];
};
path: {
job_id: components["parameters"]["JobId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Повтор поставлен в очередь. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Job"];
};
};
409: components["responses"]["Conflict"];
/** @description Очередь задач заполнена. */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["Problem"];
};
};
default: components["responses"]["DefaultProblem"];
};
};
getSpeakerLatestRevision: {
parameters: {
query?: never;
+18 -3
View File
@@ -198,6 +198,17 @@ export type PeerSessionOnSpeaker = {
label: string
state: string
poll_error?: string
observation?: LiveObservation
}
export type LiveObservation = {
observed_at: string | null
received_at: string | null
last_success_at: string | null
expires_at?: string | null
source: 'none' | 'cp_birdc' | 'agent_bird_protocols' | 'agent_health' | 'mixed'
quality: 'ok' | 'stale' | 'unreachable' | 'unknown' | 'disabled'
error_code?: 'not_configured' | 'not_polled' | 'poll_failed' | 'reported_unhealthy' | 'snapshot_expired' | 'configuration_changed' | 'ambiguous_authority'
}
export type PeerRow = {
@@ -206,7 +217,9 @@ export type PeerRow = {
neighbor: string
remote_asn?: number
enabled?: boolean
session_state: string
session_state: string | null
last_known_session_state?: string | null
live_observation?: LiveObservation
bgp_speaker_id: string | null
connected_speaker_id?: string | null
connected_speaker_label?: string
@@ -267,13 +280,15 @@ export type BgpSessionLive = {
export type SpeakerLiveStatus = {
label?: string
agent_ok?: boolean
agent_observation?: LiveObservation
bgp_observation?: LiveObservation
agent_error?: string
agent_last_sync_at?: string
agent_last_applied_revision_id?: string
bgp_poll_ok?: boolean
bgp_poll_error?: string
bgp_sessions_total?: number
bgp_established?: number
bgp_sessions_total?: number | null
bgp_established?: number | null
sessions?: BgpSessionLive[]
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/segmented-tabs.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/status-toggle-group.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/directories/community-create-dialog.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/directories/directories-row-actions.tsx","./src/components/directories/doh-profile-create-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/apps-menu.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/nav-user.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/lookup/lookup-add-step.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-status-banner.tsx","./src/components/lookup/lookup-wizard.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-create-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-edit-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-discovered-peers-card.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/job-timeline.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-jobs-kanban.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/icon-tile.tsx","./src/components/reui/kanban.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/sortable.tsx","./src/components/reui/stepper.tsx","./src/components/reui/timeline.tsx","./src/components/reui/cascader/cascader-async.tsLine truncated
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/segmented-tabs.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/status-toggle-group.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-prefix-trend.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/directories/community-create-dialog.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/directories/directories-row-actions.tsx","./src/components/directories/doh-profile-create-dialog.tsx","./src/components/layout/portal-shell.tsx","./src/components/lookup/lookup-add-step.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-status-banner.tsx","./src/components/lookup/lookup-wizard.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-create-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-edit-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-discovered-peers-card.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/job-timeline.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-jobs-kanban.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/portal/bits.tsx","./src/components/portal/canvas-blocks.tsx","./src/components/portal/chrome-context.tsx","./src/components/portal/chrome-store.ts","./src/components/portal/chrome.tsx","./src/components/portal/data-page-layout.ts","./src/components/portal/data-page.tsx","./src/components/portal/format.ts","./src/components/portal/kpi-strip.tsx","./src/components/portal/kpi.tsx","./src/components/portal/section-grid.tsx","./src/components/portal/tone.ts","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/fLine truncated
+81 -20
View File
@@ -6,10 +6,14 @@ import (
"fmt"
"log"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"evobgp/internal/agentserver"
"evobgp/internal/agentwatch"
"evobgp/internal/birdfmt"
)
@@ -18,19 +22,22 @@ func main() {
birdc := flag.String("birdc", "", "path to birdc binary (default: birdc from PATH)")
socket := flag.String("socket", "", "optional birdc control socket (-s)")
timeout := flag.Duration("timeout", 30*time.Second, "timeout for bird/birdc")
watchEvery := flag.Duration("watch-interval", 30*time.Second, "for watch: interval between birdc configure")
watchEvery := flag.Duration("watch-interval", 30*time.Second, "for watch: interval between configuration checks")
listen := flag.String("listen", "", "for serve: listen address (default :8443 or EVOBGP_AGENT_LISTEN)")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [flags] <command>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Commands:\n")
fmt.Fprintf(os.Stderr, " parse-check <path/to/bird.conf> run bird -c <path> -p (syntax check)\n")
fmt.Fprintf(os.Stderr, " configure run birdc configure (reload running BIRD)\n")
fmt.Fprintf(os.Stderr, " watch periodically run birdc configure (compose sidecar)\n")
fmt.Fprintf(os.Stderr, " watch reload changed BIRD configuration and periodically reconcile\n")
fmt.Fprintf(os.Stderr, " serve Panel→Node HTTP API (POST /v1/agent/sync)\n")
flag.PrintDefaults()
}
flag.Parse()
args := flag.Args()
args, err := parseAgentArgs(flag.CommandLine, os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if len(args) < 1 {
flag.Usage()
os.Exit(2)
@@ -47,10 +54,12 @@ func main() {
switch args[0] {
case "serve":
runServe(*listen, *timeout)
case "parse-check", "configure", "watch":
case "watch":
runWatch(ctl, *watchEvery, *timeout)
case "parse-check", "configure":
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
runBirdCommand(ctx, args, ctl, *watchEvery, *timeout)
runBirdCommand(ctx, args, ctl)
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
flag.Usage()
@@ -58,7 +67,47 @@ func main() {
}
}
func runBirdCommand(ctx context.Context, args []string, ctl *birdfmt.BirdCtl, watchEvery, timeout time.Duration) {
// parseAgentArgs accepts flags before or immediately after the subcommand.
// A second parse preserves previously supplied flag values and rejects unknown
// flags instead of silently passing them to watch or serve as positional args.
func parseAgentArgs(fs *flag.FlagSet, argv []string) ([]string, error) {
if err := fs.Parse(argv); err != nil {
return nil, err
}
args := fs.Args()
if len(args) == 0 {
return args, nil
}
command := args[0]
globalStop := false
for i, consumed := 0, len(argv)-len(args); i < consumed; i++ {
if argv[i] == "--" {
globalStop = true
break
}
name, _, hasValue := strings.Cut(strings.TrimLeft(argv[i], "-"), "=")
if !hasValue {
if f := fs.Lookup(name); f != nil {
boolean, ok := f.Value.(interface{ IsBoolFlag() bool })
if !ok || !boolean.IsBoolFlag() {
i++
}
}
}
}
if !globalStop {
if err := fs.Parse(args[1:]); err != nil {
return nil, err
}
args = append([]string{command}, fs.Args()...)
}
if (command == "watch" || command == "serve" || command == "configure") && len(args) != 1 {
return nil, fmt.Errorf("%s takes no positional arguments", command)
}
return args, nil
}
func runBirdCommand(ctx context.Context, args []string, ctl *birdfmt.BirdCtl) {
switch args[0] {
case "parse-check":
if len(args) != 2 {
@@ -78,21 +127,33 @@ func runBirdCommand(ctx context.Context, args []string, ctl *birdfmt.BirdCtl, wa
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case "watch":
if watchEvery <= 0 {
fmt.Fprintln(os.Stderr, "watch-interval must be > 0")
}
}
func runWatch(ctl *birdfmt.BirdCtl, every, timeout time.Duration) {
if every <= 0 || timeout <= 0 {
fmt.Fprintln(os.Stderr, "watch-interval and timeout must be > 0")
os.Exit(2)
}
reconcile := 5 * time.Minute
if raw := os.Getenv("EVOBGP_AGENT_WATCH_RECONCILE_INTERVAL"); raw != "" {
var err error
reconcile, err = time.ParseDuration(raw)
if err != nil || reconcile <= 0 {
fmt.Fprintln(os.Stderr, "EVOBGP_AGENT_WATCH_RECONCILE_INTERVAL must be a positive duration")
os.Exit(2)
}
log.Printf("evobgp-agent watch: birdc configure every %s (socket=%q)", watchEvery, ctl.Socket)
for {
cctx, cancel := context.WithTimeout(context.Background(), timeout)
err := ctl.Configure(cctx)
cancel()
if err != nil {
log.Printf("evobgp-agent watch: configure: %v", err)
}
time.Sleep(watchEvery)
}
}
active := os.Getenv("EVOBGP_BIRD_ACTIVE_DIR")
if active == "" {
active = "/etc/bird"
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
log.Printf("evobgp-agent watch: checking %s every %s, forced reconciliation every %s (socket=%q)", active, every, reconcile, ctl.Socket)
if err := agentwatch.Run(ctx, ctl, agentwatch.Config{ActiveDir: active, Every: every, ReconcileEvery: reconcile, CommandTimeout: timeout}); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"flag"
"io"
"reflect"
"testing"
"time"
)
func TestAgentFlagsBeforeAndAfterCommand(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want []string
socket, listen string
every time.Duration
}{
{"legacy flags first", []string{"-socket=/custom/bird.ctl", "-watch-interval=45s", "watch"}, []string{"watch"}, "/custom/bird.ctl", "", 45 * time.Second},
{"compose command first", []string{"watch", "-socket=/run/bird/bird.ctl", "-watch-interval=30s"}, []string{"watch"}, "/run/bird/bird.ctl", "", 30 * time.Second},
{"serve", []string{"serve", "-listen=127.0.0.1:9999"}, []string{"serve"}, "", "127.0.0.1:9999", time.Minute},
{"mixed", []string{"-socket=/first", "watch", "-watch-interval=5s"}, []string{"watch"}, "/first", "", 5 * time.Second},
{"parse check", []string{"parse-check", "bird.conf"}, []string{"parse-check", "bird.conf"}, "", "", time.Minute},
{"global delimiter", []string{"--", "parse-check", "-bird.conf"}, []string{"parse-check", "-bird.conf"}, "", "", time.Minute},
{"flags then delimiter", []string{"-socket=/first", "--", "parse-check", "-bird.conf"}, []string{"parse-check", "-bird.conf"}, "/first", "", time.Minute},
{"delimiter as value", []string{"-socket", "--", "watch", "-watch-interval=5s"}, []string{"watch"}, "--", "", 5 * time.Second},
} {
t.Run(tc.name, func(t *testing.T) {
fs := flag.NewFlagSet("agent", flag.ContinueOnError)
fs.SetOutput(io.Discard)
socket := fs.String("socket", "", "")
listen := fs.String("listen", "", "")
every := fs.Duration("watch-interval", time.Minute, "")
args, err := parseAgentArgs(fs, tc.args)
if err != nil || !reflect.DeepEqual(args, tc.want) || *socket != tc.socket || *listen != tc.listen || *every != tc.every {
t.Fatalf("args=%v socket=%s listen=%s every=%s err=%v", args, *socket, *listen, *every, err)
}
})
}
}
func TestAgentRejectsIgnoredFlagsAndUnexpectedArguments(t *testing.T) {
for _, argv := range [][]string{{"watch", "-unknown=1"}, {"serve", "-unknown=1"}, {"watch", "unused"}, {"serve", "unused"}, {"configure", "unused"}} {
fs := flag.NewFlagSet("agent", flag.ContinueOnError)
fs.SetOutput(io.Discard)
if _, err := parseAgentArgs(fs, argv); err == nil {
t.Fatalf("ignored argv: %v", argv)
}
}
}
+33 -15
View File
@@ -41,23 +41,41 @@ func main() {
apiBase := strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL"))
apiTok := strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_BEARER"))
if os.Getenv("EVOBGP_SCHEDULER_STANDALONE") == "1" && apiBase == "" {
log.Fatal("evobgp-scheduler: EVOBGP_SCHEDULER_STANDALONE=1 requires EVOBGP_CONTROL_PLANE_URL and EVOBGP_SCHEDULER_BEARER (split deploy must not use in-process jobs.Registry)")
mode := strings.ToLower(strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_MODE")))
if mode == "" {
switch {
case apiBase != "" || apiTok != "":
mode = "http"
case pool != nil:
mode = "db"
default:
mode = "local"
}
}
deps := &scheduler.Deps{
Store: st,
HTTP: &http.Client{Timeout: 45 * time.Second},
}
if (apiBase != "") != (apiTok != "") {
log.Fatal("evobgp-scheduler: set both EVOBGP_CONTROL_PLANE_URL and EVOBGP_SCHEDULER_BEARER for HTTP mode, or leave both empty for in-process job registry")
}
if apiBase != "" {
deps.APIBase = apiBase
deps.APIToken = apiTok
log.Printf("evobgp-scheduler: control plane HTTP mode (%s)", apiBase)
} else {
deps := &scheduler.Deps{Store: st, HTTP: &http.Client{Timeout: 45 * time.Second}}
switch mode {
case "db":
if pool == nil {
log.Fatal("evobgp-scheduler: db mode requires EVOBGP_DATABASE_URL")
}
deps.Jobs = reg
log.Printf("evobgp-scheduler: in-process job registry mode (use EVOBGP_CONTROL_PLANE_URL + EVOBGP_SCHEDULER_BEARER for split containers)")
log.Print("evobgp-scheduler: trusted DB enqueue mode; API workers consume the shared durable queue")
case "http":
tenant := strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_TENANT_ID"))
if apiBase == "" || apiTok == "" || tenant == "" {
log.Fatal("evobgp-scheduler: http mode requires EVOBGP_CONTROL_PLANE_URL, EVOBGP_SCHEDULER_BEARER and EVOBGP_SCHEDULER_TENANT_ID")
}
deps.APIBase, deps.APIToken, deps.APITenantID = apiBase, apiTok, tenant
log.Print("evobgp-scheduler: single-tenant HTTP enqueue mode")
case "local":
if os.Getenv("EVOBGP_SCHEDULER_STANDALONE") == "1" || pool != nil {
log.Fatal("evobgp-scheduler: local mode is for in-process development only; use db for a shared PostgreSQL queue")
}
deps.Jobs = reg
defer func() { reg.RequestCancelAll(); _ = reg.Drain(context.Background()) }()
log.Print("evobgp-scheduler: local development mode")
default:
log.Fatal("evobgp-scheduler: EVOBGP_SCHEDULER_MODE must be db, http or local")
}
scheduler.Run(ctx, deps)
}
+12
View File
@@ -4,5 +4,17 @@
EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest
# Stable ID of the local BIRD node for telemetry. Set uniquely per actual BIRD node
# when several control-plane deployments share one PostgreSQL database.
# EVOBGP_TELEMETRY_LOCAL_ID=bird2-node-a
# Prometheus: ограничение TSDB по размеру (WAL и head требуют отдельного запаса).
# EVOBGP_PROMETHEUS_RETENTION_SIZE=2GB
# Для защищенного Web UI (Traefik + Let's Encrypt + Cloudflare DNS challenge)
# используйте второй файл рядом: .env.web-sec (шаблон: .env.web-sec.example)
# Reference Compose hardcodes EVOBGP_SCHEDULER_MODE=db (trusted, all tenant scopes).
# Setting HTTP variables in this .env DOES NOT switch the scheduler mode.
# To use a single-tenant HTTP scheduler, explicitly override its service environment
# with MODE=http plus URL, bearer and tenant; see docs/quickstart.md.
+7
View File
@@ -4,6 +4,13 @@
EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest
# Уникальный стабильный ID локального BIRD-узла в общей БД; не меняйте при рестарте.
# EVOBGP_TELEMETRY_LOCAL_ID=bird2-node-a
# Optional NATS для внешних клиентов: COMPOSE_PROFILES=nats
# Prometheus: TSDB retention size; WAL/head требуют дополнительного места.
# EVOBGP_PROMETHEUS_RETENTION_SIZE=2GB
# Имя compose-проекта (должно совпадать с name: в docker-compose.yaml)
COMPOSE_PROJECT_NAME=evobgp-microvps-full
@@ -7,6 +7,13 @@
EVOBGP_REGISTRY=git.shx.one/denozord
EVOBGP_IMAGE_TAG=latest
# Уникальный стабильный ID локального BIRD-узла в общей БД; не меняйте при рестарте.
# EVOBGP_TELEMETRY_LOCAL_ID=bird2-node-a
# Optional NATS для внешних клиентов: COMPOSE_PROFILES=nats
# Prometheus: TSDB retention size; WAL/head требуют дополнительного места.
# EVOBGP_PROMETHEUS_RETENTION_SIZE=2GB
# Защищенный Web UI (Traefik + Let's Encrypt + Cloudflare DNS challenge)
WEBUI_DOMAIN=bgp.shz.su
WEBUI_IP_WHITELIST=109.174.26.78/32,87.103.241.8/32
@@ -1,4 +1,4 @@
# Дополнительный файл Compose: профиль microvps-full + ужатые лимиты под хост ~1 ГиБ RAM.
# Дополнительный файл Compose: профиль microvps-full + ресурсные лимиты.
#
# Запуск (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,13 +6,13 @@
# Остановка:
# docker compose -f docker-compose.yaml -f docker-compose.microvps-full.yaml --profile microvps-full down
#
# Лимиты под хост ~1 ГиБ: сумма лимитов контейнеров ~800 МиБ + запас ОС/Docker. При OOM увеличьте RAM VPS или уберите prometheus/nats из профиля (отдельная правка compose).
# Сумма memory limits с edge и без optional NATS — 984M, это не замер потребления.
# Пригодность для хоста ~1 ГиБ требует workload-теста с запасом ОС/Docker и BIRD peaks.
# Postgres ниже ~240–256 МиБ часто не успевает пройти healthcheck / падает по памяти — не уменьшайте postgres без tuned postgresql.conf.
services:
evobgp-all:
environment:
EVOBGP_BROKER_URL: nats://nats:4222
EVOBGP_SERVICE: evobgp-all
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
volumes:
@@ -62,6 +62,7 @@ services:
max-file: "3"
nats:
profiles: ["nats"]
image: nats:2.10-alpine
restart: unless-stopped
command: ["-js", "-m", "8222"]
@@ -104,7 +105,7 @@ services:
- bird_etc:/etc/bird
- bird_run:/run/bird
entrypoint: ["/usr/local/bin/evobgp-agent"]
command: ["watch", "-socket=/run/bird/bird.ctl", "-watch-interval=30s"]
command: ["-socket=/run/bird/bird.ctl", "-watch-interval=30s", "watch"]
logging:
driver: json-file
options:
@@ -117,20 +118,18 @@ services:
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
bird2:
condition: service_started
ports:
- "8080:8080"
environment:
EVOBGP_DATABASE_URL: postgres://evobgp:evobgp@postgres:5432/evobgp?sslmode=disable
# EVOBGP_BROKER_URL: nats://nats:4222
EVOBGP_NODE_DISPATCH_ENABLED: "1"
EVOBGP_BUNDLE_SEED_HEX: "bd8fbcd31545aacfdd228203beca8e945ab9a752f2ce5624cf42d9f316389a9d"
EVOBGP_HTTP_ADDR: ":8080"
EVOBGP_SEED_DEMO: "1"
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
EVOBGP_TELEMETRY_LOCAL_ID: ${EVOBGP_TELEMETRY_LOCAL_ID:-}
EVOBGP_BIRDC_INTERVAL: 30s
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
@@ -233,10 +232,13 @@ services:
target: /etc/prometheus/prometheus.yml
- source: prometheus_alerts_yml
target: /etc/prometheus/alerts.yml
volumes:
- prometheus_data:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
- --storage.tsdb.retention.size=${EVOBGP_PROMETHEUS_RETENTION_SIZE:-2GB}
- --web.enable-lifecycle
logging:
driver: json-file
@@ -285,6 +287,7 @@ services:
volumes:
pgdata:
prometheus_data:
bird_etc:
bird_run:
traefik_letsencrypt:
+14 -19
View File
@@ -9,10 +9,10 @@
# docker compose --profile microvps up -d
# microVPS ~1 ГиБ + Web UI — docs/quickstart.md и docker-compose.microvps-full.yaml
#
# reference: 5× Go (api + scheduler + ingest + render + deploy) + postgres + NATS JetStream + bird2 + agent + web.
# Async jobs выполняются in-process в evobgp-api (jobs.Registry); NATS — задел под брокер (см. docs/architecture.md).
# reference: 5× Go (api + scheduler + ingest + render + deploy) + postgres + bird2 + agent + web.
# Async jobs выполняются in-process в evobgp-api (jobs.Registry); NATS — опция `--profile nats` (см. docs/architecture.md).
# microvps: evobgp-all + postgres + bird2 + agent (без брокера и без UI).
# microvps-full: как microvps + evobgp-web-microvps (порт 3000) + nats + prometheus-microvps (порт 9090).
# microvps-full: как microvps + evobgp-web-microvps (порт 3000) + prometheus-microvps (порт 9090).
#
# Опция microVPS_sqlite из плана (без контейнера Postgres): требует реализации store на SQLite в приложении;
# пока используйте microvps с контейнерным Postgres (единые миграции).
@@ -35,7 +35,6 @@ x-logging: &default-logging
x-env-ref: &env-ref
EVOBGP_DATABASE_URL: postgres://evobgp:evobgp@postgres:5432/evobgp?sslmode=disable
EVOBGP_BROKER_URL: nats://nats:4222
x-env-micro: &env-micro
EVOBGP_DATABASE_URL: postgres://evobgp:evobgp@postgres:5432/evobgp?sslmode=disable
@@ -67,7 +66,7 @@ services:
memory: 384M
nats:
profiles: ["reference", "microvps-full"]
profiles: ["nats"]
image: nats:2.10-alpine
restart: unless-stopped
command: ["-js", "-m", "8222"]
@@ -86,8 +85,6 @@ services:
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
bird2:
condition: service_started
ports:
@@ -99,6 +96,7 @@ services:
# DEV ONLY — не для production. Bearer dev + слабые demo-секреты (см. docs/access.md).
EVOBGP_DEV_INSECURE: "1"
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
EVOBGP_TELEMETRY_LOCAL_ID: ${EVOBGP_TELEMETRY_LOCAL_ID:-}
EVOBGP_BIRDC_INTERVAL: 30s
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
@@ -120,16 +118,13 @@ services:
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
evobgp-api:
condition: service_started
environment:
<<: *env-ref
# Split deploy: scheduler must enqueue jobs via API, not in-process Registry (ARCH-04).
# Trusted DB scheduler enqueues into the shared durable queue; API workers claim jobs.
EVOBGP_SCHEDULER_STANDALONE: "1"
EVOBGP_CONTROL_PLANE_URL: http://evobgp-api:8080
EVOBGP_SCHEDULER_BEARER: dev
EVOBGP_SCHEDULER_MODE: db
logging: *default-logging
deploy:
resources:
@@ -144,8 +139,6 @@ services:
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
environment:
<<: *env-ref
logging: *default-logging
@@ -162,8 +155,6 @@ services:
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
environment:
<<: *env-ref
logging: *default-logging
@@ -180,8 +171,6 @@ services:
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
environment:
<<: *env-ref
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
@@ -225,6 +214,7 @@ services:
EVOBGP_HTTP_ADDR: ":8080"
EVOBGP_SEED_DEMO: "1"
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
EVOBGP_TELEMETRY_LOCAL_ID: ${EVOBGP_TELEMETRY_LOCAL_ID:-}
EVOBGP_BIRDC_INTERVAL: 30s
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
@@ -315,10 +305,12 @@ services:
volumes:
- ../prometheus/prometheus-microvps.yml:/etc/prometheus/prometheus.yml:ro
- ../prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
- prometheus_data:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
- --storage.tsdb.retention.size=${EVOBGP_PROMETHEUS_RETENTION_SIZE:-2GB}
- --web.enable-lifecycle
logging: *default-logging
deploy:
@@ -359,7 +351,7 @@ services:
- bird_etc:/etc/bird
- bird_run:/run/bird
entrypoint: ["/usr/local/bin/evobgp-agent"]
command: ["watch", "-socket=/run/bird/bird.ctl", "-watch-interval=30s"]
command: ["-socket=/run/bird/bird.ctl", "-watch-interval=30s", "watch"]
logging: *default-logging
deploy:
resources:
@@ -377,10 +369,12 @@ services:
volumes:
- ../prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ../prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
- prometheus_data:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
- --storage.tsdb.retention.size=${EVOBGP_PROMETHEUS_RETENTION_SIZE:-2GB}
- --web.enable-lifecycle
logging: *default-logging
deploy:
@@ -390,6 +384,7 @@ services:
volumes:
pgdata:
prometheus_data:
bird_etc:
bird_run:
traefik_letsencrypt:
+9 -5
View File
@@ -1,4 +1,5 @@
# Standalone stack (без profiles): Portainer / `docker compose -f stack.microvps-full.yaml up -d`
# Standalone stack: Portainer / `docker compose -f stack.microvps-full.yaml up -d`.
# Основные сервисы запускаются без профиля; NATS доступен только в профиле `nats`.
# Эквивалент microvps-full из docker-compose.yaml + docker-compose.microvps-full.yaml,
# с встроенными configs для Prometheus (как у вас).
#
@@ -66,6 +67,7 @@ services:
max-file: "3"
nats:
profiles: ["nats"]
image: nats:2.10-alpine
restart: unless-stopped
command: ["-js", "-m", "8222"]
@@ -108,7 +110,7 @@ services:
- bird_etc:/etc/bird
- bird_run:/run/bird
entrypoint: ["/usr/local/bin/evobgp-agent"]
command: ["watch", "-socket=/run/bird/bird.ctl", "-watch-interval=30s"]
command: ["-socket=/run/bird/bird.ctl", "-watch-interval=30s", "watch"]
logging:
driver: json-file
options:
@@ -121,18 +123,16 @@ services:
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
bird2:
condition: service_started
ports:
- "8080:8080"
environment:
EVOBGP_DATABASE_URL: postgres://evobgp:evobgp@postgres:5432/evobgp?sslmode=disable
EVOBGP_BROKER_URL: nats://nats:4222
EVOBGP_HTTP_ADDR: ":8080"
EVOBGP_SEED_DEMO: "1"
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
EVOBGP_TELEMETRY_LOCAL_ID: ${EVOBGP_TELEMETRY_LOCAL_ID:-}
EVOBGP_BIRDC_INTERVAL: 30s
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
@@ -237,10 +237,13 @@ services:
target: /etc/prometheus/prometheus.yml
- source: prometheus_alerts_yml
target: /etc/prometheus/alerts.yml
volumes:
- prometheus_data:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
- --storage.tsdb.retention.size=${EVOBGP_PROMETHEUS_RETENTION_SIZE:-2GB}
- --web.enable-lifecycle
logging:
driver: json-file
@@ -361,6 +364,7 @@ services:
volumes:
pgdata:
prometheus_data:
bird_etc:
bird_run:
traefik_letsencrypt:
+4 -4
View File
@@ -14,20 +14,20 @@ BuildKit кэширует `/go/pkg/mod`, `~/.cache/go-build` и pnpm store че
| Образ | Runtime base | Заметка |
|-------|----------------|---------|
| scheduler, ingest, render | `gcr.io/distroless/static-debian12:nonroot` | static Go (`CGO_ENABLED=0`), без shell |
| api, all, deploy, node | `debian:bookworm-slim` + `bird` + `birdc` | `bird -p` (parse-check) и `birdc`; демон не запускается |
| scheduler, ingest, render, deploy | `gcr.io/distroless/static-debian12:nonroot` | static Go (`CGO_ENABLED=0`), без shell; deploy только сверяет drift |
| api, all, node | `debian:bookworm-slim` + `bird` + `birdc` | `bird -p` (parse-check) и `birdc`; демон не запускается |
| agent | тот же Ubuntu+bird2, что bird2 | общие слои с `evobgp-bird2` |
| bird2 | Ubuntu Noble + пакет bird2 | |
| web, web-all | `nginx:1.27-alpine` | `worker_processes 1` |
Сборка Go: `golang:1.24-alpine`. Context режется корневым [.dockerignore](../../.dockerignore).
Сборка Go: `golang:1.24-alpine`. Для Go Dockerfile действует отдельный [Dockerfile.dockerignore](gobinary/Dockerfile.dockerignore): в контексте остаются только Go-модуль, исходники, миграции, встроенные shell-скрипты и сборочный скрипт BIRD. Web-исходники не входят в Go-контекст. Кэш Go build также зависит от `GIT_SHA` и `BUILD_TIME`, поэтому эффект для web-only коммитов требует замера. Web context режется корневым [.dockerignore](../../.dockerignore).
### Кэш registry
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache` — **запись** только из `go-build-all`
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache` — **запись** только из `web-build`
- `git.shx.one/<owner>/evobgp-buildcache:birdc-buildcache` — **запись** только из `go-birdc`
- `git.shx.one/<owner>/evobgp-buildcache:base-*` — зеркало FROM с **Docker Hub** (`docker.io/library/…`; distroless — `gcr.io`). Только `linux/amd64`; skip если тег уже в Gitea. Неуспешный copy не валит CD — bake берёт Docker Hub FROM для этой базы.
- `git.shx.one/<owner>/evobgp-buildcache:base-*` — зеркало FROM с **Docker Hub** (`docker.io/library/…`; distroless — `gcr.io`). Только `linux/amd64`; существующий тег не копируется повторно, а в bake передаётся `repo@sha256:…`. `MIRROR_REFRESH=1` принудительно обновляет зеркало и завершает скрипт с ошибкой, если copy или digest lookup не удались. Без refresh неуспешный copy или digest lookup оставляет исходный upstream FROM для этой базы.
`pull = false` в bake: не перекачивать FROM, если слой уже в builder. Не запускайте `docker system prune -a` на runner.
+1 -1
View File
@@ -236,7 +236,7 @@ target "evobgp-render" {
}
target "evobgp-deploy" {
inherits = ["_go-runtime-birdc"]
inherits = ["_go-runtime"]
args = { BIN = "evobgp-deploy" }
tags = image-tags("evobgp-deploy")
}
+2 -2
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
# Универсальная сборка бинарей cmd/* (ARG BIN) или всех сразу (target build-all).
# Воркеры (runtime): distroless static. api/all/deploy/node (runtime-birdc): debian-slim + bird + birdc.
# Воркеры, включая deploy (runtime): distroless static. api/all/node (runtime-birdc): debian-slim + bird + birdc.
# CI: docker buildx bake -f deploy/docker/docker-bake.hcl
ARG BASE_GOLANG=docker.io/library/golang:1.24-alpine
ARG BASE_DEBIAN=docker.io/library/debian:bookworm-slim
@@ -64,7 +64,7 @@ COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/evobgp"]
# api / all / deploy / node — bird -p (parse-check) + birdc configure.
# api / all / node — bird -p (parse-check) + birdc configure.
# Демон BIRD в этом контейнере не запускается; процесс bird — в образе evobgp-bird2.
FROM ${BASE_DEBIAN} AS runtime-birdc
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
@@ -0,0 +1,17 @@
# Go builds use only the Go module and assets embedded into the binaries.
# This file takes precedence over the repository .dockerignore for this Dockerfile.
*
!go.mod
!go.sum
!cmd/
!cmd/**
!internal/
!internal/**
!migrations/
!migrations/**
!deploy/
!deploy/docker/
!deploy/docker/bird/
!deploy/docker/bird/bird-from-source.sh
**/*_test.go
**/*.md
+39 -16
View File
@@ -4,7 +4,8 @@
# REGISTRY=git.shx.one/<owner> (без суффикса /evobgp-buildcache)
#
# Источник library-образов — Docker Hub (docker.io), не public.ecr.aws (429 Too Many Requests).
# Если тег уже есть в Gitea — skip (без повторного pull).
# Если тег уже есть в Gitea — skip. MIRROR_REFRESH=1 принудительно обновляет зеркала.
# В bake передаётся immutable digest зеркала, а не его изменяемый tag.
# Копируем только linux/amd64 — bake platforms совпадает, multi-arch индекс не нужен.
# Шаг CD не должен падать: неуспешный copy оставляет bake на Docker Hub FROM для этой базы.
set -eu
@@ -13,6 +14,12 @@ CACHE_REPO="${REGISTRY}/evobgp-buildcache"
ENV_FILE="${MIRROR_ENV_FILE:-}"
PLATFORM="${MIRROR_PLATFORM:-linux/amd64}"
RETRIES="${MIRROR_RETRIES:-4}"
REFRESH="${MIRROR_REFRESH:-0}"
case "$REFRESH" in
0|1) ;;
*) echo "MIRROR_REFRESH must be 0 or 1" >&2; exit 2 ;;
esac
tmp="$(mktemp)"
cleanup() { rm -f "$tmp"; }
@@ -22,6 +29,14 @@ dest_exists() {
docker buildx imagetools inspect "$1" >/dev/null 2>&1
}
dest_digest() {
digest="$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$1")" || return 1
case "$digest" in
sha256:????????????????????????????????????????????????????????????????) printf '%s\n' "$digest" ;;
*) echo "invalid mirror digest for $1: $digest" >&2; return 1 ;;
esac
}
copy_retry() {
src="$1"
dest="$2"
@@ -31,9 +46,11 @@ copy_retry() {
if docker buildx imagetools create --platform "$PLATFORM" --tag "$dest" "$src"; then
return 0
fi
delay=$((n * 25))
echo "mirror retry ${n}/${RETRIES}, sleep ${delay}s: ${dest}"
sleep "$delay"
if [ "$n" -lt "$RETRIES" ]; then
delay=$((n * 25))
echo "mirror retry ${n}/${RETRIES}, sleep ${delay}s: ${dest}"
sleep "$delay"
fi
done
return 1
}
@@ -43,30 +60,36 @@ mirror() {
tag="$2"
var="$3"
dest="${CACHE_REPO}:${tag}"
if dest_exists "$dest"; then
if [ "$REFRESH" = 0 ] && dest_exists "$dest"; then
echo "skip (already in registry): ${dest}"
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
else
echo "mirror ${src} -> ${dest} (${PLATFORM})"
if ! copy_retry "$src" "$dest"; then
if [ "$REFRESH" = 1 ]; then
echo "error: refresh failed for ${dest}" >&2
return 1
fi
echo "warn: ${dest} not mirrored — bake uses upstream FROM for ${var}"
return 0
fi
fi
if digest="$(dest_digest "$dest")"; then
printf '%s=%s@%s\n' "$var" "$CACHE_REPO" "$digest" >>"$tmp"
return 0
fi
echo "mirror ${src} -> ${dest} (${PLATFORM})"
if copy_retry "$src" "$dest"; then
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
return 0
if [ "$REFRESH" = 1 ]; then
echo "error: cannot resolve refreshed digest for ${dest}" >&2
return 1
fi
echo "warn: ${dest} not mirrored — bake uses Docker Hub FROM for ${var}"
echo "warn: cannot resolve digest for ${dest} — bake uses upstream FROM for ${var}"
return 0
}
mirror "docker.io/library/golang:1.24-alpine" "base-golang-1.24-alpine" BASE_GOLANG
sleep 8
mirror "docker.io/library/debian:bookworm-slim" "base-debian-bookworm-slim" BASE_DEBIAN
sleep 8
mirror "docker.io/library/node:22-alpine" "base-node-22-alpine" BASE_NODE
sleep 8
mirror "docker.io/library/nginx:1.27-alpine" "base-nginx-1.27-alpine" BASE_NGINX
sleep 8
mirror "docker.io/library/ubuntu:noble" "base-ubuntu-noble" BASE_UBUNTU
sleep 8
mirror "gcr.io/distroless/static-debian12:nonroot" "base-distroless-static-debian12-nonroot" BASE_DISTROLESS
echo "----- mirrored BASE_* -----"
+10 -1
View File
@@ -3,7 +3,7 @@ groups:
- name: evobgp-controlplane
rules:
- alert: EvoBGPMaterializedPrefixesZero
expr: evobgp_materialized_prefixes_max == 0
expr: evobgp_stored_revision_materialized_prefixes_max == 0 and evobgp_store_metrics_collection_success == 1
for: 2h
labels:
severity: warning
@@ -11,6 +11,15 @@ groups:
summary: "В сторе control-plane нет ни одной ревизии с ненулевым materialized_prefix_count."
description: "Проверьте render/ingest и наличие ревизий."
- alert: EvoBGPStoreMetricsCollectionFailed
expr: evobgp_store_metrics_collection_success == 0
for: 5m
labels:
severity: warning
annotations:
summary: "Не удалось прочитать агрегаты хранилища для /metrics."
description: "Проверьте PostgreSQL и ошибки API; отсутствие агрегатов не означает нуль."
- alert: EvoBGPBirdEstablishedMissing
expr: |
evobgp_bird_protocols_scrape_success == 1
+7
View File
@@ -16,6 +16,13 @@
| [overview.md](overview.md) | Ключевые возможности системы |
| [quickstart.md](quickstart.md) | Быстрый запуск: готовые образы из `git.shx.one`, Docker Compose, локальный Go, веб |
| [architecture.md](architecture.md) | Компоненты, потоки данных, пакеты |
| [backend-improvement-plan.md](backend-improvement-plan.md) | План улучшения backend, статистики и Docker; статус выполнения |
| [backend-baseline.md](backend-baseline.md) | Этап 0: ресурсные snapshots и воспроизведение дефектов |
| [backend-compatibility.md](backend-compatibility.md) | Проверяемые legacy-контракты и оставшиеся проверки старых speakers |
| [bundle-limits.md](bundle-limits.md) | Пределы загрузки/распаковки бандлов и безопасные пути файлов |
| [outbound-policy-decision.md](outbound-policy-decision.md) | Разрешённые внутренние DoH/RIPE, отдельная защита CDN и пределы внешних ответов |
| [metrics-semantics.md](metrics-semantics.md) | Исторические, inventory и live-метрики; совместимость имён и ошибки сбора |
| [docker-resources.md](docker-resources.md) | Optional NATS, retention и перенос истории Prometheus, границы проверки Docker |
| [manual.md](manual.md) | Подробное руководство по модулям, процессам и API |
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI |
| [router-lists-ui-integration.md](router-lists-ui-integration.md) | Интеграция `router-lists-ui` с EvoBGP API (`DOMAINS/IP_RANGES/AS_PREFIXES/communities`) |
+12
View File
@@ -37,6 +37,18 @@ curl -sS https://bgp.shnt.top/v1/auth/config
UI: `VITE_AUTH_ENABLED`, `VITE_AUTH_PORTAL_URL`. App Switcher: `CURRENT_APP_ID=bgp`, конфиг с `GET {portal}/api/v1/app-switcher`.
## Доверенные reverse proxy и публичный URL реплик
По умолчанию API считает соединение прямым: для аудита и лимита `EVOBGP_AUTH_RATE_LIMIT` используется IP TCP-пира, а `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Host`, `X-Forwarded-Proto` и `Origin` не задают IP или публичный URL. Лимит авторизации — до 60 запросов в минуту на IP по умолчанию; значение `EVOBGP_AUTH_RATE_LIMIT` принимается в диапазоне 1–1000. Таблица лимитера ограничена 4096 активными IP; при заполнении новые IP получают `429` до истечения окна, действующие счётчики не вытесняются.
Для API за reverse proxy задайте `EVOBGP_TRUSTED_PROXY_CIDRS` списком **только адресов сетевых интерфейсов доверенных proxy**, например `10.20.0.5/32,10.20.0.6/32`. Пустое или некорректное значение отключает доверие к заголовкам. API разбирает `X-Forwarded-For` справа налево от TCP-пира и останавливается на первом недоверенном адресе; некорректная ближайшая цепочка возвращает IP TCP-пира. Proxy должен удалять клиентские `X-Forwarded-*` и устанавливать собственные `X-Forwarded-For`, `X-Forwarded-Host` и `X-Forwarded-Proto`. Не добавляйте широкие CIDR клиентов или всего Docker-окружения, если в них возможны прямые подключения к API.
При создании удалённого speaker безопаснее явно передать публичный HTTPS URL в `control_plane_url`: это значение имеет приоритет для команды установки. Если поле не задано, API использует валидные `X-Forwarded-Host` и `X-Forwarded-Proto` только от доверенного proxy, иначе схему прямого соединения и `Host`. `Origin` никогда не определяет адрес backend. Для deployment за proxy проверьте, что в результате создания speaker команда установки содержит внешний URL, доступный ноде.
## Исходящие запросы к CDN
Клиент CDN выполняет проверку DNS и назначения при локальном соединении и не использует `HTTP_PROXY`, `HTTPS_PROXY` или `NO_PROXY`: все CDN-запросы идут напрямую. Через внешний proxy нельзя гарантировать, что проверенный локально IP совпадёт с адресом, к которому подключится proxy. DNS разрешается заново для каждого нового соединения; перенаправления допускаются только на HTTPS URL без учётных данных. `EVOBGP_CDN_ALLOW_PRIVATE=1` явно разрешает приватные назначения **только для HTTPS** CDN URL; для HTTP этот флаг исключения не делает. Это правило не меняет приватный транспорт node agent.
## API-ключи (`EVOBGP_API_KEYS`)
Формат переменной окружения: список записей через **запятую** без пробелов внутри логики парсера (пробелы вокруг записей допускаются при обрезке). Каждая запись:
+36
View File
@@ -23,6 +23,14 @@
Все остальные запросы под **`/v1/...`**, кроме перечисленных выше трёх `GET`, проходят через middleware и требуют **`Authorization: Bearer <api_key>`** (см. [access.md](access.md)).
## Ограничение тела запроса
После аутентификации все непубличные `/v1/...` запросы проверяются целиком до вызова handler: максимум **8 MiB (8388608 bytes)**. Проверка действует для любого метода, включая GET, без зависимости от `Content-Type` или `Content-Length`; JSON с большим хвостом whitespace не позволяет выполнить мутацию до обнаружения превышения. Ответ — `413 application/problem+json`, без выполнения запрошенной мутации. Ошибка чтения возвращает `400`, отмена/таймаут чтения — `408`.
Запросы с телом допускаются без очереди ожидания: по умолчанию **4 одновременно на API instance**. `EVOBGP_API_BODY_MAX_CONCURRENT` задаёт число от 1 до 64; невалидное значение выбирает default 4. Слот удерживается до завершения handler, пока его request buffer может использоваться. При занятых слотах — немедленный `429` с `Retry-After: 1`. Пустые `http.NoBody` запросы, включая обычные node GET/pull, слота не занимают; указание `Content-Length: 0` при фактически существующем body не отключает ограничение.
CSV сохраняет отдельные ограничения своей операции: 8 MiB, 10000 строк данных, 64 поля. Тело запроса проверяется до импорта; SSE ограничивается только на входе и продолжает потоковый ответ. Предел буферов ограничивает память входящих тел, а не всю память разбора JSON, работы handler или response.
## Группы маршрутов (соответствие тегам OpenAPI)
Ниже — обзор того, что реализовано в коде (`internal/httpapi/routes.go`, `routes_crud.go`). Детали тел, кодов ответов и схем — только в OpenAPI.
@@ -65,6 +73,8 @@
- `GET|PATCH|DELETE /v1/peers/{id}`
- Для `POST|PATCH|DELETE` peer запускается быстрый job `peer_reconcile` (без module ingest/сбора префиксов); после него автоматически ставится apply на спикеры.
Live-состояние следует читать вместе с `live_observation` (схема `LiveObservation` в OpenAPI). Отсутствующее наблюдение не означает ноль сессий или отказ BGP: `session_state` может быть `null`, а сохранённое значение вынесено в `last_known_session_state`. Для назначенного пира состояние вычисляется по его speaker; успешный опрос другого узла не делает его healthy. У speakers отдельно представлены качество опроса BGP и агента; счётчики без успешного опроса nullable.
### Speakers
- `GET /v1/speakers`, `POST /v1/speakers`
@@ -86,6 +96,8 @@
- `POST /v1/speakers/{id}/apply`
- `POST /v1/bird/reload`
Ответ `202` подтверждает принятие задания. Результат доставки удалённому агенту не равен применению: старый `2xx` без явного revision ID имеет уровень `unconfirmed`; совпадающий явный ACK — `agent_reported`. Локальный успешный apply — `local_applied`. Подтверждение хеша работающего BIRD-конфига (`verified`) пока не реализовано. Job содержит частичные результаты по speakers; ошибка одного узла не скрывается общим успехом, отмена сохраняет выполненную часть и останавливает публикацию следующим узлам. Границы совместимости и одного активного deploy writer описаны в [backend-compatibility.md](backend-compatibility.md).
### Jobs
- `GET /v1/jobs`, `GET /v1/jobs/{job_id}`
@@ -161,6 +173,30 @@ curl -s -H "Authorization: Bearer opkey" http://localhost:8080/v1/modules
CORS для браузерных клиентов настраивается переменной **`EVOBGP_CORS_ORIGINS`** на стороне API.
### Квоты preview и lookup
Синхронные `GET /lookup`, CDN preview и revision preview используют общий локальный пул: по умолчанию 4 активных операции на процесс API, не более 2 на tenant. Для каждого tenant действуют отдельные минутные счётчики: 120 lookup, 30 CDN preview и 30 revision preview. При заполнении возвращается `429` с `Retry-After`, без очереди ожидания. Отмена клиента не освобождает место, пока обработчик действительно не завершил работу. `Retry-After` доступен также клиентам через CORS.
| Переменная | По умолчанию | Допустимый диапазон |
|---|---:|---:|
| `EVOBGP_API_EXPENSIVE_MAX_CONCURRENT` | 4 | 1–64 |
| `EVOBGP_API_EXPENSIVE_TENANT_CONCURRENT` | 2 | 1–64 |
| `EVOBGP_API_LOOKUP_PER_MINUTE` | 120 | 1–100000 |
| `EVOBGP_API_CDN_PREVIEW_PER_MINUTE` | 30 | 1–100000 |
| `EVOBGP_API_REVISION_PREVIEW_PER_MINUTE` | 30 | 1–100000 |
Некорректное значение заменяется значением по умолчанию. Состояние ограничено 4096 tenant; действующие счётчики и активная работа не вытесняются ради новых tenant. Квоты локальны: несколько API-реплик умножают доступную нагрузку. Они не являются общей кластерной квотой. Refresh/retry управляются отдельной durable queue с её лимитами и replay исходного запроса; legacy bundle/latest endpoints не используют квоту preview/lookup. Общий admission входного тела может вернуть `429` до проверки replay, поэтому такой POST можно повторить с тем же idempotency key.
### Ограничения CSV-импорта
CSV-импорт ограничен 8 MiB полного тела запроса, 10000 строк данных без заголовка и 64 полями в одной записи. Превышение возвращает `413` до создания записей, включая запросы без `Content-Length`; отправляйте большие списки частями. Синтаксически корректный префикс слишком большого файла больше не импортируется молча. Отмена запроса останавливает последующие записи. Ошибки данных или БД после начала импорта всё ещё могут оставить уже созданные строки: импорт не является общей транзакцией.
### Сохранённый отчёт доставки
`GET /v1/jobs/{job_id}/report` требует `bgp:operations:read` и возвращает `deployment_progress` независимо от промежуточной `meta`. Для recovery job поле `source_deployment_job_id` указывает исходную операцию в том же tenant. Пока собственных записей нет, отчёт показывает исходный прогресс; после начала recovery — собственный. Каждый элемент содержит `job_id`, поэтому происхождение результата видно явно.
`health_received_at` — время получения наблюдения control plane, `health_source_at` — необязательная отметка агента. Отсутствие этого поля у 2.56.2 не препятствует доставке. `unknown` и `unconfirmed` нельзя интерпретировать как подтверждённое применение или отказ BIRD. Полный HTTP-контракт: [OpenAPI, getJobReport](openapi.yaml).
## Связанные документы
- [access.md](access.md) — ключи и роли.
+22 -7
View File
@@ -14,14 +14,14 @@
|----------|------|
| `evobgp-api` | Только HTTP API и связанная логика в одном процессе. |
| `evobgp-all` | Тот же API + in-process **scheduler** (очередь `module_refresh` в общем Registry), **ingest** (prefetch ETag CDN), **render** (опционально auto-publish), **deploy** (лог расхождений published/applied). |
| `evobgp-scheduler` | По `refresh_interval_sec` ставит refresh: в одном процессе с API — через `jobs.Registry`; в reference Compose — **HTTP** `POST /v1/modules/{id}/refresh` (`EVOBGP_CONTROL_PLANE_URL`, `EVOBGP_SCHEDULER_BEARER`). |
| `evobgp-scheduler` | По `refresh_interval_sec` ставит tenant refresh. Reference Compose: доверенный DB-worker, общая PostgreSQL queue (`EVOBGP_SCHEDULER_MODE=db`). Опциональный HTTP-режим ограничен явным tenant; параметры и Compose override описаны в quickstart. |
| `evobgp-ingest` | Периодический conditional GET по URL CDN-источников и обновление `etag` в БД. |
| `evobgp-render` | По умолчанию только heartbeat; при `EVOBGP_RENDER_AUTOPUBLISH=1` выставляет всем спикерам tenant последнюю ревизию (упрощение для демо). |
| `evobgp-deploy` | Периодически логирует **drift**: `last_applied_revision_id` vs опубликованная ревизия для ноды. |
| `evobgp-node` | CLI реплики: `pull-bundle`, `verify-bundle`, `apply-bundle`. |
| `evobgp-agent` | Локальный агент рядом с BIRD: `watch`, **`serve`** (Panel→Node sync API на реплике). |
В Docker Compose профиль **reference** запускает отдельные контейнеры под `evobgp-api` и четыре воркера; профиль **microvps** использует один контейнер `evobgp-all`.
В Docker Compose профиль **reference** запускает отдельные контейнеры под `evobgp-api` и четыре воркера; профиль **microvps** использует один контейнер `evobgp-all`. Сервис NATS вынесен в отдельный профиль `nats` и не требуется для внутренней очереди задач.
## Пакеты `internal/` (сжатая карта)
@@ -31,14 +31,15 @@
| `store` | Абстракция бэкенда данных; реализации в памяти и через репозиторий. |
| `repository` | Доступ к PostgreSQL, сущности и миграции на уровне приложения. |
| `db` | Подключение к БД и применение миграций. |
| `jobs` | Реестр и выполнение асинхронных задач, связанных с API. |
| `jobs` | Durable enqueue/claim, lease/heartbeat/fencing, выполнение задач и deployment reconciliation. |
| `telemetry` | Ограниченный фоновый collector legacy BGP/health и local BIRD; persistent snapshots и fenced discovery. |
| `bundle` | Упаковка и проверка бандлов для нод. |
| `signing` | Криптографическая проверка подписей. |
| `birdfmt` | Форматирование и фрагменты конфигурации BIRD, вызовы `birdc`. |
| `birddeploy` | Логика применения конфигурации к BIRD (используется в цепочке деплоя). |
| `config` | Переменные окружения `EVOBGP_*`. |
| `observability` | Метрики Prometheus, HTTP middleware. |
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; пакет лишь логирует URL, NATS не участвует в исполнении задач. Текущий Registry дополнен PostgreSQL persistence/claim/reclaim. |
| `pipeline` | Ingest+render для `module_refresh`: CDN/AS/IP/DOMAINS → `module_prefix_snapshot` (batch `COPY`, per-module lock) → агрегация CIDR O(n log n) → `CreateRenderRevision`. Fast-path снапшота — `module.input_hash`; DoH — TTL-кэш `domain_resolve_cache`; scheduler — jitter границ интервала. |
| `nodedispatch` | Panel→Node HTTP wake-up (`POST /v1/agent/sync`) после `deploy_apply`. |
| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik; опционально firewall failover (`/v1/firewall/*`). |
@@ -71,7 +72,7 @@ flowchart LR
Operator --> API
NodeCLI --> API
API --> PG
Sched -->|HTTP_or_DB| API
Sched -->|optional_single_tenant_HTTP| API
Sched --> PG
Ingest --> PG
Render --> PG
@@ -79,7 +80,21 @@ flowchart LR
Agent --> BIRD
```
Очередь задач по-прежнему **in-memory в процессе API** (`jobs.Registry`); отдельный контейнер `evobgp-scheduler` не разделяет память с API и дергает refresh по HTTP. Полноценный брокер (NATS) и общая очередь `job_audit` между процессами — в следующих итерациях.
При PostgreSQL очередь использует общий `job_audit`: enqueue фиксируется до HTTP `202`, claim получает owner/lease/fencing token, а heartbeat продлевает владение. List/get/cancel/report читают общее хранилище. `Server.StartBackground` запускает durable queue и deployment reconciliation; Memory-режим сохраняет задания только в процессе. Подробности и ограничения — [durable-jobs.md](durable-jobs.md). NATS не участвует в исполнении задач. Разрыв между сохранением CRUD и автоматическим enqueue пока не закрыт transactional outbox; ошибка постановки явно сообщается клиенту.
### Фоновая статистика
`Server.StartBackground` также запускает `telemetry.Collector`. Фиксированное число workers получает due targets из store с lease/fencing, опрашивает прежние endpoints агента 2.56.2 и локальный BIRD, затем сохраняет независимые BGP/health результаты. Discovery записывается в той же fenced транзакции. Истёкший владелец не может заменить snapshot или discoveries. Конфигурация speaker проверяется перед фиксацией; смена endpoint/секрета не переносит успешное наблюдение прежней конфигурации на новую.
GET peers/speakers/discoveries читает сохранённое состояние; `live=1` не вызывает сетевой fan-out. POST `/v1/telemetry/refresh` объединяет запросы в пределах 10 секунд и назначает следующий сбор. `received_at` и настроенный интервал определяют свежесть, `expires_at` передаёт её срок клиенту; просроченные sessions не становятся текущим Established. При недоступности хранилища snapshots API отвечает 503.
Для local BIRD несколько CP различаются стабильной authority. `EVOBGP_TELEMETRY_LOCAL_ID` должен идентифицировать именно BIRD-узел; одинаковый ID допустим только при чтении одного экземпляра BIRD. Две одновременно актуальные authority одного master дают неизвестный статус. Устаревшая authority после перезапуска не скрывает единственный свежий снимок. Настройки интервала, workers и локальной идентичности — [quickstart.md](quickstart.md).
### Снимки префиксов модуля
`UpdateModulePrefixSnapshot` выполняет read-modify-write атомарно. В PostgreSQL transaction advisory lock, чтение текущих строк, замена снимка и `module.input_hash` используют одну транзакцию и одно соединение. Потеря соединения не позволяет продолжить запись через другое. GET читает заголовок и строки в repeatable-read транзакции; ошибки scan/iteration не превращаются в неполный успешный результат. Memory использует блокировку ключа tenant/module и фиксирует snapshot/hash под общей mutex. Callback преобразует переданный снимок и не вызывает store I/O.
Это сериализует фиксацию снимка, но ещё не гарантирует единую версию всех входов ingest: конфигурация и input hash вычисляются до транзакции, а tenant manifest/version CAS остаётся отдельной работой B04. Контекст передаётся в операции snapshot и lookup; устаревшие вызовы других store-сущностей требуют последующего расширения.
## Диаграмма: microvps (`evobgp-all`)
@@ -96,7 +111,7 @@ flowchart LR
Внутри `evobgp-all` все воркеры используют **тот же** `store` и `jobs.Registry`, что и HTTP handlers, поэтому `module_refresh` выполняется в том же процессе без HTTP.
Профиль Compose **`microvps-full`** добавляет к этому стеку **Web UI** (nginx → `evobgp-all`), **NATS** и **Prometheus** без отдельных контейнеров воркеров (функционально то же, что отдельные `scheduler`/`ingest`/… в reference). Запуск и лимиты под ~1 ГиБ RAM — в [quickstart.md](quickstart.md).
Профиль Compose **`microvps-full`** добавляет к этому стеку **Web UI** (nginx → `evobgp-all`) и **Prometheus** без отдельных контейнеров воркеров (функционально то же, что отдельные `scheduler`/`ingest`/… в reference). NATS включается отдельно через профиль `nats`. Запуск и текущие лимиты — в [quickstart.md](quickstart.md); пригодность для ~1 ГиБ RAM требует замера под нагрузкой.
## Поток pipeline (ingest → render)
+101
View File
@@ -0,0 +1,101 @@
# Backend: исходные измерения и воспроизведение проблем
Материалы этапа 0 [плана развития](backend-improvement-plan.md). Сборщик ниже выполняет только чтение Docker; он не запускает, не останавливает и не обновляет сервисы, не вызывает refresh/apply и не подключается к production автоматически.
## Ресурсный snapshot
На машине с Docker daemon и PowerShell выполните из корня репозитория:
```powershell
./scripts/collect-backend-baseline.ps1 -ComposeFile @('deploy/compose/docker-compose.yaml', 'deploy/compose/docker-compose.microvps-full.yaml') -Profile microvps-full -Samples 3 -IntervalSeconds 15
```
Укажите те же Compose-файлы, profile и при необходимости `-ProjectName`, с которыми запущен исследуемый стек. Относительные Compose-пути разрешаются от корня репозитория. Для другого окружения передайте его файлы явно. Скрипт работает с **текущим Docker context**; перед запуском проверьте, что выбран нужный хост.
Результат по умолчанию записывается в игнорируемый Git каталог `artifacts/backend-baseline/`; другой путь задаётся `-OutputPath`. Код выхода `0` означает собранные snapshots работающих контейнеров, `2` — неполный результат или недоступную среду. Статусы `partial/unavailable` нельзя считать успешным замером. `configured_services` содержит определения из effective config (включая другие profiles, если их возвращает Compose), а `containers` — фактически найденные контейнеры выбранного проекта. Нельзя суммировать всю конфигурацию как реально запущенный workload.
Сохраняются только разрешённые поля: commit, image references/IDs, Docker version, CPU/RAM хоста, настроенные/фактические resource limits, OOM/restart counters и результаты `docker stats`. Raw Compose config разбирается только в памяти. Environment, commands, labels, mount paths, логи и текст ошибок Docker не записываются. Сам отчёт всё равно содержит инфраструктурные метаданные; перед публикацией его нужно проверить.
Это точечные значения CPU/memory и накопительные IO counters, а не p95/p99, peak RSS или нагрузочный benchmark. Отдельно зафиксируйте dataset (число модулей/префиксов/узлов), workload, длительность, BGP-сессии, API latency, DB waits, PSI и cgroup events по D00. Для delta IO сравнивайте один и тот же container ID без restart; новое значение counters после пересоздания не означает снижение нагрузки. Объём образа/слоёв, pull/build time и 24h soak пока собираются отдельно.
## Регрессионные проверки достоверности live-status
```powershell
go test ./internal/httpapi -run '^TestStage0Repro' -count=1 -v
```
После исправления S01 build tag удалён: эти тесты входят в обычный обязательный набор. Проверяются сохранённый Established при timeout/отсутствующей сессии/collector и ложный healthy у неопрошенного master. Дополнительные проверки в `peers_live_test.go` подтверждают, что успешный опрос постороннего speaker не превращает неизвестное состояние назначенного speaker в `absent` или `ok`. Это локальные регрессии HTTP-слоя; они не заменяют стенд с реальными агентами.
## Статус фактической проверки этапа 0
### Дополнение: legacy 2.56.2 и durable jobs
Обязательная версия удалённых speakers уточнена пользователем: **2.56.2**. Тег `v2.56.2` разрешён в commit `924f111e4871e1d6d47c9916d7da1b9051718f1c`. На Windows собраны исторические node/agent без изменения их исходников. Node прошёл pull/auth/signature против текущего HTTP API, включая отказ на неверном ключе подписи; agent serve прошёл dispatch/ACK/health/protocols с текущим подписанным bundle и fake BIRD. Дополнительный CLI v1.0.0 также прошёл проверку. Это source-built executables, не опубликованные Docker images и не настоящий BIRD/TLS стенд.
CI-скрипт `scripts/ci/legacy-node-compatibility.sh` закрепляет commits и проверяет отсутствие skips нужных binary tests. `scripts/tests/mirror-base-images.test.sh` локально прошёл восемь offline сценариев; он не измеряет загрузку или сборку Docker.
Тест отчёта jobs подтверждает чтение durable speaker progress новым Registry, собственный результат recovery вместо старого результата родителя и изоляцию tenant. PostgreSQL-проверки lease/generation/restart и fresh seed добавлены в обязательный CI gate; локально без DSN результат остаётся неподтверждённым.
После добавления отчёта выполнены `go test ./... -count=1`, `go vet ./...`, `scripts/lint-go.ps1`, OpenAPI lint/codegen и frontend typecheck/lint/build — exit 0. После финального исправления порядка health observations повторены тесты затронутых пакетов и legacy binaries 2.56.2 — exit 0. Astra завершила проверку очереди, fencing, health token, отчёта и fixture pruning без оставшихся блокирующих замечаний. Реальный SQL/race gate не выполнен локально; обязательных CI DB/repository tests теперь 17.
### Дополнение: разделение внешних HTTP pools
Восстановление прежнего доступа к настроенным private DoH/RIPE явно разрешено пользователем после первоначального отказа автоматической проверки. Политика и границы описаны в [outbound-policy-decision.md](outbound-policy-decision.md). Тест actual bootstrap DOMAINS refresh с локальным DoH прошёл; CDN через paired client сохраняет public-only guard. Три последовательных TLS CDN-запроса используют одно соединение.
До начала S02 полный Go test, lint-go и legacy 2.56.2 binary smoke прошли. Astra независимо прогнала RIPE/DoH/CDN/breaker тесты четырёх пакетов. Bounded breaker cache проверен на capacity1024, active/open retention, metric cleanup и 253-символьный hostname с завершающей точкой. Production DNS/network policy и реальные ресурсы хоста не проверялись.
### Дополнение: HTTP admission и квоты
После добавления общего 8 MiB body preflight, четырёх body slots и tenant/global квот preview/lookup выполнены OpenAPI lint/codegen, frontend typecheck/lint/build, `scripts/lint-go.ps1` и повторный полный `go test ./... -count=1` — exit 0. Legacy node/agent 2.56.2 повторно прошли HTTP/dispatch compatibility tests. Astra независимо проверила auth-first, отмену без преждевременного освобождения слота, Retry-After/CORS, ограниченный CDN error-body и отсутствие изменений legacy GET/SSE.
Первый полный прогон выявил нестабильность очереди при равном CreatedAt. Исправлен tie-breaker RequestSequence/ID; детерминированная regression с одинаковым временем прошла 50 повторов у Astra, jobs/store — 20 у Sol. После исправления полный прогон и lint прошли. Реальный PostgreSQL/race и нагрузочные RSS/CPU измерения этим не заменяются.
### Дополнение: B05 CSV и bundle limits
CSV byte/row/field preflight, отсутствие записи при превышении и отмена прошли focused tests и независимую проверку Astra. Полный `go test ./... -count=1`, Go lint и frontend gates прошли после добавления CSV/bundle limits; после финальных sparse/retry-body исправлений повторены bundle/nodecli/httpclient/pipeline tests и проверка старых node/agent 2.56.2 — exit 0. OpenAPI lint/codegen также успешны.
Windows-проверка extraction выполнила hard-link часть, затем получила `SKIP` при создании symlink из-за отсутствия привилегии. Это не подтверждение symlink runtime safety: на Linux тест не допускает такой skip и должен пройти в обычном CI `go test -race`. Конфигурация из миллиона повторяющихся IPv6 route-строк проверяет допустимый размер bundle, а не BIRD или производительность реального миллиона уникальных маршрутов.
В локальной среде 26.10.2026 доступны Go 1.26.1 и golangci-lint; Docker/psql и C-компилятор в PATH не найдены, `CGO_ENABLED=0`. Поэтому production-ресурсы, реальный PostgreSQL integration и Go race detector здесь не подтверждены. CI использует проектную версию Go; результаты локального более нового Go не заменяют этот прогон. Конкретные выполненные проверки и оставшиеся gates фиксируются в [статусе плана](backend-improvement-plan.md).
| Проверка 26.10.2026 | Результат |
|---|---|
| `go test ./... -count=1` | Exit 0; повторный полный прогон после добавления тестов. DB-тесты без DSN пропущены. Для тестовых localhost-серверов потребовался запуск вне sandbox |
| `go vet ./...` | Exit 0 |
| `pwsh -NoProfile -File scripts/lint-go.ps1` | Exit 0 после исправления области поиска файлов и замечаний errcheck в новых тестах; выполняет gofmt check, vet и golangci-lint |
| `pwsh -NoProfile -File scripts/tests/collect-backend-baseline.test.ps1` | Exit 0; offline mock, проверены несколько контейнеров, whitelist без секретов и partial/exit 2 при отсутствии stats |
| JSON guard PostgreSQL CI | На синтетических событиях: полный pass принят; skip, пропущенный обязательный тест и подмена subtest отклонены. Сам PostgreSQL job локально не запускался |
| `go test ./internal/httpapi -run '^TestStage0Repro' -count=1 -v` | После S01 включён в основной набор; ожидается exit 0. Первоначально opt-in тесты воспроизводили дефекты, теперь закрепляют исправленное поведение |
| `go test ./... -race -count=1` | Не выполнен: race требует CGO и отсутствующий C toolchain. Проверка остаётся обязательной в CI |
| Resource collector без Docker | Сформирован `status=unavailable`, причина `docker-cli-unavailable`; никаких чисел VPS из этого запуска не получено |
| Повторная проверка Astra | Замечания к guard обязательных DB-тестов и полноте stats исправлены; оставшихся блокирующих замечаний в проверенном diff нет |
### Runtime-итерация B01/B02/S01
- Полный `go test ./... -count=1` после первой runtime-итерации: exit 0 вне sandbox для localhost-тестов. PostgreSQL без DSN по-прежнему пропускается.
- После замечаний Astra повторный `go test ./internal/birddeploy ./internal/jobs ./internal/nodedispatch ./internal/httpapi -count=1`: exit 0. Проверены исправления include scanner, отмена dispatch и старый пустой ответ 204.
- Повторный review Astra не выявил блокирующих замечаний в исправлениях B01/B02/S01. В UI замечания по знаменателям deployment/peer KPI и явному unhealthy исправлены и покрыты регрессиями; повторный review без блокеров.
- UI: `pnpm --filter @evobgp/web run typecheck`, `lint`, `build` — exit 0; Vitest — 9/9. В lint сохраняется существующий warning `use-file-upload.ts`. Визуальный прогон в браузере не выполнялся.
- `pwsh -NoProfile -File scripts/lint-go.ps1` после исправлений B01/B02/S01 — exit 0 (gofmt, vet, golangci-lint).
- B02 Linux-тесты компилируются, но не запускались локально. CI `bird2` дополнен реальным `bird -p`; configure в этом тесте имитируется. Это не проверка работающего BIRD daemon, сетевых сессий или полного восстановления после аварии процесса.
- Новый apply требует существующий parse-valid `ActiveDir/bird.conf` как LKG. Управляемые include должны быть отдельными строками и ссылаться внутрь `bird.d`; внешние/вложенные/неподдержанные include отклоняются до переключения. Старый удалённый агент продолжает применять прежний bundle своим кодом; ограничение относится к обновлённому локальному apply.
- B05 transport/proxy и B02 symlink hardening: повторный полный `go test ./... -count=1` — exit 0; scoped tests, `go vet ./...` и `scripts/lint-go.ps1` — exit 0. Astra не нашла блокеров в проверенном diff.
### Следующая очередь: B04 migrations / S06 / CDN body
- После изменений полный `go test ./... -count=1` — exit 0. После финальных замечаний Astra тесты изменённых пакетов (`db`, `observability`, `repository`, `store`, `pipeline`, `httpapi`) — exit 0; последние lexical-guard регрессии дополнительно проверены `go test ./internal/db -count=1`. DB integration без DSN пропущены и не считаются пройденными.
- Общий `scripts/lint-go.ps1` — exit 0; OpenAPI lint — exit 0 с прежним предупреждением о неиспользуемой `FirewallRule`; `git diff --check` и shell syntax CI guard — exit 0.
- CDN: добавлен настоящий TLS HTTP redirect с контролируемым DNS/dial, проверяющий отказ до соединения с private target. Exact-limit/overflow, ingest/preview и gzip tests прошли; данные больше 8 MiB не парсятся частично. Реальный внешний DNS/production при этом не использовался.
- S06: проверены отсутствие ложных нулей при ошибке БД, сохранение legacy labels и нормализация только нового ряда. Desired/published/applied/live метрики остаются следующей работой.
- B04: 33 существующих PostgreSQL migration-файла проходят проверку transaction-only policy. Новые DB-тесты используют изолированные схемы; CI guard требует все 23 обязательных DB/repository tests. Повторный review Astra закрыл обходы через комментарии, dollar-identifiers и E-string boundaries. Реальный PostgreSQL, race и Linux BIRD всё ещё требуют соответствующего окружения.
CI-запуск для подготовленной изолированной тестовой БД на Linux: задать `EVOBGP_TEST_DATABASE_URL`, затем `bash scripts/ci/postgres-integration.sh`. Использовать только отдельную тестовую БД: тесты применяют миграции и выполняют CRUD/prune. Не указывать production DSN. В Gitea job эта БД создаётся service-контейнером PostgreSQL 16; 23 обязательных DB/repository tests исполняются последовательно с `-race` и проверкой событий pass/skip/fail.
## PostgreSQL runtime: Windows, 26 октября 2026
На отдельном временном кластере PostgreSQL **16.15** выполнены все **23 обязательных** DB/repository integration tests из `scripts/ci/postgres-integration.sh`: обе группы PASS, 0 skip, 0 fail. Проверены миграции и код до **000036 включительно**, до начала добавления manifest migration 000037. Сценарии включают competing migration locks/rollback, durable queue/deployment leases, telemetry discovery rollback и snapshot transaction cancellation/concurrency/partial write/потерю backend-соединения.
Прогон использовал `go test -count=1 -p 1 -json`: DB-фильтр `^TestPostgresMigration.*Integration$`, repository-фильтр `Integration$`. Все 23 именованных pass проверены по JSON-логам независимо от exit code пакетов. Это Windows-прогон **без `-race`**; обязательный Linux/race CI остаётся отдельным gate.
Portable runtime взят из [официального архива EDB](https://www.enterprisedb.com/download-postgresql-binaries), файл `postgresql-16.15-5-windows-x64-binaries.zip`; SHA-256 проверен по vendor `.sha256`: `43bb45f173a6f08cf1d29a97a6d8deb119e8e8093a24c00d2d1001a0ccaa8281`. Кластер слушал только `127.0.0.1` на свободном порту с временным паролем. Служба Windows не создавалась; после тестов `pg_ctl status` подтвердил отсутствие работающего сервера. Локальные JSON-логи сохранены в ignored `artifacts/backend-baseline/pg-test-5c93b7da43dc4c488ca7796a686213c2/`.
+58
View File
@@ -0,0 +1,58 @@
# Совместимость control plane и спикеров: проверяемая основа
Обязательная legacy-версия по указанию пользователя — **2.56.2**. Новый control plane должен обслуживать эти спикеры без синхронного обновления агентов. Digest развёрнутых образов и версия BIRD внутри них ещё не подтверждены. Дополнительная проверка исходников `v1.0.0` ниже не является обещанием поддержки всех промежуточных релизов.
## Проверка исполняемого CLI 2.56.2
26.10.2026 `git ls-remote` подтвердил `refs/tags/v2.56.2` → `924f111e4871e1d6d47c9916d7da1b9051718f1c`. Из этого неизменённого commit локально собран `evobgp-node` (Go 1.26.1, Windows/amd64, зависимости из локального кэша). Тест `TestLegacyNodeBinaryCurrentControlPlane` запускает именно исторический executable против настоящего HTTP-handler нового CP: latest/pull, проверка текущего подписанного бандла, отказ неверному/чужому токену и повреждённому архиву. Локально тест прошёл для **2.56.2** и отдельно для **1.0.0**.
Отдельно собран и запущен `evobgp-agent` **2.56.2** в режиме `serve`. `TestLegacyAgentBinaryCurrentDispatch` проверил текущий dispatch, авторизацию, получение подписанного текущим builder бандла, явный ACK, health и BGP protocols. BIRD/birdc заменены изолированными helper-процессами; CP для этого теста выдаёт заранее подписанный бандл через тестовый HTTP-handler. Проверка прошла локально на Windows, без изменения BIRD хоста. В CLI-тесте дополнительно проверен отказ для корректного архива с чужим публичным ключом.
В CI добавлена матрица двух pinned commits (`scripts/ci/legacy-node-compatibility.sh`), включая agent для 2.56.2; обязательные тесты не могут быть пропущены незаметно. CI job ещё не запускался. Это source-built binaries, не опубликованные Docker images: реальные image digests, TLS, настоящий BIRD parse/apply/rollback и mixed-fleet стенд остаются отдельными gates.
Для повторного локального запуска задайте `EVOBGP_TEST_LEGACY_NODE_BIN` абсолютным путём к собранному historical CLI и выполните `go test ./internal/httpapi -run '^TestLegacyNodeBinaryCurrentControlPlane$' -count=1 -v`. Без переменной тест явно пропускается. Проверка не вызывает apply и не меняет конфигурацию BIRD хоста.
Агент проверяется аналогично: `EVOBGP_TEST_LEGACY_AGENT_BIN` и `go test ./internal/nodedispatch -run '^TestLegacyAgentBinaryCurrentDispatch$' -count=1 -v`. Вызов sync проходит через старый агент, но команды BIRD выполняет только тестовая подмена. В Linux CI включён race detector; таймаут учитывает задержку завершения race-instrumented helper-процессов.
## Доступные свидетельства
- В локальном Git имеются теги `v1.0.0`–`v2.54.0` и исходники `cmd/evobgp-node`, `internal/nodecli`, `internal/bundle` для `v1.0.0` (`6ca34b900859665eede22044d734b698059bdf84`). Это исторические исходники, а не сохранённые исполняемые файлы или подтверждение установленной версии.
- CLI в `v1.0.0` запрашивает `GET /v1/speakers/{speaker_id}/revisions/latest`, затем `GET /v1/speakers/{speaker_id}/bundle/{revision_id}` с `Authorization: Bearer`. Ответ latest читается по `revision_id`. Публичный ключ передаётся CLI отдельно в base64/hex; текущий CP предоставляет `GET /v1/bundle/signing-public-key` для роли с `bgp:network:read`.
- Формат `v1.0.0` — gzip/tar с `manifest.json`, `bundle.sig` и файлами конфигурации. В манифесте: `revision_id`, необязательный `speaker_id`, `created_at`, `files` (`path`, `sha256`), `signature_algorithm=ed25519`, `public_key_base64`. Подпись Ed25519 считается по точным байтам `manifest.json`; содержимое файлов сверяется по SHA-256. Сопоставление исходников `v1.0.0` и текущего `internal/bundle` показывает ту же структуру, но само по себе не доказывает совместимость реального бинарника или синтаксиса BIRD.
- `internal/agentserver` и `internal/nodedispatch` в дереве `v1.0.0` отсутствуют. Проверки их минимальных ответов ниже — характеристика текущего протокола и имитация legacy-формы, а не тест агента релиза `v1.0.0`.
## Автоматические проверки этой основы
| Тест | Что проверяет | Предел вывода |
|---|---|
| `internal/bundle/legacy_compatibility_test.go` | Текущий builder создаёт прежний набор полей и порядок tar entries; независимая проверка Ed25519/SHA-256 по полям старого манифеста принимает результат. Замороженный подписанный архив `internal/bundle/testdata/current_baseline_signed_bundle.tar.gz` проверяется отдельно | Фикстура создана текущим builder с тестовым ключом, не извлечена из старого релиза; исторический бинарник не запускается |
| `internal/httpapi/node_legacy_compatibility_test.go` | Node bearer auth и tenant scope для latest revision и bundle; получение ключа с операторским токеном; проверяемый подписанный bundle без новых обязательных полей | In-memory HTTP; не проверяет TLS, внешнюю БД и CLI-процесс |
| `internal/nodedispatch/legacy_compatibility_test.go` | Sync отправляет только старое поле `revision_id` и Bearer secret; health с отсутствующими дополнительными полями остаётся читаемым | Ответы агента моделируются, реальный старый агент не запущен |
В первой реализации B01 подстановка запрошенного ID удалена: `2xx` без `applied_revision_id` остаётся совместимой успешной доставкой с `confirmation=unconfirmed`. Явный совпадающий ID даёт `agent_reported`; он не означает независимую проверку фактического BIRD-конфига. Несовпадающий или устаревший ACK не меняет applied. Запрос sync по-прежнему содержит только `revision_id`, новые поля от старого агента не требуются. Старый health без времени наблюдения принимается; время опроса фиксирует CP, а его источник указан явно.
Публикация и обработка ACK теперь дополнены persistent deployment progress и атомарными проверками lease/generation в PostgreSQL. Реальная проверка нескольких CP writers и restart остаётся открытым integration gate. Смешанные старый и новый CP не поддерживаются при миграции очереди; удалённые агенты 2.56.2 при этом сохраняют прежний протокол, см. [порядок обновления](durable-jobs.md).
Агент 2.56.2 не сериализует параллельные sync. Сохранённая reservation на две минуты после неоднозначного ответа уменьшает риск перекрывающейся доставки, но timeout не доказывает завершение старой операции. Поэтому CP сохраняет unknown/unconfirmed и сверяет последующий health; exactly-once, отсутствие любых перекрытий и подтверждённый BIRD config hash для старого агента не обещаются.
## Что ещё нужно до release gate B08
### Ограничения новых сборок bundle/node
Полная таблица и настройки: [bundle-limits.md](bundle-limits.md).
Формат manifest, Ed25519-подписи и HTTP endpoints сохраняются. Новые сборки ограничивают сжатый архив 128 MiB, распакованный tar и суммарное содержимое 512 MiB, отдельный payload 256 MiB, manifest 2 MiB, число записей 4096 и длину пути 1024 байт. Лимиты проверяются также при создании bundle; downloader ограничивает metadata latest до 64 KiB и текст ошибки до 4 KiB. Для встраиваемого вызова доступны `bundle.Limits` и `SyncConfig.BundleLimits`; отдельные operator CLI flags пока не добавлены.
Это верхние защитные пределы, а не обещание низкого RSS на малом VPS: архив и проверенные файлы ещё хранятся в памяти. Тест с миллионом повторяющихся строк IPv6 проверяет вместимость типичного размера текста, не валидность миллиона уникальных маршрутов или нагрузку BIRD. Повторные проверки source-built node/agent 2.56.2 с текущим builder прошли.
Новые verifier/extractor отклоняют дубликаты, неописанные manifest файлы, небезопасные пути и ссылки, проверяют gzip trailer. Эти защиты действуют в новых бинарниках; уже установленный 2.56.2 не получает их без обновления. Поддержка его прежнего протокола сохраняется.
### Оставшиеся проверки
1. Для обязательной версии **2.56.2** подтвердить установленные image digests `evobgp-agent`/`evobgp-node`, BIRD, режим `serve` или pull-only и источник ключа подписи. Сохранить инвентарь смешанного парка; source commit сам по себе не определяет digest опубликованного образа.
2. Сохранить реальные старые binaries/images и подписанные bundle-артефакты с подтверждённым происхождением. На стенде запустить matrix: новый CP + старый и текущий agent/CLI, предыдущий CP + новый agent, смешанный парк. Проверить pull/sync, auth/TLS, подпись, BIRD parse/apply/rollback и offline/reconnect.
3. Ввести согласованные capabilities в OpenAPI и совместимый fallback. Новые ACK, config hash, telemetry и формат бандла отправлять по объявленным возможностям. `404/405` документированного endpoint может обозначать legacy; timeout, auth/TLS error и повреждённый ответ — ошибка, без ослабления защиты.
4. Завершить проверку deploy: разделение `unconfirmed`/`agent_reported`, persistent progress и fencing реализованы; `verified` требует нового подтверждения и проверки хеша. Выполнить PostgreSQL concurrent/restart tests и стендовую проверку поздних ACK, неоднозначных timeout, восстановления и ограниченного повторения доставки.
5. Проверить BIRD-синтаксис на реально установленной версии и canary без BGP flap; сохранить путь отката CP и предыдущее поколение схемы БД на окно rollback.
Пока эти шаги не выполнены, тесты подтверждают только локальный baseline совместимости формата и HTTP-форм, а не готовность смешанного production-парка.
+460
View File
@@ -0,0 +1,460 @@
# EvoBGP · План развития backend
> **Дата:** 26 октября 2026 · **Основа анализа:** checkout `924f111`
> **Фокус:** достоверная статистика, надёжное применение BGP-конфигураций, устойчивые фоновые задачи, экономное использование VPS.
## 1. Главный вывод
EvoBGP уже имеет содержательное ядро: сбор префиксов из AS/CDN/доменов/ручных списков, материализованные снимки, агрегацию CIDR, ревизии, подписанные бандлы, управление BIRD, удалённые агенты, PostgreSQL-мониторинг и обслуживание БД. Основная задача следующей итерации — укрепить гарантии между этими частями и сделать наблюдаемое состояние достоверным.
**Рекомендуемый порядок:** исправление статусов и выполнения задач → единый сбор телеметрии → снижение фоновой нагрузки → новые операторские функции.
**Обязательное условие:** обновление control plane не должно требовать одновременного обновления удалённых спикеров. Уже развёрнутые старые агенты/CLI должны продолжать получать, проверять и применять совместимые бандлы. Новые возможности включаются по capabilities конкретной ноды; ограничения наблюдаемости показываются явно. Политика перехода и проверки — B08.
| Приоритет | Значение | Правило исполнения |
|---|---|---|
| **P0** | Неверное подтверждение сетевого изменения, небезопасное применение | Выполнить до расширения автоматического deploy |
| **P1** | Потеря/повтор задач, недостоверная статистика, ошибки изоляции и защиты | Ближайший инженерный этап |
| **P2** | Производительность, эксплуатация, полезные функции | После стабилизации зависимостей |
| **P3** | Расширенные возможности | По подтверждённой потребности |
### Границы исследования
Первоначальный анализ исходников, миграций, Compose и CI выполнен тремя субагентами **Astra**. Реализация ведётся субагентами **Sol / medium**, проверка — **Astra**; фактический прогресс отмечен ниже. Production, реальные RSS/CPU/IO, размер опубликованных образов и нагрузка операторов не измерялись. Docker CLI в локальной среде отсутствует. Поэтому проценты экономии и гарантированная вместимость VPS здесь не заявляются.
Найденные при анализе устаревшие описания очереди и Svelte UI в `docs/architecture.md` и `docs/overview.md` исправлены на этапе 0. План учитывает фактическую реализацию и подключённый **ReUI PRO**.
## 2. Что уже есть и что следует сохранить
### Статус реализации · 26.10.2026
**Этап 0 «Проверяемая основа» и первая очередь runtime-исправлений.** Реализация: Sol / medium; независимая проверка: Astra. Старый формат запроса node/sync и формат бандла сохраняются. Полный этап 0 остаётся открытым до стендовых проверок ниже.
- [x] Добавлен CI job `postgres-integration`: временная PostgreSQL 16, последовательное применение миграций и repository tests с `-race`; проверяется успех каждого из 23 обязательных тестов, skip/отсутствие теста не даёт зелёный результат.
- [x] Добавлены тесты повторного применения миграций, конкурентного SQL claim, сохранения строки job при повторном открытии pool и idempotency row; исправлен невалидный tenant fixture в существующем CRUD-тесте.
- [x] Добавлены воспроизведения двух дефектов live-status: stale Established и ложный healthy без poll. После исправления S01 tag `backend_repro` удалён; проверки включены в обязательный набор.
- [x] Зафиксирован локальный baseline совместимости: исторические исходники `v1.0.0`, минимальные node/sync/health wire contracts, независимая проверка подписи/хешей и замороженный signed bundle текущего builder. Подробности: [совместимость](backend-compatibility.md).
- [x] Добавлен read-only PowerShell collector ресурcных snapshots с whitelist полей и offline проверками multi-container stats, отсутствия утечки env и неполного результата: [инструкция](backend-baseline.md).
- [x] Уточнены architecture/overview: гибридная Registry + PostgreSQL очередь с ограничениями, реальный tenant-refresh endpoint и текущий React/ReUI UI.
- [x] Исправлен локальный Go lint runner: проверяет пакеты текущего модуля вместо посторонних вложенных worktree и возвращает ошибки vet/linter.
- [ ] Подтвердить новый integration job реальным запуском CI/PostgreSQL. Локальный запуск без DSN компилирует тесты, но DB-проверки пропускаются; это не интеграционный успех.
- [ ] Воспроизвести взаимодействие локального Registry и DB claim при ожидании дольше grace, повторном idempotency key и настоящем restart worker/jobs API. SQL-level тесты выше не доказывают эти гарантии.
- [ ] Инвентаризировать реально установленные версии/digests speakers и прогнать матрицу на старых binaries/images. Source-derived контракт и текущая fixture не заменяют эту матрицу.
- [ ] Снять effective Compose и реальные CPU/RAM/IO, выполнить workload/24h soak на целевом VPS; локально Docker отсутствует.
Результаты локальной валидации и ограничения среды приведены в [backend-baseline.md](backend-baseline.md).
**Первая очередь runtime: B01 / B02 / S01 / B08.** Отметки ниже относятся к реализованным частям, а не к закрытию пунктов целиком.
- [x] B01/B08: bare HTTP 2xx означает доставку без подтверждения (`unconfirmed`); applied обновляется только после явного ACK нужной опубликованной ревизии либо успешного локального apply. Формат запроса старому агенту сохранён.
- [x] B01: результат job содержит результаты по speakers и счётчики published/applied/unconfirmed/failed; ошибки доставки больше не превращаются в общий успешный apply.
- [x] S01: неопрошенное состояние и отсутствующие счётчики возвращаются как unknown/null; сохранённый Established не подменяет текущее наблюдение. Добавлены quality/source/timestamps и проверки назначенного speaker.
- [x] B02: реализованы атомарное переключение `bird.conf` внутри mounted ActiveDir, межпроцессная блокировка, проверяемые поколения и rollback с отдельным context. Astra проверила исправления inline/commented include bypass; переносимые тесты прошли.
- [x] B01: отмена останавливает публикацию следующим speakers, игнорирует поздний ACK и сохраняет частичный результат со статусом cancelled; добавлены регрессии отмены dispatch и legacy empty 204.
- [ ] B02: выполнить Linux fault/process-lock тесты и реальный `bird -p` CI; проверить configure/recovery с настоящим daemon, ENOSPC и аварийное завершение между фазами. Cross-compilation и fake birdc не закрывают эти проверки.
- [x] S01/B01 UI: качество и время наблюдения, last-known подсказки, unknown вместо ложного down/0, результаты неподтверждённого deploy. Исправлены знаменатель deployment, disabled peers и явный unhealthy; Astra проверила. Typecheck/lint/build — exit 0, Vitest — 9/9. Визуальный browser review ещё не выполнен; полный F01 этим не закрыт.
- [x] B01: добавлены persistent progress по speakers и атомарные lease/generation проверки публикации/ACK; `/jobs/{job_id}/report` читает сохранённые результаты после перезапуска, включая связанные recovery jobs. Проверены восстановление отчёта, приоритет собственного прогресса recovery и tenant isolation.
- [x] B01: bounded reconciliation сохраняет порядок исходных намерений, дедуплицирует recovery и ограничивает его тремя раундами. Health observation token отклоняет запоздалые результаты; timeout/отсутствующее поле не восстанавливает устаревший статус. Memory tests порядка/retry budget прошли; SQL-регрессии добавлены в CI.
- [ ] B01: выполнить PostgreSQL restart/fencing/health gate и стенд legacy agent. Двухминутная reservation после неоднозначного ответа 2.56.2 ограничивает повторы, но не доказывает остановку удалённой операции; exactly-once и состояние BIRD не заявляются.
- [x] B08 executables: обязательная legacy-версия **2.56.2**; её pinned source-built CLI прошёл pull/auth/signature против текущего CP, агент serve — текущий dispatch/ACK/health/protocols с подписанным bundle и fake BIRD. Добавлена CI-матрица 2.56.2 и дополнительного CLI 1.0.0; реальный BIRD этим не проверен.
- [ ] B08: реальные image digests 2.56.2, agent/BIRD apply/rollback и смешанный парк по-прежнему требуют стендовой проверки; source-built CLI не закрывает всю матрицу.
- [x] B05 transport/proxy: CDN URL проверяется при dial/redirect, DNS-ответ проверяется целиком, соединение выполняется с проверенным IP; CDN proxy отключён. Приватный node transport отделён. Forwarded-заголовки доверены только `EVOBGP_TRUSTED_PROXY_CIDRS`; auth limiter ограничен 4096 активными IP без вытеснения действующих счётчиков. Astra проверила; полный Go test и lint прошли.
- [x] B05 CDN body: ingest/preview отклоняют распакованный ответ больше 8 MiB вместо обрезания; ограничено чтение error-body при retry. Проверены точный предел/+1, gzip и реальный TLS redirect с контролируемым DNS на private target.
- [x] B05 CSV: превышение 8 MiB, 10000 строк данных или 64 полей в записи возвращает 413 до создания записей. Убрано тихое обрезание, ограничено усиление памяти CSV-парсером, отмена останавливает последующие записи. Тесты exact/+1, неизвестной длины, корректного префикса перед превышением и quoted/multiline полей прошли.
- [x] B05 bundle: ограничены compressed/expanded/logical bytes, payload, manifest, число файлов и пути; закрыты sparse tar, gzip trailing expansion, дубликаты/неподписанные файлы и выход через ссылки. HTTP retry читает не более 64 KiB error-body. Astra проверила; bundle/nodecli/httpclient/pipeline tests и повторные legacy 2.56.2 tests прошли. [Пределы и настройки](bundle-limits.md).
- [x] B05 HTTP: общий предел входного тела 8 MiB проверяется после auth до любых handler side effects; до 4 body-запросов одновременно с удержанием памяти до завершения handler. Добавлены локальные tenant/global concurrency и minute quotas preview/lookup, 429 + CORS-visible Retry-After. Refresh/retry сохраняют replay и shared queue quotas; legacy GET не занимают body slots. Astra review и focused HTTP tests прошли.
- [x] B05 внешние ответы: RIPE 32/4 MiB и DoH 2/1 MiB читаются полностью с проверкой +1, включая gzip; введены default deadlines без ослабления caller/profile deadline. После явного разрешения пользователя восстановлены внутренние DoH/RIPE через отдельный pool; CDN остаётся защищённым и переиспользует соединения. Bootstrap private-DoH и TLS connection-reuse tests прошли; [политика](outbound-policy-decision.md).
- [x] B05/B06 circuit cache: не более 1024 hostname, canonical keys, отказ до dial при заполнении, idle expiry закрытых записей и удаление Prometheus label set. Активные/open записи не вытесняются. Тесты capacity, aliases, lease/ref lifecycle и metric cleanup прошли.
- [ ] B05: выполнить symlink extraction regression в Linux CI (локально Windows пропускает из-за привилегий) и стендовые проверки нагрузки. Локальные квоты не доказывают экономию RSS или общую кластерную квоту; старые правила redirects/proxy для DoH сохранены по разрешённой политике.
- [x] S06 основа: добавлены явные inventory/stored метрики, сохранены старые имена и raw labels. При ошибке чтения БД выдаётся collection_success=0 без ложных нулевых агрегатов; добавлены alert и [описание семантики](metrics-semantics.md).
- [ ] S06: desired/published/confirmed applied/observed live группы и общий статус обязательных размещений ещё не завершены.
- [x] S02 реализация: persistent snapshots, bounded collector с lease/fencing, независимые BGP/health, local authority и atomic discoveries; GET не опрашивает ноды. Coalesced POST refresh подключён к UI. `expires_at`, ошибки GET и offline скрывают устаревшие значения, включая memoized графики. Astra не обнаружила блокеров; 30 web tests, typecheck/lint/build и scoped backend tests прошли. Протокол 2.56.2 сохранён, source-built binary smoke пройден.
- [x] S02 PostgreSQL: lease/fencing, discovery rollback и coalesced refresh исполнены на временном PostgreSQL 16.15; все 23 DB/repository tests пройдены без skip (состояние до manifest migration 000037).
- [ ] S02 release gate: Linux/race и реальный mixed-speaker/BIRD стенд остаются проверками B08.
- [x] B04 миграции: закреплённое соединение, advisory lock до создания schema_migrations, SQL+version в одной транзакции, bounded cleanup и отмена ожидания. Проверка SQL закрывает comment/dollar-identifier/строковые обходы transaction control; Astra повторно проверила.
- [x] B04 acquisition: отменяемое ожидание и явные ошибки перенесены в атомарный snapshot RMW; отдельные lock sessions заменены transaction advisory lock на том же соединении, что выполняет запись.
- [x] B04 scheduler scope: reference Compose использует trusted DB enqueue; HTTP-режим ограничен явным tenant. Проверены два tenants, idempotency, system-tenant exclusion и отмена до работы; Astra review и полный Go suite прошли. ListTenantIDs/ListModules ещё требуют context, поэтому общий deadline пока не ограничивает все DB reads.
- [x] B04 snapshot I/O/RMW: context и ошибки чтения/записи проходят в pipeline/lookup; snapshot read-modify-write и module hash атомарны на одном соединении. Memory concurrency/rollback и обработка partial reads проверены; полный Go test, vet/lint и независимый Astra review прошли.
- [ ] B04 manifest: фиксировать единую версию входов tenant-refresh и не допускать публикации результата по устаревшей конфигурации. PostgreSQL connection-loss/rollback/lock-contention tests прошли на изолированной БД; новые manifest-сценарии потребуют отдельного прогона.
- [x] B04 PostgreSQL до 000036: 23 обязательных DB/repository tests прошли на изолированном PostgreSQL 16.15 под Windows, без skips; race не запускался. [Протокол проверки](backend-baseline.md).
- [ ] B04 PostgreSQL 000037+: после manifest изменений повторить SQL runtime gate; отдельно выполнить Linux/race CI.
- [x] B03: единый durable enqueue/claim, lease/heartbeat/fencing, shared jobs API, квоты и graceful shutdown; replay исходного запроса предшествует динамическому подбору модулей и rate limit. Scoped Go tests прошли, Astra выявленные ошибки replay/seed/метрик устранены.
- [x] B03: при одинаковом CreatedAt выбор и список задач используют стабильные RequestSequence/ID. Добавлена регрессия с 32 одинаковыми timestamps; jobs/store прошли 20 повторов, независимая проверка Astra — 50 повторов regression. Это не обещание порядка завершения параллельных workers или snapshot-пагинации.
- [ ] B03: выполнить SQL/race/restart gate и закрыть разрыв между CRUD и enqueue transactional outbox. Заголовок enqueue-failed делает ошибку видимой, но не устраняет crash gap.
- [x] D02/D04 конфигурация: NATS вынесен в optional профиль, Prometheus получил named volume/retention, deploy worker переведён в static runtime, Go build context сужен, зеркало передаёт digest и обновляется явно без фиксированных пауз. Существующие Docker log caps сохранены. Static review Astra и YAML-проверки прошли; [перенос истории Prometheus](docker-resources.md) описан.
- [x] D02 CLI flags: агент принимает флаги до/после subcommand и отклоняет неизвестные/лишние аргументы watch/serve. В трёх Compose watch-командах флаги идут перед subcommand для совместимости с 2.56.2; argv-тесты и YAML-проверка прошли.
- [ ] D02/D04 runtime: Docker build/config engine, реальные замеры и перенос данных ещё не выполнены. Watch hash-gating, host log replay/rotation, согласование BIRD и SBOM остаются следующими частями Docker-этапа.
| Подсистема | Существующая реализация | Направление развития |
|---|---|---|
| Источники префиксов | `AS_PREFIXES`, `CDN_CIDRS`, `DOMAINS`, `IP_RANGES`; кэши, conditional HTTP, снимки | Контроль свежести, происхождения и качества данных |
| Pipeline | Batch-запись снимков, input hash, агрегация CIDR, ревизии и diff | Согласованный снимок tenant, отмена, лимиты ресурсов |
| BIRD / узлы | Parse-check, LKG, подписанные бандлы, pull/apply, dispatch | Подтверждённый applied, сериализация и надёжное восстановление |
| Jobs | Локальный Registry, ограничение активных workers, PostgreSQL audit/claim/reclaim | Одна авторитетная очередь, lease, идемпотентность, чтение после restart |
| Наблюдаемость | Prometheus, live BGP polling, PostgreSQL snapshots, runtime logs | Фоновый collector, история BGP, качество измерений |
| Доступ | API keys, роли, JWT permissions, tenant context | Проверка доверенных прокси, URL transport policy, интеграционные проверки изоляции |
| Эксплуатация | Maintenance policies, dry-run, pruning, аудит обслуживания | Предсказуемые бюджеты, backup/restore, контроль деградации |
| UI | React, shadcn/ui, ReUI PRO, Frame kit | Использовать существующие блоки для новых backend-возможностей |
Не нужно заново создавать очередь, CDN-кэш, revision diff, rollback, lookup, maintenance или мониторинг PostgreSQL: они уже существуют. Нужно усилить их контракт и завершить недостающие части.
## 3. План улучшения backend
### B01 · Достоверный жизненный цикл deploy — P0
**Основание:** `internal/jobs/worker.go:493` вызывает `SetLastAppliedRevision` до удалённого dispatch. Ошибка dispatch сохраняется в metadata, однако job завершается `Succeed` (`:545`, `:573`). Это позволяет считать удалённую ревизию применённой без подтверждения ноды.
**Работы:**
1. Разделить `desired_revision`, `published_revision`, `dispatched_revision`, `applied_revision`, `verified_revision`.
2. Ввести deployment и результаты по каждому speaker: `pending → dispatched → applying → applied → verified`, отдельно `failed`, `unknown`, `timed_out`.
3. Обновлять `applied_revision` только по явному аутентифицированному сообщению ноды о конкретной revision ID. Для новых агентов дополнительно проверять deployment ID/generation и хеш фактической конфигурации; хеш обязателен для уровня `verified`, но не становится новым обязательным полем старого протокола. Для legacy-агента сохранять уровень подтверждения `agent_reported`; без явного revision ID оставлять результат `unconfirmed`, продолжая совместимую доставку. Старое подтверждение не должно перезаписывать новое поколение deploy (см. B08).
4. Dispatch трактовать как доставку запроса. Для недоступной ноды сохранять desired state и повторять доставку с ограниченным backoff.
5. В результате массовой операции показывать частичный успех и ошибки по узлам. Не переопределять молча семантику существующего `202`: обновить OpenAPI и UI согласованно.
**Приёмка:** недоступная нода никогда не получает новый `applied`; потеря ответа и повторный dispatch не применяют один и тот же deployment дважды; после перезапуска CP корректно восстанавливает прогресс; 1 ошибка из 5 узлов явно видна в результате.
### B02 · Безопасное применение BIRD и восстановление — P0
**Основание:** `internal/birddeploy/apply.go:69–97` игнорирует ошибки создания LKG, меняет файлы по одному, а восстановление после ошибки использует исходный context. При отменённом context повторный configure также может не выполниться. Локальная функция не содержит блокировки конкурирующих apply.
**Работы:** сериализовать операции на один BIRD instance, включая несколько процессов; использовать отдельный staging на deployment; проверять полный набор файлов и реальные include paths; выполнять переключение согласованного дерева на одном filesystem; не продолжать без пригодного LKG; удалять исключённые из новой ревизии фрагменты; выполнять rollback с отдельным ограниченным recovery-context; сохранять отдельно исходную ошибку и результат восстановления.
**Приёмка:** тесты двух конкурентных apply, отмены, заполненного диска, ошибки parse/configure и падения между фазами; после каждого сценария BIRD использует целиком старую или целиком новую проверенную конфигурацию. Статус `restored` выдаётся только после подтверждённого восстановления.
### B03 · Довести PostgreSQL-очередь до надёжного контракта — P1
**Основание:** `internal/jobs/job.go:351–410`, `internal/jobs/durable.go:62–131`, `internal/repository/job_audit.go:23–70`. Enqueue запускает локального worker; сохранение в БД — best-effort. Повтор idempotency key создаёт новый UUID локально, а SQL conflict сохраняет старый ID строки. Ошибки SQL игнорируются. Reclaim ориентируется на возраст running, без полноценного протокола lease.
**Работы:**
1. Для PostgreSQL сделать `INSERT/claim` авторитетным источником выполнения; HTTP `202` возвращать после успешной фиксации задания. Memory-режим оставить явным режимом разработки.
2. Определить семантику idempotency: одинаковый ключ и payload возвращают одну операцию в пределах retention; другой payload с тем же ключом — конфликт; намеренный повтор — новый ключ.
3. Добавить `owner_id`, `lease_until`, heartbeat, номер попытки и fencing token. Claim выполнять только при наличии свободной worker capacity.
4. Реализовать ограниченные retries для временных ошибок, terminal failure и управляемый повтор оператором. Модель доставки — at-least-once с идемпотентными эффектами, без обещания exactly-once.
5. Ограничить размер ожидающей очереди и число заданий tenant. Текущий semaphore ограничивает активных workers, но не число ожидающих goroutine.
6. List/get/cancel/report читать из общего хранилища: сейчас `routes.go:870–944` обращается к локальному Registry.
7. При shutdown прекратить claim, завершить либо отпустить lease и закрыть БД после остановки workers.
**Приёмка:** две API-реплики, restart и повтор запроса дают один логический job; живой долгий worker не перехватывается; сбой записи в БД не даёт ложного `202`; job виден и отменяется через любую реплику; нагрузочный burst не вызывает неограниченный рост памяти.
`SKIP LOCKED` подходит для конкурирующих потребителей очереди, но сам по себе не решает lease и идемпотентность: [официальная документация PostgreSQL](https://www.postgresql.org/docs/18/sql-select.html#SQL-FOR-UPDATE-SHARE).
### B04 · Миграции, блокировки и tenant isolation — P1
**Основание:** `internal/db/apply.go:34–50` выполняет проверку версии, SQL и запись версии отдельно; миграции запускаются при открытии pool. В `internal/repository/module_snapshot.go:177` используется advisory lock с background-context. Standalone scheduler обходит tenants, но HTTP refresh отправляет с одним bearer (`internal/scheduler/run.go:70–116`).
**Работы:**
- Один владелец миграций: dedicated migrate step либо advisory lock на закреплённом соединении; транзакция на поддерживаемую миграцию, явный отдельный путь для non-transactional DDL; проверка совместимости schema при readiness.
- Передавать context через store/repository I/O; lock acquisition обязан иметь timeout и возвращать ошибку, а не продолжать без блокировки.
- Сделать tenant-refresh согласованным: сериализация render на tenant и фиксация версий входных снимков. Начать с транзакционного manifest входных версий, затем оптимизировать.
- Для scheduler выбрать явную модель: доверенный DB worker либо строго ограниченная service identity с tenant-scoped запросами. Не использовать пользовательский bearer как неявный доступ ко всем tenants.
- Проверить tenant scope для jobs, revisions, node acknowledgements, monitoring и maintenance. Глобальный мониторинг БД должен быть доступен только соответствующей административной роли.
**Приёмка:** параллельный старт API/workers не ломает миграции; неудачная миграция не записана как успешная; отмена операции прерывает ожидание lock; тесты минимум с двумя tenants доказывают отсутствие смешивания данных.
### B05 · Защита исходящих запросов и ограничение нагрузки — P1
**Основание:** CDN URL уже проверяется, но DNS precheck с кэшем (`internal/pipeline/cdn_url.go:96–132`) отделён от фактического соединения, а общий HTTP transport не проверяет redirect/dial (`internal/httpclient/httpclient.go:18–25`). `clientIP` доверяет X-Forwarded-For (`routes_audit.go:134`), а rate limiter не удаляет старые IP-ключи (`ratelimit.go:36–60`). Это подтверждённые пробелы защиты; эксплуатация в текущей сети не проверялась.
**Работы:** проверять целевые адреса при соединении и каждом redirect; определить поведение при HTTP proxy; разделить публичные CDN/DoH и разрешённые приватные адреса агентов; ограничить размеры скачивания/распаковки/CSV и длительность операций; добавить квоты дорогостоящих refresh/preview/lookup; доверять forwarded headers только от настроенных proxy; ограничить и очищать rate-limit cache.
**Приёмка:** тесты redirect на private address, смены DNS-ответа, IPv4-mapped IPv6, oversized/decompression payload; корректный доступ к явно разрешённому private agent; поддельный XFF не обходит лимит; cardinality IP не увеличивает память без границы.
### B06 · Ускорить lookup, API и pipeline на больших наборах — P2
**Основание:** `internal/lookup/lookup.go:193` перебирает модули и snapshots; для домена поиск повторяется по каждому resolved IP (`:173–185`). Ошибка DNS сейчас не отражена отдельно от отсутствия IP-совпадений. Размер выигрыша требует benchmark.
**Работы:**
- Добавить repository-операцию membership lookup с tenant predicate и индексом по сетевым префиксам; выбрать PostgreSQL `inet/cidr` и индекс по результатам EXPLAIN. Для memory backend — компактный префиксный индекс, если профиль оправдает сложность.
- Batch lookup всех resolved IP; лимиты/пагинация совпадений; `resolution_status` и используемый resolver; единая семантика с DoH pipeline.
- Для list endpoints — пагинация в SQL, а не после загрузки всей коллекции; summary DTO без тяжёлых snapshots и BIRD-конфига.
- Профилировать allocations, COPY/merge, render и bundle; уменьшать копирование больших срезов и повторную сериализацию по конкретным pprof/benchmark данным.
- Повтор внешних HTTP: учитывать 429/Retry-After, jitter и общий deadline; сохранять существующий circuit breaker, добавив наблюдаемость и ограничение его кэша.
- Не менять все store-интерфейсы за один раз: начать с горячих I/O путей и cancellable методов.
**Приёмка:** baseline и сравнение на 10k/100k/1M префиксов в подходящем профиле; одинаковый результат IPv4/IPv6 до и после; отсутствие full snapshot scan в DB lookup; API latency не растёт пропорционально числу открытых браузеров.
### B07 · Интеграционные проверки и эксплуатационная готовность — P1/P2
**Основание:** CI запускает `go test -race`, но в `.gitea/workflows/quality.yaml` нет отдельного PostgreSQL service/DSN для repository tests. Эти тесты вызывают `t.Skip` без `EVOBGP_TEST_DATABASE_URL` (`internal/repository/backend_test.go:17`). Таким образом зелёный общий Go job не доказывает работу PostgreSQL-сценариев.
**Работы:** добавить обязательный integration job с временной PostgreSQL и применением миграций; отдельные сценарии durable queue, cancel, two-tenant, restart, prune и concurrent migration; стенд CP + два агента + BIRD для apply/rollback. Добавить backup/restore runbook и периодическое восстановление в изолированное окружение: БД, signing key, node credentials, конфигурации и последние рабочие ревизии. Согласовать RPO/RTO по реальным требованиям.
OpenTelemetry сейчас является stub (`internal/observability/otel.go:11`). Сначала завершить структурированные логи и request/job/deployment correlation IDs; полноценный tracing включать опционально с sampling, когда он нужен для расследований.
**Приёмка:** CI явно подтверждает исполнение DB-тестов, а не skip; аварийный сценарий воспроизводим; восстановленный стенд проверяет подписи и применяет известную ревизию; все изменения проходят существующие Go/OpenAPI/BIRD quality gates.
### B08 · Обратная совместимость и постепенное обновление спикеров — P1, обязательный release gate
**Цель:** новый CP обслуживает смешанный парк старых и новых speakers. Отсутствие нового telemetry/ACK API не должно лишать старый speaker возможности работать по существующему pull/sync протоколу. Совместимость доставки и полнота подтверждения — отдельные характеристики.
**Основание в текущем коде:** `internal/nodedispatch/dispatch.go` уже читает `applied_revision_id` из sync-ответа, а `health.go` — `last_applied_revision_id` и `last_sync_at`. При этом sync-клиент подставляет запрошенную revision ID, если ответ её не содержит. Такая подстановка не является подтверждением ноды и должна быть устранена без отказа от legacy dispatch. Текущий формат бандла описан в `internal/bundle/pack.go` и проверяется в `verify.go`.
#### Контракт совместимости
| Поверхность | Что сохранить | Как вводить новое |
|---|---|---|
| Node API control plane | Существующие `/v1/speakers/{id}/revisions/latest`, `/bundle/{revision_id}`, получение signing public key и используемые механизмы auth | Новые обязательные поля/семантика — через согласованную capability или отдельную версию API; старые запросы остаются допустимыми |
| Agent API | Текущие sync/health запросы и известные поля ответа | Не отправлять новые поля старым strict-decoders; расширенный ACK и telemetry — только после определения возможностей |
| Signed bundle | Для legacy сохранить tar.gz, layout файлов, manifest, Ed25519/SHA-256 и проверку доверенного ключа | Новый формат выдавать только совместимому клиенту; legacy-вариант создавать и подписывать отдельно, не менять уже подписанные байты |
| BIRD-конфигурация | Синтаксис и capabilities реально установленной на speaker версии | Рендерить по профилю совместимости узла; новую директиву не отправлять неподдерживающему BIRD |
| Credentials / TLS | Действующие ключи доступа, сохранённый signing key и проверка TLS | Ротация через отдельный переход с периодом перекрытия там, где протокол его поддерживает; не обходить подпись/auth ради совместимости |
| UI / monitoring | Старые узлы остаются управляемыми и видимыми | Явные `legacy`, `unsupported`, `unconfirmed`; отсутствие новой метрики не означает неисправность BGP |
#### Определение возможностей и fallback
1. Перед изменением протокола инвентаризировать реальные версии/дигесты `evobgp-agent`, `evobgp-node`, BIRD и режимы работы: agent serve, pull по расписанию/CLI, иные используемые варианты. Зафиксировать поддерживаемый legacy baseline, а не предполагать, что все ноды равны текущему checkout.
2. Новые агенты объявляют protocol version и capabilities, например `deployment_ack`, `config_hash`, `telemetry_v2`, `bundle_v2`. Конкретные имена закрепить в OpenAPI. Возможности важнее сравнения строк версии.
3. Отсутствие capability metadata в корректном старом ответе либо документированный `404/405` capability endpoint означает legacy. Timeout, `401/403`, TLS error и некорректный ответ означают ошибку связи/доступа; они не должны автоматически запускать downgrade или обход проверки безопасности.
4. Для legacy использовать существующие request/response shapes и signed bundle. Если старый агент явно сообщает совпадающую revision ID, записывать `agent_reported`, но не утверждать проверку config hash. Голый `2xx` подтверждает только запрос; без подтверждения ревизии показывать `dispatched/unconfirmed`. Если старый health сообщает revision ID без времени наблюдения, показывать ограниченную свежесть, не выдавая его за ACK нового deployment.
5. Для нод без generation-aware ACK сериализовать deploy на speaker, не допускать неоднозначных перекрывающихся операций. Устаревший ответ не меняет текущий desired state; неоднозначность остаётся `unconfirmed` до новой проверки.
6. Старые pull-only узлы продолжают получать published revision без обязательного push/callback. Отсутствие ACK не вызывает бесконечный повтор apply: bounded retry относится к доставке, наблюдение — к reconciliation с backoff.
7. Новые health-gated rollout/auto-rollback допускают только узлы с достаточным уровнем подтверждения. Legacy-узел можно обновить прежним способом с явным ограничением автоматической верификации; его не блокируют глобально из-за отсутствующей новой функции.
#### Порядок перехода
- **Сначала CP:** добавить совместимые адаптеры, schema expansion и распознавание возможностей; новые флаги включать по speaker/cohort. Старый путь доставки остаётся рабочим.
- **Затем canary-агент:** проверить новый протокол на одной ноде, затем обновлять остальные поочерёдно. Смешанный парк — поддерживаемое рабочее состояние, а не только короткий момент установки.
- **Откат:** проверить новый CP со старым агентом и новый агент с предыдущим поддерживаемым CP. Сохранить старый digest, совместимый bundle generation и предыдущий протокол. Миграции БД — expand/contract: удаление/переименование полей откладывается за окно rollback; обратная совместимость схемы проверяется запуском предыдущего CP на обновлённой схеме.
- **Срок поддержки:** поддерживать инвентаризированные развёрнутые legacy-версии в рамках этого плана. Точный minimum supported version и окно deprecation зафиксировать после инвентаризации. Удаление legacy-пути — отдельное решение владельца проекта после миграции нод; не отключать его автоматически при первом новом релизе. Для опасного неподдерживаемого релиза — адресный план обновления, без бессрочного сохранения уязвимого поведения.
**Приёмка:** integration matrix использует реальные сохранённые старые binaries/images и golden bundles, а не только mocks текущего агента. Обязательные пары: новый CP + самый старый поддерживаемый агент/CLI; новый CP + текущий агент; предыдущий CP + новый агент; смешанный парк. Проверить pull/sync, подпись, auth, parse/apply/rollback, отсутствующие capabilities/ACK, reconnect после offline и поздний ответ. Обновление только CP не требует изменений на legacy-ноде и не вызывает BGP flap; недоступные новые функции явно обозначены. Проверка совместимости входит в CI/release gate каждого изменения node API, bundle, auth и BIRD render.
## 4. Сбор статистики: целевая модель
### S01 · Отделить текущее состояние от последнего известного — P1
**Основание:** `internal/httpapi/peers_live.go:275–283` не заменяет сохранённый session state, если live-проверка не нашла состояние. В `speakers_live.go:117–126` отсутствующий результат превращается в пустой view, а `:24–48` считает отсутствие ошибки успешным polling.
Для каждого измерения хранить `observed_at`, `received_at`, `last_success_at`, `source`, `quality`, `error_code`. Состояния качества: `ok`, `stale`, `unreachable`, `unknown`, `disabled`. Отдельно хранить последнее подтверждённое BGP state. Неизвестное значение — `null`/gap, а не ноль и не зелёный статус.
**Приёмка:** выключение агента переводит данные в stale/unreachable; удалённая сессия не остаётся Established; не настроенный collector не считается успешным; UI показывает возраст измерения и причину отсутствия данных.
### S02 · Перенести polling из чтения API в фоновый collector — P1
Исходно live-опрос был привязан к HTTP-чтению и короткому BGP cache, а health опрашивался в `collectSpeakerLiveStatus`. В реализации S02 эти пути удалены: GET читает persistent snapshots, collector выполняет ограниченные фоновые опросы.
**Целевая цепочка:**
```mermaid
flowchart LR
N[Agent / BIRD] --> C[Collector: jitter, limits, deadlines]
C --> L[Последнее состояние и качество]
C --> E[История событий]
C --> M[Prometheus metrics]
L --> API[Read API]
E --> API
API --> UI[ReUI PRO: dashboard и диагностика]
```
Начальный интервал — 15–30 секунд как проверяемая настройка, не гарантированный SLA. Добавить ограничение параллельных опросов, jitter, backoff для недоступных узлов и единственного владельца опроса в нескольких CP-репликах. Обычный GET отдаёт snapshot сразу. Кнопка «Обновить» ставит объединяемый запрос, не создаёт отдельный fan-out на каждого клиента.
**Приёмка:** при переходе с 1 на 20 UI-клиентов число poll к одному speaker практически неизменно; просроченные данные отмечаются явно; недоступный speaker не задерживает всю страницу; очередь collector ограничена.
### S03 · История BGP и событий — P2
Добавить историю переходов BGP state, длительности недоступности, flap count, времени последнего established, prefix counts по AFI/SAFI там, где их надёжно отдаёт BIRD. Отделить BGP FSM от состояния самого агента и транспорта управления.
Рекомендуемое разделение: PostgreSQL — события, deployment, аудит и ограниченные агрегаты; Prometheus — временные ряды с ограниченной cardinality. На минимальном VPS разрешить режим только событий и последнего snapshot. Не записывать каждый неизменившийся poll как полноценное событие.
Начальные retention-параметры для проверки: детальные события 30 дней, пятиминутные агрегаты 90 дней; это настраиваемые предложения, не требование к любому VPS. Оценивать объём по `число рядов × 86400 / интервал × дни`, затем измерять фактический размер с индексами/WAL. Partitioning вводить при подтверждённом объёме, не автоматически.
**Приёмка:** разрывы данных видны на графике; restart collector не создаёт ложный flap; повторные события дедуплицируются; очистка истории ограничена по времени/пакету; экспорт не блокирует ingest.
### S04 · Статистика качества источников и pipeline — P1/P2
Для каждого источника: время последней попытки и успеха, latency, HTTP status, ETag/cache hit, valid/invalid/duplicate prefix counts, возраст использованных данных, число последовательных ошибок. Для каждой ревизии — manifest источников и их версий, raw/aggregated counts, добавленные/удалённые префиксы, предупреждения о stale fallback.
Настроить пороги изменений: внезапный пустой ответ или массовое удаление префиксов переводят ревизию в `needs_review`; ручное исключение оставляет audit trail. Порог зависит от модуля, а не от одной глобальной константы.
**Приёмка:** оператор видит, почему данные не обновились; временная ошибка upstream не выдаётся за новый успешный ingest; опасное сокращение списка не публикуется автоматически без установленной политики.
### S05 · Правильная аналитика PostgreSQL и алерты — P2
Не смешивать накопительные статистики PostgreSQL с показателями за окно. Для rate/delta учитывать `stats_reset`, временной интервал и исчезновение query ID. В корреляции показывать только реально измеренные точки: текущий `internal/pgmonitor/correlation.go` объединяет историю jobs с одной текущей точкой cache hit, а `RecordCorrelationSnapshot` — заглушка.
Расширить существующие Prometheus alerts на потерю телеметрии, stale источники, очередь/возраст jobs, lease failure, deployment drift и ошибки БД. Предусмотреть окно обслуживания, задержку срабатывания, дедупликацию и восстановление. Правило «нет Established» отдельно от «не удалось опросить BIRD».
Не использовать job ID, revision ID, полный URL, домен или префикс как неограниченные labels. Это соответствует рекомендациям [Prometheus по cardinality](https://next.prometheus.io/docs/practices/naming/). Детали хранить в событиях и логах.
**Приёмка:** outage collector вызывает отдельный alert; графики не рисуют отсутствующие значения как нули; периодические snapshots не дублируются каждой API-репликой; количество рядов имеет проверяемый бюджет.
### S06 · Разделить смысл метрик — P1
`materialized_prefixes_max/sum` агрегируют сохранённые ревизии (`internal/repository/postgres.go:78–89`), а `bgp_peer_sessions` берёт сохранённый `meta_json.session_state` (`:100–116`). Локальная метрика Established не описывает все remote speakers. Это разные показатели: их нельзя объединять в один KPI «маршруты сейчас» или «сеть здорова».
Добавить явные группы **inventory / desired / published / confirmed applied / observed live**; для multi-speaker считать отдельно «есть Established хотя бы где-то» и «здоровы все обязательные размещения». Общий статус — healthy/degraded/down/unknown по задокументированной политике. При смене метрик сохранить переходный период и обновить dashboards/alerts. Ошибка DB-агрегации должна иметь collection error, а не выглядеть достоверным нулём.
**Приёмка:** добавление старой ревизии в историю не увеличивает KPI текущих маршрутов; outage одного обязательного speaker даёт degraded даже при Established на другом; fresh live и сохранённая конфигурация явно различаются в API.
## 5. Отдельный план улучшения и добавления функций
| ID / приоритет | Функция | Польза и объём MVP | Зависимости / критерий готовности |
|---|---|---|---|
| F01 · P1 | **Центр состояния сети** | Пиры, speakers, stale источники, deployment drift и очередь проблем на одном экране | S01–S02, B01; каждый статус имеет источник и возраст |
| F02 · P1 | **Паспорт ревизии** | Продолжение существующих preview/diff: происхождение данных, impact по узлам, warnings и подтверждения apply | B01, S04; можно объяснить каждое существенное изменение |
| F03 · P2 | **Управляемая раскатка** | Canary speaker, этапы rollout, лимит параллелизма, pause/resume, окно обслуживания | B01–B03/B08; ошибка canary останавливает продвижение; legacy-путь доступен отдельно от автоматической верификации |
| F04 · P2 | **История инцидентов и уведомления** | Flap, outage, stale, deploy failure; acknowledge, silence, webhook и один выбранный канал уведомлений | S03–S05; дедупликация, recovery notification и повтор доставки |
| F05 · P2 | **Объяснение принадлежности IP/домена** | Развитие existing lookup: источник → модуль → community → ревизия → desired/applied speaker | B06, F02; DNS failure отделён от «совпадений нет» |
| F06 · P2 | **Проверка источника до включения** | Развитие CDN preview: тип ответа, нормализация, ошибки строк, ожидаемый diff, тест расписания | S04, B05; тест не меняет опубликованную ревизию |
| F07 · P2 | **Backup и восстановление из панели** | Статус последних backup/restore drill, экспорт manifest, запуск разрешённой backup job | B03/B07; секреты не попадают в обычный экспорт UI |
| F08 · P2 | **Диагностический пакет** | Версии CP/agent/BIRD, job/deploy timeline, redacted logs, config hashes | B07; токены и приватные ключи исключены; лимит размера |
| F09 · P3 | **RPKI и защитные BGP-политики** | Warning-only проверка origin, max-prefix guard, затем опциональное применение политики | Отдельный дизайн и BIRD-тесты; отсутствие валидатора не меняет маршрутизацию молча |
| F10 · P3 | **Трафик по интерфейсам/потокам** | Если нужен именно трафик: SNMP/agent counters для bps/pps; NetFlow/IPFIX — отдельный сервис с sampling/retention | Отдельный бюджет и потребность; BGP counters нельзя выдавать за объём пользовательского трафика |
**ReUI PRO уже подключён.** Новые экраны строить на существующем `surface: frame` и `apps/web/src/components/reui-kit/`: `OpsDashboard` для F01, `ResourcePage` для событий/источников, `DetailPanel` для ревизии, `SettingsShell` для политик. Сохранять DataGrid/Filters и семантические Badge. Показывать stale/unknown/partial failure, а не только success/error. Переустановка UI-библиотеки или редизайн shell не нужны. Реализация UI должна следовать [контракту проекта](ui-design-contract.md) и обязательным typecheck/lint/build.
## 6. Отдельный план оптимизации Docker и ресурсов хоста
> **Меньший образ** сокращает загрузку, распаковку и место на диске. **Меньше RAM/CPU/IO** достигается также сокращением процессов, работы collectors, повторных configure, логирования и конкуренции. Эти результаты измеряются отдельно.
### Что уже оптимизировано
В `deploy/docker/gobinary/Dockerfile:26` уже используются multi-stage, `CGO_ENABLED=0`, `-trimpath`, `-s -w`; scheduler/ingest/render используют distroless targets. BuildKit registry cache и общая Go/web сборка присутствуют в `docker-bake.hcl`. `.dockerignore` исключает тяжёлые лишние каталоги. nginx уже работает с одним worker; Docker stdout rotation настроена. Повторное добавление этих механизмов не даст заявляемого нового выигрыша.
### D00 · Измеримый baseline — P1, до изменения лимитов
Зафиксировать image digests, effective Compose, vCPU/RAM, filesystem/cgroup, число tenants/modules/prefixes/speakers/peers. Собрать следующие сценарии на отдельном стенде:
| Сценарий | Что измерять | Зачем |
|---|---|---|
| Idle 15–30 минут | CPU, RSS/cgroup memory, SQL connections, число poll/configure, bytes logs | Найти постоянную лишнюю работу |
| Refresh → render → apply | Длительность, peak memory, allocations/GC, DB waits, BIRD parse/configure | Не оптимизировать idle ценой пикового OOM |
| Несколько refresh + UI | p95/p99 API, queue wait, CPU throttling, IO PSI | Проверить backpressure |
| Недоступные upstream/agents | Retries, goroutines, stale age, рост очереди | Убедиться в ограниченности деградации |
| Restart и 24h soak | Recovery, leaks, WAL/log growth, OOM/restarts, BGP flaps | Проверить эксплуатационный результат |
| Cold/warm image build и pull | Compressed bytes, unique/shared layers, build/pull/extract/start time | Отделить CI, сеть, диск и runtime |
Использовать effective `docker compose config`, `docker inspect` для фактических cgroup limits, `docker stats`, `docker system df -v`, cgroup memory/events/cpu.stat, PostgreSQL statistics и BIRD counters. `shm_size` — допустимая ёмкость, не автоматически занятая RAM. Shared layers не считать несколько раз.
### D01 · Исправить ресурсную модель профилей — P1
`docker-compose.microvps-full.yaml:9` обещает около 800 MiB суммы лимитов. С учётом наследуемого edge получается **1032M**: all 280 + PostgreSQL 256 + BIRD 96 + agent 48 + NATS 48 + web 48 + Prometheus 128 + Traefik 128. Это сумма верхних границ, **не измеренная RAM**, но заявленного резерва для ОС на 1 GiB VPS она не обеспечивает.
Production example и remote-speaker Compose не имеют аналогичных CPU/RAM caps. Подготовить согласованную матрицу ресурсов:
| Профиль | Предлагаемый состав | Политика |
|---|---|---|
| **microvps-core** — новый вариант | all + PostgreSQL + нужные BIRD/agent/edge; UI по потребности | Минимум фоновых процессов; внешний scrape либо ограниченный локальный мониторинг |
| **microvps-full** | Core + UI + локальная история/мониторинг | Допуск на 1 GiB только после workload-теста; если бюджет не сходится — честно поднять минимальные требования |
| **reference** | API и отдельные workers | Сохранить изоляцию процессов, но суммировать pools и concurrency |
| **remote-speaker** | BIRD + agent serve + защищённый endpoint | Отдельный бюджет на маршруты, reconfigure и reconnect; CP workload сюда не переносить |
Оставлять измеренный запас ОС/Docker и на пики BIRD/subprocesses. Не ужимать BIRD лимиты до появления flap/OOM. Согласовать значения с реальным cgroup, а не только YAML. Основа: [Docker resource constraints](https://docs.docker.com/engine/containers/resource_constraints/).
### D02 · Сократить лишние процессы и повторяющуюся работу — P1/P2
| Изменение | Основание | Ожидаемый эффект | Проверка |
|---|---|---|---|
| Вынести **NATS** в optional профиль | `internal/broker/connect.go:12–18` только логирует URL; Compose запускает сервис и зависимости | Удаление лишнего процесса и его RAM/IO в минимальном профиле | Внутренних потребителей нет; перед production проверить внешних клиентов; удалить зависимости/env согласованно |
| Change-driven или hash-gated **BIRD configure** | `cmd/evobgp-agent/main.go:86–94` безусловно configure в watch-цикле, обычно каждые 30s | Меньше работы при неизменной конфигурации | Реальный change применяется; restart/manual change покрываются reconciliation; учитывать, что remote serve — другой режим |
| Проверить CLI flags | `flag.Parse` до positional subcommand, Compose передаёт флаги после `watch` | Настройки interval/socket реально действуют | Тест argv для watch/serve; неверный flag не игнорируется молча |
| Ограничить **дублирование логов** | Production log collector делает `docker logs -f` в host-файлы; reconnect без cursor может повторять историю | Меньше disk IO и роста host logs | Cursor/rotation, dedup replay; Runtime Logs API продолжает работать |
| Использовать существующий cleanup осознанно | `internal/runtimelogs/policy.go:32–37` default disabled | Быстрый контроль накопления | Проверить политику/retention; cron threshold не выдавать за жёсткий byte cap |
### D03 · Настроить Go, PostgreSQL и мониторинг как общий бюджет — P2
- На текущем Go 1.24 провести A/B с `GOMAXPROCS` по CPU quota и `GOMEMLIMIT` с запасом для не-Go памяти и subprocesses. `GOMEMLIMIT` — мягкий лимит runtime, не полный RSS контейнера. Отдельно оценить обновление toolchain, где изменено container-aware поведение; не смешивать обновление версии с первыми correctness fixes. См. [Go GC guide](https://go.dev/doc/gc-guide) и [Go 1.25 runtime](https://go.dev/doc/go1.25#runtime).
- Настроить уже существующие `EVOBGP_DB_MAX_CONNS/MIN_CONNS` (`internal/db/open.go:28–36`) по роли. Сумма pools reference-профиля должна укладываться в PostgreSQL max_connections. `work_mem` оценивать с учётом числа одновременно выполняемых операций, а не как единственный буфер процесса.
- Ограничить общий job concurrency и отдельные бюджеты сетевого ingest/render/deploy/maintenance. Maintenance не должен вытеснять live collection и подтверждение deploy.
- Для Prometheus добавить явный named data volume, retention по времени и размеру с запасом для WAL; проверить recreate без потери истории. В текущем Compose mount `/prometheus` явно не закреплён. Для microvps проверить scrape 30–60s вместо 15s либо external scrape; оценить влияние на detection latency. Сокращение retention не означает пропорционального сокращения head RAM. См. [Prometheus storage](https://prometheus.io/docs/prometheus/latest/storage/).
### D04 · Уменьшить образы и ускорить сборку — P2
1. **Кандидат на облегчение deploy worker.** Target `evobgp-deploy` наследует birdc runtime (`docker-bake.hcl:238–240`), хотя `internal/deploy/run.go` выполняет наблюдение drift. Проверить startup/dependency paths и перевести в уже существующий static runtime, если BIRD tools действительно не вызываются. Считать unique-layer disk отдельно от размера одного image.
2. **Согласовать BIRD toolchain.** API/all/node используют сборку BIRD 2.14 на Debian, daemon/agent — Ubuntu package (`gobinary/Dockerfile:50–58`, `bird2/Dockerfile:4–12`). Сверить реальные версии/capabilities и parse-check на тех же конфигурациях. Затем унифицировать версию/источник и минимальные runtime libraries для новых образов, сохранив render/validation profile старых удалённых BIRD по B08. Обновление CP image не требует синхронного обновления всех speakers. Не переносить динамический BIRD в scratch без зависимостей.
3. **Сузить Go build inputs.** Текущая общая сборка видит root context. Проверить инвалидирование при web-only изменении; отделить Go sources/go.mod/go.sum/migrations и необходимые embed assets. Сохранить общую компиляцию targets, если benchmark не докажет пользу другого подхода. Проверить cold ephemeral builder: layer cache и cache mounts — разные механизмы. См. [Docker cache optimization](https://docs.docker.com/build/cache/optimize/).
4. **Обновляемые base digests.** Mirror script пропускает существующий tag и содержит пять фиксированных `sleep 8` (`mirror-base-images.sh:46–69`). Сделать контролируемый digest refresh и задержки только при retry/rate limit. Это улучшение CI/воспроизводимости, не экономия RAM приложения.
5. **Проверки поставки.** Фиксировать digest, проверять зависимости и базовые образы, генерировать SBOM при сборке; не включать tooling сборки в runtime. Не применять UPX и случайную смену libc ради красивого image-size числа без benchmark и совместимости.
### Приёмка Docker-этапа
До/после используют одинаковый workload и аппаратный профиль. Уменьшение compressed image size, unique disk, steady/peak memory, CPU и logs/day отражается раздельно. Обязательные ограничения: нет новых OOM/restarts и BGP flaps; наборы prefix/community совпадают; подпись, apply и rollback работают; p95/p99 и время refresh не ухудшены сверх согласованного допуска (начальная гипотеза — 5%, уточнить по шуму стенда).
Rollout: лаборатория → canary → остальные узлы; immutable tag/digest и предыдущий рабочий digest готовы. Пересоздавать только изменяемые сервисы с сохранением volumes. Экономия места не должна включать удаление рабочих данных или истории без retention-политики.
## 7. Очерёдность реализации и оценка объёма
Оценки ниже — предварительные **человеко-дни** одного опытного инженера, включая тесты и документацию. Это не календарное обещание. После первых воспроизведённых сценариев уточнить оценки; заложить резерв 25–35% на совместимость, миграции и fault injection. Работы могут идти параллельно только при разделении областей и соблюдении зависимостей.
| Этап | Результат | Задачи | Оценка |
|---|---|---|---|
| **0 · Проверяемая основа** | PostgreSQL integration CI, воспроизведения проблем, baseline ресурсов, инвентаризация legacy | B07, D00, начало B08, актуализация architecture | 3–5 дней |
| **1 · Доверие и защита** | Честные live/applied статусы, безопасный BIRD recovery, закрытие URL/proxy gaps | S01/S06, минимальный B01, B02, B05 | 8–14 дней |
| **2 · Устойчивое исполнение** | Общая durable очередь и jobs API, миграции/locks, multi-tenant scheduler | B03–B04, завершение B01 | 10–16 дней |
| **3 · Статистика и ресурсный бюджет** | Независимый collector, история, alerts, измеренная Docker-конфигурация | S02–S05, D01–D04, B06 | 10–18 дней |
| **4 · Операторские функции** | Dashboard, паспорт ревизии, rollout, incidents | F01–F04/F06, по очереди | 8–14 дней |
**Основной объём:** ориентировочно 39–67 человеко-дней до резерва. F05/F07/F08 и особенно F09/F10 оценивать отдельно после уточнения глубины; они не должны задерживать correctness и эксплуатационные исправления. Наличие ReUI PRO сокращает работу над типовыми экранами, но не заменяет backend-контракты и проверки отказов.
**Дополнение по совместимости B08:** предварительно ещё 4–8 человеко-дней на adapters, version matrix и тесты реальных legacy-артефактов; итого 43–75 человеко-дней до резерва. Уточнить после инвентаризации. B08 выполняется сквозным потоком этапов 0–3 и обязателен до выпуска изменённых протоколов B01/S02 или BIRD toolchain D04.
### Первая итерация: конкретный backlog
1. Добавить PostgreSQL integration job и regression tests: повтор idempotency key, queued > grace при двух workers, restart/history, stale Established, непроведённый poll.
2. Исправить модель live-status; добавить freshness/quality в OpenAPI и UI.
3. Прекратить преждевременную запись remote `applied`; показывать pending/unknown до ACK.
4. Сериализовать BIRD apply и сделать ошибки LKG/recovery явными.
5. Исправить transport-level CDN URL policy и trusted-proxy обработку.
6. Снять baseline effective Compose/RAM/CPU/IO, подтвердить бюджет microvps; подготовить optional NATS и bounded logs изменения.
7. Зафиксировать legacy baseline и golden bundles; добавить проверки «обновили только CP — старый speaker продолжает pull/sync/apply». Выполнить до выпуска изменений подтверждения apply и telemetry.
На первой итерации допустимо временно ограничить конкурентный deploy и число API-writers, пока lease/fencing не готовы. Это должно быть явным ограничением конфигурации и runbook, а не скрытой надеждой на один процесс.
### Зависимости, которые нельзя пропускать
```mermaid
flowchart TD
A[PG integration и воспроизведение отказов] --> B[Миграции и transactional enqueue]
B --> C[Lease / fencing / durable jobs API]
C --> D[Надёжный deployment workflow]
E[Сериализация BIRD и recovery] --> D
D --> F[Canary / автоматический rollout]
G[Честные статусы и timestamps] --> H[Фоновый collector]
H --> I[История / alerts / операторская аналитика]
J[Baseline ресурсов] --> K[Docker и runtime tuning]
H --> K
```
## 8. Измеримые результаты и правила готовности
| Направление | Контрольная проверка |
|---|---|
| Достоверность | Unknown/stale не отображаются как Established/healthy; applied всегда имеет подтверждение |
| Очередь | При DB outage запрос не принят ложно; restart восстанавливает задания; lease и эффект защищены от повторов |
| Сеть | Конкурентный apply и сбой rollback не скрываются; old/new поколения не смешиваются |
| Статистика | Число UI клиентов не определяет частоту polling; история имеет coverage/gaps и ограниченный retention |
| Производительность | Сохранён baseline; p95/p99, peak memory и GC сравниваются на фиксированном наборе данных |
| VPS | Effective limits проверены, достаточный запас памяти подтверждён пиковым workload и 24h soak |
| Доступ | Два tenant, несколько ролей, node ACK, redirect и forged forwarded headers покрыты тестами |
| Восстановление | Успешно выполнен restore drill со схемой БД, ключами и проверкой бандла |
| Контракт | OpenAPI обновлён до реализации; docs соответствуют реальному startup и режимам работы |
| Обратная совместимость | Новый CP обслуживает поддерживаемые старые speakers; смешанный парк проходит version matrix; отсутствие новых ACK/метрик не ломает доставку и не создаёт ложный verified |
Для Go-изменений — `gofmt`, `go vet ./...`, `scripts/lint-go.ps1`, `go test ./... -race -count=1`; для API — OpenAPI lint/codegen; для BIRD — scenarios и `bird -p`; для UI — обязательные typecheck/lint/build и релевантные тесты. Фактически выполненные проверки приведены в [backend-baseline.md](backend-baseline.md); наличие команды в плане не означает её успешный прогон.
## 9. Решения, которые стоит отложить до измерений
- Переход на микросервисы, Kafka/NATS/Redis как обязательную инфраструктуру: текущая PostgreSQL-очередь сначала должна получить корректные гарантии.
- Выделенный time-series кластер: сначала посчитать ряды, частоту и retention; для малого профиля PostgreSQL events + Prometheus достаточно как стартовая архитектура.
- Переписывание Go backend или замена PostgreSQL: аудит не выявил оснований для такого масштаба изменений.
- Автоматический rollback по одному неуспешному poll: сначала нужны quality/coverage, hysteresis и проверенный recovery.
- RPKI enforcement и сбор NetFlow: отдельный продуктовый этап с сетевым тестированием и самостоятельным ресурсным бюджетом.
## 10. Навигация по источникам проекта
| Область | Основные файлы |
|---|---|
| Контракт API / доступ | [openapi.yaml](openapi.yaml), [access.md](access.md), [permissions.go](../internal/httpapi/permissions.go) |
| Queue / persistence | [job.go](../internal/jobs/job.go), [durable.go](../internal/jobs/durable.go), [job_audit.go](../internal/repository/job_audit.go), [bootstrap.go](../internal/httpapi/bootstrap.go) |
| Deploy / recovery | [worker.go](../internal/jobs/worker.go), [apply.go](../internal/birddeploy/apply.go), [nodedispatch](../internal/nodedispatch/) |
| BGP live | [peers_live.go](../internal/httpapi/peers_live.go), [speakers_live.go](../internal/httpapi/speakers_live.go), [bgp_sessions.go](../internal/birdfmt/bgp_sessions.go) |
| Метрики / PostgreSQL | [metrics.go](../internal/observability/metrics.go), [pgmonitor](../internal/pgmonitor/), [alerts.yml](../deploy/prometheus/alerts.yml) |
| Источники / lookup | [pipeline](../internal/pipeline/), [lookup.go](../internal/lookup/lookup.go), [httpclient.go](../internal/httpclient/httpclient.go) |
| Docker / CI | [docker-bake.hcl](../deploy/docker/docker-bake.hcl), [Compose](../deploy/compose/docker-compose.yaml), [microvps override](../deploy/compose/docker-compose.microvps-full.yaml), [quality.yaml](../.gitea/workflows/quality.yaml) |
Номера строк в тексте относятся к исследованному checkout; при реализации ориентироваться также на имена функций. Приоритеты — инженерная оценка по последствиям возможного отказа, а не утверждение о произошедших production-инцидентах.
+25
View File
@@ -0,0 +1,25 @@
# Ограничения подписанных bundles
Формат `.tar.gz`, поля manifest, порядок файлов и Ed25519-подпись не изменены. Замороженный legacy fixture и проверка структуры старого manifest остаются тестами совместимости. Новые ограничения применяются до больших выделений памяти и не требуют обновления wire-протокола speakers 2.56.2.
| Ограничение по умолчанию | Значение |
| --- | --- |
| Сжатый bundle / HTTP download / локальный файл | 128 MiB |
| Весь распакованный tar, включая headers, PAX и хвост gzip | 512 MiB |
| Один payload-файл | 256 MiB |
| `manifest.json` | 2 MiB |
| Файловые записи tar, включая manifest и signature; служебные PAX ограничены общим объёмом tar | 4096 |
| Длина относительного пути | 1024 bytes |
| Ответ latest revision | 64 KiB |
| Диагностическое тело HTTP-ошибки | 4 KiB |
| Чтение error-body перед HTTP retry | 64 KiB на попытку |
Это верхние пределы обработки недоверенного ввода, а не гарантия работы на устройстве с малым RAM. Проверка пока хранит сжатый bundle и распакованные payloads в памяти. Большие конфигурации, включая порядок миллиона IPv6 route-строк, не ограничены маленьким лимитом отдельного CDN-источника. Размерный regression test использует повторяющиеся типовые строки и не заменяет benchmark уникальных префиксов или BIRD.
В Go API `bundle.Limits` позволяет явно менять бюджеты: `BuildGzippedTarWithLimits`, `VerifyGzippedTarWithLimits`, `WriteExtractedFilesWithLimits` и `ReadCompressed`. Нулевые поля выбирают defaults; отрицательные значения отклоняются. `nodecli.SyncConfig.BundleLimits` передаёт одинаковые бюджеты в download, verify и extract. Обычные команды CLI используют defaults.
Verifier проверяет gzip checksum, включая данные после tar EOF, ограничивает внутреннее чтение tar/PAX, отвергает дубликаты путей, ссылки и другие nonregular entries, а также payloads вне подписанного manifest. Пути должны быть каноническими относительными POSIX-путями: absolute, traversal, backslash, Windows drive/stream и device names отклоняются. Извлечение использует directory confinement `os.Root` из Go 1.24 и не следует существующим symlinks; существующий hard link удаляется до exclusive-create нового файла, сохраняя внешний файл.
Sparse entries отклоняются, а суммарный логический размер файлов дополнительно ограничен 512 MiB: физический размер sparse tar не отражает объём создаваемого содержимого.
Эти проверки действуют в новых binaries, не изменяют уже установленный agent 2.56.2 и не делают переключение всего дерева конфигурации атомарным: безопасное применение BIRD и восстановление описываются отдельно.
+17
View File
@@ -0,0 +1,17 @@
# Ресурсы и данные Docker
Этот файл сопровождает профили в `deploy/compose/`. Текущие значения `deploy.resources.limits` — верхние границы, а не измеренное потребление. Сумма лимитов `microvps-full` с edge и без NATS — 984M. Её нельзя считать подтверждением пригодности для VPS с 1 ГиБ RAM: нужны измерения ОС, Docker, BIRD и пиков refresh/apply. Лимиты BIRD и agent до таких измерений не снижены.
## Дополнительный NATS
Во всех основных Compose-вариантах NATS запускается только через `--profile nats` (или `COMPOSE_PROFILES=nats` для standalone stack). В коде `EVOBGP_BROKER_URL` только записывается в лог: внутренняя очередь не использует NATS. Перед отключением NATS на существующем хосте проверьте внешних клиентов порта 4222. Профиль не удаляет данные NATS и не меняет BIRD, WireGuard, agent или их тома.
## Данные Prometheus
Prometheus монтирует named volume `prometheus_data` в `/prometheus`. Retention по времени: 7 дней для `microvps-full`/standalone и 15 дней для `reference`; дополнительный предел по размеру задаёт `EVOBGP_PROMETHEUS_RETENTION_SIZE` (по умолчанию `2GB`). Это предел данных TSDB, а не строгий предел диска процесса: WAL и head block требуют отдельного запаса. Для самого маленького хоста сначала измерьте прирост диска и число серий, затем установите размер под доступную ёмкость.
**Обновление существующего стека:** прежний образ Prometheus мог хранить историю в анонимном Docker volume. Простое пересоздание контейнера с новым named volume покажет пустую историю. До `up` найдите текущий mount через `docker inspect <prometheus-container>`: у записи с `Destination=/prometheus` сохраните `Name`/`Source` и сделайте резервную копию. Для файлового копирования сначала остановите Prometheus либо используйте его поддерживаемый snapshot API. Перенесите содержимое старого тома в новый том с сохранением прав владельца; фактическое имя нового тома содержит префикс Compose project (проверьте через `docker volume ls` и `docker inspect`). Можно осознанно начать новую историю после проверки бэкапа. Проверьте запросы Prometheus до и после recreate. Не запускайте `docker compose down -v`: это удаляет также PostgreSQL, BIRD и другие тома проекта. В production перенос выполняйте отдельно от обновления BIRD/agent.
## Проверка эффекта
На одном оборудовании и одинаковом наборе маршрутов сравните `docker compose config`, `docker inspect` (фактические лимиты и mount), `docker stats`, `docker system df -v`, размер логов за сутки, cold/warm build и pull. После изменения проверьте отсутствие OOM/restarts и BGP flaps, совпадение prefix/community, работу apply/rollback и latency API. Локальная среда разработки без Docker не подтверждает экономию RAM или диска.
+20
View File
@@ -0,0 +1,20 @@
# Общая очередь задач
В production PostgreSQL является источником состояния jobs. HTTP возвращает `202` после фиксации enqueue; повтор ключа с тем же исходным запросом возвращает сохранённую операцию, включая terminal status. Другой запрос с тем же ключом получает `409`. Ключи старых записей без сохранённого request hash также дают `409`: исходный запрос нельзя надёжно восстановить из изменявшихся результатов. Для нового намеренного запуска нужен новый ключ.
API/all резервируют локальную worker capacity до claim. Lease содержит owner, срок и fencing token; heartbeat продлевает lease и сохраняет текущую metadata каждые 15 секунд. List/get/cancel/report используют общее хранилище. Потеря heartbeat/terminal write оставляет lease для восстановления; максимум три попытки, затем `failed`. Операторский `POST /v1/jobs/{job_id}/retry` сохраняет ID и исходные параметры, сбрасывает бюджет и проверяет квоты. При graceful shutdown незавершённые jobs освобождаются без расхода попытки; БД закрывается после остановки claim и сохранения результатов workers.
`EVOBGP_JOB_MAX_PENDING` (по умолчанию 10000) и `EVOBGP_JOB_MAX_TENANT_PENDING` (1000) ограничивают queued+running; `EVOBGP_JOB_MAX_CONCURRENT` ограничивает исполнителей одного процесса. Memory — явный режим разработки, без сохранности после перезапуска. Доставка — at-least-once: обработчики и побочные эффекты должны выдерживать повтор.
CRUD и автоматический enqueue пока не составляют одну SQL-транзакцию. Если изменение сохранено, а enqueue вернул ошибку, ответ содержит `X-EvoBGP-Job-Status: enqueue-failed`, а аудит — `bgp.job.enqueue_failed`. Повторять создание объекта не нужно; следует явно запустить refresh/reconcile. Падение процесса между CRUD и enqueue требует transactional outbox для полной гарантии восстановления.
## Обновление control plane
1. Остановить приём новых мутаций и дождаться завершения/отмены jobs старого control plane.
2. Остановить все старые процессы API/all и прочие CP writers. Старый best-effort протокол не поддерживает lease/fencing: смешанная работа старого и нового CP не поддерживается.
3. Выполнить migration `000034` и последующие migrations, затем запускать новый control plane. Legacy running rows получают 15 минут grace до reclaim; это средство восстановления записей, а не разрешение оставить старые writers работающими.
4. Проверить `/v1/ready`, общую видимость jobs, queue metrics и явный replay запроса.
Удалённые speakers с агентом 2.56.2 не являются writers очереди control plane; их останавливать или обновлять для этой миграции не требуется. Обратная migration удаляет данные протокола владения; rollback схемы требует полной остановки CP и заранее проверенного backup, а не смешанного rolling rollback.
PostgreSQL integration tests выполняются только с `EVOBGP_TEST_DATABASE_URL`. Пропуск этих тестов локально не подтверждает поведение SQL; CI требует их успешного выполнения без skips.
+21
View File
@@ -0,0 +1,21 @@
# Смысл метрик хранилища
Метрики `/metrics` с префиксом `evobgp_` описывают разные источники данных. Их нельзя складывать в один показатель «маршруты сейчас» или «сеть здорова».
| Метрика | Источник и смысл |
| --- | --- |
| `stored_revision_materialized_prefixes_max`, `stored_revision_materialized_prefixes_sum` | Максимум и сумма `materialized_prefix_count` **по всем сохранённым ревизиям**, включая историю. Не текущие маршруты, не опубликованная или применённая конфигурация. |
| `inventory_bgp_peers` | Число настроенных BGP peers в хранилище, независимо от `enabled` и фактической сессии. |
| `stored_bgp_peer_session_state{state}` | Число peers по сохранённому `meta_json.session_state`. Это последнее записанное значение или intent, а не свежий опрос BIRD. Значения `state` ограничены стандартными состояниями BGP, `unknown` и `other`. |
| `store_metrics_collection_success` | `1`, если агрегаты хранилища прочитаны; `0`, если чтение БД завершилось ошибкой. При `0` остальные метрики хранилища в данном scrape отсутствуют. |
| `bird_bgp_sessions_established`, `bird_protocols_scrape_success` | Локальный опрос BIRD процесса API; не охватывает все remote speakers. При ошибке опроса число Established не следует трактовать как достоверный нуль. |
Старые `materialized_prefixes_max/sum`, `bgp_peers_configured_total` и `bgp_peer_sessions{state}` продолжают экспортироваться с прежним смыслом на переходный период. Они устаревают; правила и dashboards нужно перевести на явные имена перед удалением старых рядов. Исторические ревизии могут увеличить оба ряда `*_sum`, поэтому использовать их как KPI текущих маршрутов нельзя.
Группы desired, published, confirmed applied и observed live, а также общий статус multi-speaker требуют отдельной реализации и политики свежести. Здесь эти метрики пока не объявлены.
## Circuit breaker внешних запросов
`upstream_breaker_open{host}` — последнее наблюдение breaker при запросе, не фоновая проверка доступности сервера. Истечение cooldown само по себе не обновляет gauge до следующего обращения; по этой метрике нельзя выводить свежий outage upstream.
Cache ограничен 1024 hostname на процесс, имена нормализованы в нижний регистр без завершающей точки; порты одного hostname разделяют breaker. Новая identity при заполненном cache отклоняется до соединения. Закрытые записи после 10 минут простоя удаляются вместе с текущим label set при следующем обслуживании cache; активные запросы и ещё открытый cooldown не вытесняются. Это ограничивает текущую память и scrape cardinality, но не удаляет исторические series из TSDB Prometheus.
+213 -9
View File
@@ -10,6 +10,20 @@ info:
**Соглашения:** префикс `/v1`; идентификаторы - UUID v7 или ULID (строки); время - ISO 8601 UTC.
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
**Общий предел request body:** после аутентификации каждый непубличный `/v1/...`
запрос проверяется целиком до выполнения handler: максимум 8 MiB (8388608 bytes),
независимо от HTTP-метода, Content-Type и наличия Content-Length. Превышение,
включая whitespace после JSON, возвращает `413 application/problem+json`
до мутаций. Ошибка чтения тела возвращает `400`, отмена/таймаут чтения — `408`.
Пустые node GET/pull и streaming SSE responses не меняются. CSV дополнительно
сохраняет ограничения числа строк и полей своей операции.
Запросы с телом занимают ограниченный слот API instance до окончания handler:
по умолчанию 4 одновременно, `EVOBGP_API_BODY_MAX_CONCURRENT` задаёт 1..64.
Свободных слотов нет — немедленный `429` с `Retry-After: 1`, без очереди ожидания.
Пустые запросы (`http.NoBody`) слота не занимают.
Если CRUD-изменение сохранено, но автоматическая refresh/reconcile задача не поставлена,
успешный ответ содержит `X-EvoBGP-Job-Status: enqueue-failed`; ошибка также попадает в аудит.
Изменение уже сохранено: следует явно запустить refresh/reconcile, а не повторять создание объекта.
**Аутентификация (dual):**
- **API key** — `Authorization: Bearer <token>` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`).
@@ -226,6 +240,16 @@ components:
Основные значения: `module_refresh`, `peer_reconcile`, `deploy_apply`, `revision_rollback`, `bird_reload`.
responses:
ExpensiveOperationLimited:
description: Локальная квота процесса API — по умолчанию 4 активных preview/lookup, 2 на tenant; 120 lookup и по 30 CDN/revision preview в минуту на tenant. Квоты умножаются на число реплик. Повторить после Retry-After, без очереди ожидания.
headers:
Retry-After:
description: Минимальная задержка повторной попытки в секундах; не гарантия свободного места.
schema: {type: integer, minimum: 1}
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
Unauthorized:
description: Нет или невалидный Bearer-токен.
content:
@@ -263,7 +287,10 @@ components:
schema:
$ref: "#/components/schemas/Problem"
DefaultProblem:
description: Ошибка (см. тело Problem).
description: >
Ошибка (см. тело Problem). Общие для authenticated `/v1` ответы middleware:
413 при request body свыше 8 MiB, 400 при ошибке чтения тела, 408 при отмене/таймауте чтения.
429 с Retry-After: 1 при занятых слотах обработки request body.
content:
application/problem+json:
schema:
@@ -903,6 +930,21 @@ components:
connected_speaker_label:
type: string
description: Человекочитаемая метка ноды из live-опроса.
last_known_session_state:
type: ["string", "null"]
description: Последнее сохранённое состояние; не является текущим наблюдением.
session_state:
type: ["string", "null"]
description: >
При live-ответе текущее наблюдение: BGP state, absent после успешного
пустого опроса или null, если актуальное состояние неизвестно.
live_observation:
$ref: "#/components/schemas/LiveObservation"
description: >
Качество BGP-наблюдений при GET /v1/peers?live=1. Для назначенного
пира учитывается только bgp_speaker_id; для общего — все speakers.
Смешанный успех и сбой не дают quality=ok или session_state=absent.
Timestamps относятся только к ожидаемым speakers.
session_on_speakers:
type: array
description: Состояние протокола пира на каждой опрошенной ноде.
@@ -915,6 +957,10 @@ components:
type: string
state:
type: string
poll_error:
type: string
observation:
$ref: "#/components/schemas/LiveObservation"
established_on_speakers:
type: array
description: Ноды, где сессия в состоянии Established (один пир может быть на нескольких).
@@ -1001,7 +1047,11 @@ components:
description: Человекочитаемая метка ноды (agent domain или CP master).
agent_ok:
type: boolean
description: true если agent /v1/agent/health успешен (master — local birdc poll).
description: true только после успешного agent /v1/agent/health (master — local birdc poll).
agent_observation:
$ref: "#/components/schemas/LiveObservation"
bgp_observation:
$ref: "#/components/schemas/LiveObservation"
agent_error:
type: string
agent_last_sync_at:
@@ -1015,15 +1065,49 @@ components:
bgp_poll_error:
type: string
bgp_sessions_total:
type: integer
type: ["integer", "null"]
description: null при отсутствии успешного опроса.
bgp_established:
type: integer
type: ["integer", "null"]
description: null при отсутствии успешного опроса.
sessions:
type: array
items:
$ref: "#/components/schemas/BgpSessionLive"
additionalProperties: true
LiveObservation:
type: object
description: >
Метаданные конкретного опроса. observed_at — время завершения опроса;
received_at — время получения результата CP; last_success_at — время
последнего успешного опроса. Старые агенты не обязаны передавать эти поля:
CP формирует их по собственным часам. При отсутствии опроса timestamps null.
required: [observed_at, received_at, last_success_at, source, quality]
properties:
observed_at:
type: ["string", "null"]
format: date-time
received_at:
type: ["string", "null"]
format: date-time
last_success_at:
type: ["string", "null"]
format: date-time
expires_at:
type: ["string", "null"]
format: date-time
description: Срок актуальности snapshot по часам CP и настроенному интервалу collector; клиент не должен считать cached quality=ok актуальным после этого времени. Для агрегата — самый ранний срок источников.
source:
type: string
enum: [none, cp_birdc, agent_bird_protocols, agent_health, mixed]
quality:
type: string
enum: [ok, stale, unreachable, unknown, disabled]
error_code:
type: string
enum: [not_configured, not_polled, poll_failed, reported_unhealthy, snapshot_expired, configuration_changed, ambiguous_authority]
BgpSessionLive:
type: object
properties:
@@ -1052,9 +1136,12 @@ components:
ok:
type: boolean
session_count:
type: integer
type: ["integer", "null"]
description: null, если успешного опроса не было.
poll_error:
type: string
observation:
$ref: "#/components/schemas/LiveObservation"
additionalProperties: true
PostgresOverview:
@@ -2108,6 +2195,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/LookupResponse"
"429":
$ref: "#/components/responses/ExpensiveOperationLimited"
"400":
$ref: "#/components/responses/BadRequest"
"401":
@@ -2115,6 +2204,29 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/telemetry/refresh:
parameters:
- $ref: "#/components/parameters/TenantId"
post:
tags: [Speakers]
summary: Запросить фоновое обновление телеметрии tenant
description: Требует bgp:network:read. Объединяет повторные запросы в пределах 10 секунд; HTTP handler не опрашивает агентов или BIRD. GET speakers/peers/discoveries читает сохранённые snapshots, включая live=1. Запрос во время выполняющегося poll сохраняется для следующего цикла. Не изменяет конфигурацию speaker и не требует нового протокола агента.
operationId: requestTelemetryRefresh
responses:
"202":
description: Запрос принят либо объединён с уже принятым.
content:
application/json:
schema:
type: object
required: [requested]
properties:
requested:
type: boolean
description: true при постановке нового запроса; false при объединении или отсутствии целей.
default:
$ref: "#/components/responses/DefaultProblem"
/v1/router-lists/catalog:
get:
tags: [Modules]
@@ -2789,6 +2901,9 @@ paths:
- `ipRange,community`
В поле `community` можно передавать либо ID community, либо её значение.
Предел запроса — 8 MiB (8388608 байт), 10000 строк данных без заголовка и 64 поля в записи.
Превышение возвращает 413 до создания записей; большой импорт следует разбить на части.
Ошибка в данных или БД после начала создания записей может оставить частичный импорт.
operationId: importModuleEntriesCsv
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
@@ -2812,6 +2927,12 @@ paths:
minimum: 0
module_type:
type: string
"413":
description: Превышен лимит байт, строк или полей CSV; записи не созданы.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"404":
$ref: "#/components/responses/NotFound"
"422":
@@ -2898,6 +3019,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/CdnPreviewResponse"
"429":
$ref: "#/components/responses/ExpensiveOperationLimited"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
@@ -3146,7 +3269,7 @@ paths:
type: string
enum: ["1"]
description: >
Опрос birdc на CP и GET /v1/agent/bird/protocols на репликах; обогащает session_state и connected_speaker_*.
Сохранённые результаты фонового опроса birdc на CP и agent protocols на репликах; обогащает session_state и connected_speaker_*. GET не запускает опрос; live=1 сохранён для совместимости.
responses:
"200":
description: Успешно.
@@ -3167,7 +3290,7 @@ paths:
live_speaker_poll:
type: array
description: >
При live=1 — результат опроса каждой ноды (CP birdc + agent protocols).
Сохранённый результат фонового опроса каждой ноды (CP birdc + agent protocols); live=1 не запускает новый опрос.
items:
$ref: "#/components/schemas/LiveSpeakerPoll"
default:
@@ -3204,7 +3327,7 @@ paths:
summary: Список обнаруженных (неодобренных) пиров
description: >
Dynamic BGP-сессии (`evobgp_dyn_*`), которых ещё нет в `bgp_peer`.
По умолчанию возвращает `status=pending`. При листинге выполняет live-опрос birdc/agent и upsert pending.
По умолчанию возвращает `status=pending`. Читает сохранённые discoveries. Фоновый collector выполняет опрос и fenced upsert; GET не опрашивает ноды и не изменяет данные.
operationId: listDiscoveredPeers
parameters:
- $ref: "#/components/parameters/TenantId"
@@ -3380,7 +3503,7 @@ paths:
type: string
enum: ["1"]
description: >
Live-опрос agent /v1/agent/health и BGP protocols на репликах; CP — local birdc.
Сохранённые результаты фоновых опросов agent health/BGP и local birdc. GET не запускает опрос.
Обогащает каждый item полем `live`.
responses:
"200":
@@ -3640,6 +3763,8 @@ paths:
description: Только чтение, без apply.
operationId: getRevisionPreview
responses:
"429":
$ref: "#/components/responses/ExpensiveOperationLimited"
"200":
description: Текст или структурированное представление - формат задаётся реализацией.
content:
@@ -3881,6 +4006,58 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/jobs/{job_id}/report:
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/JobId"
get:
tags: [Jobs]
summary: Отчёт задачи и сохранённый прогресс доставки
description: Требует bgp:operations:read. Прогресс хранится независимо от meta задачи. Для reconciliation без собственного прогресса возвращается прогресс исходной задачи в том же tenant; после начала восстановления приоритет имеет собственный прогресс. agent_reported не означает проверку состояния BIRD.
operationId: getJobReport
responses:
"200":
description: Успешно.
content:
application/json:
schema:
type: object
required: [job_id, kind, status, deployment_progress]
properties:
job_id: {type: string}
kind: {type: string}
status: {type: string}
meta: {type: [object, "null"], additionalProperties: true}
error: {type: [string, "null"]}
created_at: {type: string, format: date-time}
revision_id: {type: string}
log_entries: {type: array, items: {type: object, additionalProperties: true}}
log_total: {type: integer}
source_deployment_job_id: {type: string}
deployment_progress:
type: array
items:
type: object
required: [job_id, speaker_id, revision_id, generation, state, confirmation, dispatch_attempts, retry_round]
properties:
job_id: {type: string}
speaker_id: {type: string}
revision_id: {type: string}
generation: {type: integer, format: int64}
state: {type: string}
confirmation: {type: string}
error: {type: string}
dispatch_attempts: {type: integer}
retry_round: {type: integer}
health_received_at: {type: [string, "null"], format: date-time}
health_revision_id: {type: string}
health_source_at: {type: string}
health_error: {type: string}
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/jobs/{job_id}/cancel:
parameters:
- $ref: "#/components/parameters/TenantId"
@@ -3904,6 +4081,33 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/jobs/{job_id}/retry:
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/JobId"
post:
tags: [Jobs]
summary: Повторить завершившуюся ошибкой задачу
description: Операторский повтор сохраняет ID и исходный запрос, сбрасывая бюджет попыток.
operationId: postJobRetry
responses:
"202":
description: Повтор поставлен в очередь.
content:
application/json:
schema:
$ref: "#/components/schemas/Job"
"409":
$ref: "#/components/responses/Conflict"
"429":
description: Очередь задач заполнена.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/speakers/{speaker_id}/revisions/latest:
parameters:
- $ref: "#/components/parameters/SpeakerId"
+25
View File
@@ -0,0 +1,25 @@
# Решение по доступу worker к внутренним DoH
## Подтверждённое состояние и разрешение
В исходном `v2.56.2` (`924f111`) `httpapi.NewCDNHTTPClient` возвращал обычный `httpclient.New`: worker мог обращаться к HTTP/HTTPS DoH-профилям, включая внутренние адреса, и к RIPEstat URL из переменных окружения. Профили управляются API-key editor или JWT с `bgp:directories:write`, а не исключительно администратором.
Новый `NewCDNHTTPClient` защищает CDN egress. Передача этого client всему pipeline непреднамеренно распространяла CDN-ограничения на DoH/RIPE. Автоматическая проверка сначала отклонила восстановление общего транспорта как изменение egress/SSRF boundary. Пользователь затем явно разрешил его: «Разрешаю восстановить прежний доступ».
## Выполненное изменение
`BootstrapWorkers` использует общий reusable HTTP client с прежним timeout 45 секунд для явно настроенных DoH/RIPE endpoints. Для них сохраняются прежние правила private endpoints, proxy и redirects стандартного `net/http` client. Новых правил переноса auth, secret references или запрета HTTP/downgrade redirects здесь нет. Право изменения DoH-профиля сохраняет возможность инициировать запросы во внутреннюю сеть; этот доступ не является admin-only.
CDN ingest и preview выполняют каждый запрос через `pipeline.UpstreamHTTPDo`: HTTPS, проверенные DNS/IP при dial и redirect, запрет private targets без существующего явного override, без proxy. API preview сохраняет отдельный защищённый client. Node dispatch использует свой client и не меняется.
`TestBootstrapWorkerPreservesExplicitPrivateDOH` выполняет реальный DOMAINS refresh через bootstrap worker и локальный HTTP DoH; отдельная проверка в нём подтверждает отказ приватному HTTPS CDN при выключенном override. Старый wire-протокол нод 2.56.2 не менялся.
## Полные ответы и deadlines
RIPE announced-prefixes: 32 MiB; RIPE as-overview: 4 MiB. DoH dns-message: 2 MiB; DoH JSON: 1 MiB. Проверяется полный ответ с чтением одного дополнительного байта, включая декодированный transport gzip. Валидный префикс JSON/DNS с хвостом за пределом больше не принимается как усечённый успех. JSON разбирается целиком; дополнительный JSON-объект отклоняется. Для non-200 RIPE читается максимум 4 KiB диагностики, DoH — 1 KiB; retry-drain 5xx отдельно ограничен общим HTTP helper.
В отсутствие deadline RIPE получает 45 секунд, низкоуровневый DoH — 10 секунд. Уже заданный deadline, включая timeout выбранного DoH-профиля, сохраняется; он может быть короче. Ошибка/oversize DoH не обязательно завершает весь refresh: сохраняется существующая политика fallback. Один профиль/primary path может перейти к system DNS после ошибки или пустого результата; multi-profile union возвращает успешные результаты других профилей, failover перебирает профили и в отдельных случаях пустого результата также использует system DNS.
## Переиспользование transport
`pipeline.NewPipelineHTTPClient` владеет двумя отдельными reusable pools: обычным для настроенных DoH/RIPE и защищённым для CDN. Только CDN entry point выбирает guarded pool; выбор не зависит от эвристики URL. Новый глобальный cache клиентов не добавлен. Тест трёх последовательных TLS CDN-запросов подтверждает одно соединение, а отдельный тест проверяет разрешённый private profile и отказ CDN через тот же client.
+1 -1
View File
@@ -52,7 +52,7 @@ CLI `evobgp-node` поддерживает `pull-bundle`, `verify-bundle`, `appl
## Веб-интерфейс
Каталог `web/` — SvelteKit-приложение для операторов (статическая сборка в Docker-образе эталонного профиля). Для разработки UI обычно используется dev-сервер на порту Vite/SvelteKit с проксированием или прямым вызовом API; на стороне API задаётся CORS (`EVOBGP_CORS_ORIGINS`).
Каталог `apps/web/` — React-приложение операторов: TanStack Router/Query, shadcn/ui и подключённый ReUI PRO. В Docker используется статическая сборка с nginx; локально — Vite с проксированием или прямым вызовом API и CORS (`EVOBGP_CORS_ORIGINS`). Контракт оформления — [ReUI Frame](ui-design-contract.md).
## Наблюдаемость
+1 -1
View File
@@ -18,7 +18,7 @@
## Рекомендуется
- `EVOBGP_JOB_MAX_CONCURRENT=16`, `EVOBGP_DB_MAX_CONNS=25`, `EVOBGP_COLLECT_CONCURRENCY=16` при росте tenants.
- `EVOBGP_JOB_MAX_CONCURRENT=16`, `EVOBGP_DB_MAX_CONNS=25`, `EVOBGP_COLLECT_CONCURRENCY=16` при росте tenants. Если `EVOBGP_DB_MAX_CONNS` не задан, пул по умолчанию 8 соединений и `MaxConnIdleTime` 5 минут (не дефолт pgx в 4). Явное значение переменной не переопределяется. Сумма пулов процессов reference-профиля должна оставаться ниже `max_connections`.
- `EVOBGP_NODE_DISPATCH_INSECURE_TLS=0` — только валидный TLS к agent.
- Ограничить `/metrics`: `EVOBGP_METRICS_TOKEN` (Bearer) и/или сетевая политика / reverse proxy.
- `EVOBGP_AUTH_RATE_LIMIT` (default 60/min per IP) на `GET /v1/auth/config`.
+33 -8
View File
@@ -2,6 +2,16 @@
Примеры команд для **PowerShell**. Репозиторий: корень `EvoBGP`, Docker Compose лежит в `deploy\compose`.
Фоновый collector BGP/health опрашивает speaker с интервалом 20 секунд
(`EVOBGP_TELEMETRY_INTERVAL_SEC`, допустимо 15–300) и ограничивает параллелизм
четырьмя workers (`EVOBGP_TELEMETRY_WORKERS`, допустимо 1–32). Данные старше
трёх интервалов считаются устаревшими. Для локального BIRD задайте
`EVOBGP_TELEMETRY_LOCAL_ID` как стабильный уникальный ID именно BIRD-узла,
если несколько control-plane инстансов используют одну PostgreSQL БД;
одинаковый ID допустим только для инстансов, читающих один и тот же BIRD.
Без ID используется hostname контейнера и путь сокета, поэтому после пересоздания
контейнера старый локальный snapshot может остаться до очистки.
## Требования
- **Docker** с поддержкой Compose v2 — для готового стека.
@@ -103,7 +113,7 @@ docker compose --profile microvps down -v
## Вариант 1b: microVPS ~1 ГиБ RAM — полный набор + Web UI
Один монолит **`evobgp-all`** (те же in-process воркеры, что и в варианте 1), плюс **статическая панель** за nginx (`evobgp-web-microvps`), **NATS JetStream** (как в reference, для `EVOBGP_BROKER_URL` и будущей интеграции) и **Prometheus** со скрейпом метрик с `evobgp-all`. Отдельные контейнеры `evobgp-scheduler` / `evobgp-ingest` / … не нужны — это не дублирование логики, а тот же код в одном процессе.
Один монолит **`evobgp-all`** (те же in-process воркеры, что и в варианте 1), плюс **статическая панель** за nginx (`evobgp-web-microvps`) и **Prometheus** со скрейпом метрик с `evobgp-all`. Отдельные контейнеры `evobgp-scheduler` / `evobgp-ingest` / … не нужны — это тот же код в одном процессе. NATS JetStream запускается только с дополнительным профилем `nats`.
**Рекомендация:** на хосте с ровно **1 ГиБ** включите **swap** (1–2 ГиБ), иначе при пиках возможен OOM. На **2+ ГиБ** можно поднять тот же профиль **без** второго файла — останутся более мягкие лимиты из основного `docker-compose.yaml`.
@@ -126,19 +136,19 @@ Copy-Item .env.web-sec.example .env.web-sec -Force
4. Запускайте compose с двумя env-файлами:
```powershell
# С ужатыми лимитами и EVOBGP_BROKER_URL=nats://… (ориентир под ~1 ГиБ RAM на хосте)
# С текущими лимитами (пригодность для хоста ~1 ГиБ требует замера под нагрузкой)
docker compose --env-file .env --env-file .env.web-sec -f docker-compose.yaml -f docker-compose.microvps-full.yaml --profile microvps-full pull
docker compose --env-file .env --env-file .env.web-sec -f docker-compose.yaml -f docker-compose.microvps-full.yaml --profile microvps-full up -d
```
Только профиль `microvps-full` и **без** файла `docker-compose.microvps-full.yaml` (лимиты как в базовом compose, без принудительной подстановки брокера в `evobgp-all`):
Только профиль `microvps-full` и **без** файла `docker-compose.microvps-full.yaml` (лимиты как в базовом compose):
```powershell
docker compose --env-file .env --env-file .env.web-sec --profile microvps-full pull
docker compose --env-file .env --env-file .env.web-sec --profile microvps-full up -d
```
При необходимости задайте брокер вручную для `evobgp-all` (override или `.env` рядом с compose): `EVOBGP_BROKER_URL=nats://nats:4222`.
Если NATS нужен внешним клиентам, добавьте `--profile nats` к командам `pull` и `up`. Проверьте таких клиентов до изменения рабочего стека. `EVOBGP_BROKER_URL` в текущем приложении только записывается в лог и не включает брокерную очередь. Для standalone `stack.microvps-full.yaml` и production example сервис NATS также включается профилем `nats`. Перенос существующей истории Prometheus при обновлении описан в [docker-resources.md](docker-resources.md).
| Порт | Назначение |
|------|------------|
@@ -146,7 +156,7 @@ docker compose --env-file .env --env-file .env.web-sec --profile microvps-full u
| **443** | HTTPS Web UI (Traefik + Let's Encrypt) |
| **8080** | Прямой HTTP API (`evobgp-all`) |
| **9090** | Prometheus (`prometheus-microvps`) |
| **4222** | NATS |
| **4222** | NATS (только с профилем `nats`) |
| **179** | BGP (BIRD2), как в варианте 1 |
Проверка UI:
@@ -188,7 +198,7 @@ docker compose --env-file .env --env-file .env.web-sec -f docker-compose.yaml -f
## Вариант 2: Docker, профиль reference
Эталонное разбиение: отдельные контейнеры `evobgp-api`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, сервис **NATS JetStream** (в compose; привязка очереди задач к брокеру — следующая итерация), веб UI за nginx, опционально Prometheus.
Эталонное разбиение: отдельные контейнеры `evobgp-api`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, веб UI за nginx и Prometheus. **NATS JetStream** доступен по дополнительному профилю `nats`; очередь задач пока не использует брокер.
```powershell
cd deploy\compose
@@ -204,11 +214,26 @@ docker compose --profile reference up -d
|------|------------|
| 8080 | HTTP API (`evobgp-api`) |
| 3000 | Веб UI (`evobgp-web` → nginx, прокси на API) |
| 4222 | NATS |
| 4222 | NATS (только с профилем `nats`) |
| 9090 | Prometheus (в compose) |
| 179 | BGP (BIRD2) |
**Воркеры reference:** `evobgp-scheduler` ходит в API по HTTP (`EVOBGP_CONTROL_PLANE_URL`, `EVOBGP_SCHEDULER_BEARER`); в [docker-compose.yaml](../deploy/compose/docker-compose.yaml) для локального запуска включены `EVOBGP_DEV_INSECURE=1` на API и токен `dev` у планировщика. `evobgp-ingest` обновляет ETag CDN-источников; `evobgp-render` по умолчанию не трогает `published_revision` (включите `EVOBGP_RENDER_AUTOPUBLISH=1` осознанно); `evobgp-deploy` пишет в лог расхождение applied vs published. Очередь `jobs` остаётся in-process у **evobgp-api**; общий брокер — в планах.
**Воркеры reference:** `evobgp-scheduler` использует `EVOBGP_SCHEDULER_MODE=db` и общую PostgreSQL durable queue. Он доверенный DB-worker с доступом к tenants; API workers выполняют сохранённые задания. HTTP-вариант включается явно: `EVOBGP_SCHEDULER_MODE=http`, `EVOBGP_CONTROL_PLANE_URL`, `EVOBGP_SCHEDULER_BEARER` и `EVOBGP_SCHEDULER_TENANT_ID`. Токен должен принадлежать именно указанному tenant и иметь право `bgp:modules:write`; HTTP scheduler не перечисляет остальные tenants и не подменяет scope через заголовки. При обновлении старой HTTP-конфигурации нужно указать tenant или перейти на `db`; без tenant HTTP-режим завершится с ошибкой. `local` предназначен только для разработки без PostgreSQL, не для split deploy. `evobgp-ingest` обновляет ETag CDN-источников; `evobgp-render` по умолчанию не трогает `published_revision` (`EVOBGP_RENDER_AUTOPUBLISH=1` включается осознанно); `evobgp-deploy` пишет в лог расхождение applied vs published.
Reference Compose фиксирует DB-режим. Простое добавление HTTP-переменных в `.env` его **не переключает**. Для HTTP-варианта используйте отдельный Compose override с явным окружением сервиса:
```yaml
services:
evobgp-scheduler:
environment:
EVOBGP_SCHEDULER_MODE: http
EVOBGP_CONTROL_PLANE_URL: ${EVOBGP_CONTROL_PLANE_URL:?required}
EVOBGP_SCHEDULER_BEARER: ${EVOBGP_SCHEDULER_BEARER:?required}
EVOBGP_SCHEDULER_TENANT_ID: ${EVOBGP_SCHEDULER_TENANT_ID:?required}
```
Подключайте override через дополнительный `-f` при запуске Compose. DB-режим предназначен для всех tenants, которым доверяет оператор общей БД; HTTP-режим работает только с указанным tenant и его токеном.
В **evobgp-all** (microvps) те же пакеты крутятся в одном процессе и используют общий `jobs.Registry` без HTTP.
+312
View File
@@ -0,0 +1,312 @@
package agentwatch
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
const (
maxConfigFiles = 512
maxConfigBytes = 64 << 20
maxFileBytes = 16 << 20
maxIncludeDepth = 16
maxDirectoryEntries = 4096
)
var errMainNotReady = errors.New("main bird.conf is not ready")
// Only standalone literal include statements are hashable. Other valid BIRD
// grammar falls back to the legacy configure-on-every-tick path.
var includeLine = regexp.MustCompile(`(?m)^[\t ]*include[\t ]+"([^"\r\n]+)"[\t ]*;[\t ]*(?:#[^\r\n]*)?\r?$`)
type configScanner struct {
ctx context.Context
root string
h hash.Hash
seen map[string]bool
files int
bytes int64
}
func hashConfigTree(ctx context.Context, activeDir string) ([sha256.Size]byte, error) {
var zero [sha256.Size]byte
root, err := filepath.Abs(activeDir)
if err != nil {
return zero, fmt.Errorf("agentwatch: active directory: %w", err)
}
root, err = filepath.EvalSymlinks(root)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return zero, fmt.Errorf("agentwatch: %w: %v", errMainNotReady, err)
}
return zero, fmt.Errorf("agentwatch: resolve active directory: %w", err)
}
s := &configScanner{ctx: ctx, root: filepath.Clean(root), h: sha256.New(), seen: make(map[string]bool)}
if err := s.visit(filepath.Join(s.root, "bird.conf"), 0); err != nil {
return zero, err
}
var digest [sha256.Size]byte
copy(digest[:], s.h.Sum(nil))
return digest, nil
}
func (s *configScanner) visit(path string, depth int) error {
if err := s.ctx.Err(); err != nil {
return err
}
if depth > maxIncludeDepth {
return fmt.Errorf("agentwatch: include depth exceeds %d", maxIncludeDepth)
}
path = filepath.Clean(path)
rel, err := filepath.Rel(s.root, path)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return fmt.Errorf("agentwatch: include %q escapes active directory", path)
}
if s.seen[path] {
return nil
}
info, err := os.Lstat(path)
if err != nil {
if depth == 0 && errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("agentwatch: %w: %v", errMainNotReady, err)
}
return fmt.Errorf("agentwatch: inspect %q: %w", path, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("agentwatch: include %q is not a regular file", path)
}
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
return fmt.Errorf("agentwatch: resolve %q: %w", path, err)
}
if _, err := s.withinRoot(resolved); err != nil {
return err
}
if info.Size() > maxFileBytes {
return fmt.Errorf("agentwatch: include %q exceeds %d bytes", path, maxFileBytes)
}
if s.files >= maxConfigFiles || s.bytes+info.Size() > maxConfigBytes {
return fmt.Errorf("agentwatch: configuration tree exceeds %d files or %d bytes", maxConfigFiles, maxConfigBytes)
}
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("agentwatch: open %q: %w", path, err)
}
defer func() { _ = f.Close() }()
limit := int64(maxFileBytes)
if remaining := int64(maxConfigBytes) - s.bytes; remaining < limit {
limit = remaining
}
content, err := readBounded(f, limit)
if err != nil {
return fmt.Errorf("agentwatch: read %q: %w", path, err)
}
s.seen[path] = true
s.files++
s.bytes += int64(len(content))
writeHash(s.h, filepath.ToSlash(rel), content)
patterns, err := includePatterns(string(content))
if err != nil {
return fmt.Errorf("agentwatch: %s: %w", rel, err)
}
for _, pattern := range patterns {
if strings.ContainsAny(pattern, `[\`) {
return fmt.Errorf("agentwatch: unsupported escaped include %q", pattern)
}
candidate := pattern
if !filepath.IsAbs(candidate) {
candidate = filepath.Join(filepath.Dir(path), filepath.FromSlash(candidate))
}
candidate = filepath.Clean(candidate)
// The pattern itself changes the digest even when its glob currently matches nothing.
writeHash(s.h, "include", []byte(filepath.ToSlash(candidate)))
if _, err := s.withinRoot(candidate); err != nil {
return err
}
if strings.ContainsAny(candidate, "*?") {
if strings.ContainsAny(filepath.Dir(candidate), "*?") {
return fmt.Errorf("agentwatch: unsupported include directory glob %q", pattern)
}
resolvedDir, err := filepath.EvalSymlinks(filepath.Dir(candidate))
if err != nil {
return fmt.Errorf("agentwatch: resolve include directory %q: %w", pattern, err)
}
if _, err := s.withinRoot(resolvedDir); err != nil {
return err
}
matches, err := boundedGlob(candidate)
if err != nil {
return fmt.Errorf("agentwatch: include glob %q: %w", pattern, err)
}
for _, match := range matches {
if err := s.visit(match, depth+1); err != nil {
return err
}
}
continue
}
if err := s.visit(candidate, depth+1); err != nil {
return err
}
}
return nil
}
func readBounded(r io.Reader, limit int64) ([]byte, error) {
content, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil {
return nil, err
}
if int64(len(content)) > limit {
return nil, fmt.Errorf("configuration tree changed or exceeded size limit")
}
return content, nil
}
// boundedGlob only handles * and ? in a filename. Broader BIRD glob grammar
// falls back to every-tick configure instead of risking a false unchanged hash.
func boundedGlob(pattern string) ([]string, error) {
dir, base := filepath.Dir(pattern), filepath.Base(pattern)
f, err := os.Open(dir)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
names, err := f.Readdirnames(maxDirectoryEntries + 1)
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
if len(names) > maxDirectoryEntries {
return nil, fmt.Errorf("directory exceeds %d entries", maxDirectoryEntries)
}
matches := make([]string, 0)
for _, name := range names {
match, err := filepath.Match(base, name)
if err != nil {
return nil, err
}
if match {
matches = append(matches, filepath.Join(dir, name))
if len(matches) > maxConfigFiles {
return nil, fmt.Errorf("glob exceeds %d matches", maxConfigFiles)
}
}
}
sort.Strings(matches)
return matches, nil
}
func (s *configScanner) withinRoot(path string) (string, error) {
rel, err := filepath.Rel(s.root, path)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("agentwatch: include %q escapes active directory", path)
}
return rel, nil
}
func writeHash(h hash.Hash, name string, content []byte) {
var size [8]byte
binary.LittleEndian.PutUint64(size[:], uint64(len(name)))
_, _ = h.Write(size[:])
_, _ = h.Write([]byte(name))
binary.LittleEndian.PutUint64(size[:], uint64(len(content)))
_, _ = h.Write(size[:])
_, _ = h.Write(content)
}
func includePatterns(content string) ([]string, error) {
tokens, unsupportedQuotedText := includeTokenOffsets(content)
if unsupportedQuotedText {
return nil, fmt.Errorf("unsupported quoted text or escape")
}
tokenSet := make(map[int]bool, len(tokens))
for _, token := range tokens {
tokenSet[token] = true
}
var patterns []string
for _, match := range includeLine.FindAllStringSubmatchIndex(content, -1) {
keyword := match[0] + strings.Index(content[match[0]:match[1]], "include")
if !tokenSet[keyword] {
continue
} // text inside a block comment
delete(tokenSet, keyword)
patterns = append(patterns, content[match[2]:match[3]])
}
if len(tokenSet) != 0 {
return nil, fmt.Errorf("unsupported include syntax")
}
return patterns, nil
}
// Recognize include keywords outside BIRD comments and quoted strings.
func includeTokenOffsets(config string) ([]int, bool) {
var offsets []int
quote := byte(0)
lineComment, blockComment := false, false
for i := 0; i < len(config); {
c := config[i]
if lineComment {
if c == '\n' {
lineComment = false
}
i++
continue
}
if blockComment {
if c == '*' && i+1 < len(config) && config[i+1] == '/' {
blockComment = false
i += 2
} else {
i++
}
continue
}
if quote != 0 {
if c == '\\' {
return offsets, true
}
if c == quote {
quote = 0
}
i++
continue
}
if c == '#' || (c == '/' && i+1 < len(config) && config[i+1] == '/') {
lineComment = true
i++
continue
}
if c == '/' && i+1 < len(config) && config[i+1] == '*' {
blockComment = true
i += 2
continue
}
if c == '"' || c == '\'' {
quote = c
i++
continue
}
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' {
start := i
for i < len(config) && ((config[i] >= 'a' && config[i] <= 'z') || (config[i] >= 'A' && config[i] <= 'Z') || (config[i] >= '0' && config[i] <= '9') || config[i] == '_') {
i++
}
if config[start:i] == "include" {
offsets = append(offsets, start)
}
continue
}
i++
}
return offsets, quote != 0 || blockComment
}
+109
View File
@@ -0,0 +1,109 @@
// Package agentwatch reloads BIRD when its active configuration tree changes.
package agentwatch
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"log"
"path/filepath"
"time"
)
// Control is the subset of BIRD operations required by the watch loop.
type Control interface {
ParseCheck(context.Context, string) error
Configure(context.Context) error
}
// Config sets bounded polling and reconciliation behavior.
type Config struct {
ActiveDir string
Every time.Duration
ReconcileEvery time.Duration
CommandTimeout time.Duration
Logf func(string, ...any)
}
type watcher struct {
ctl Control
cfg Config
hash [sha256.Size]byte
haveHash bool
lastConfigured time.Time
}
// Run starts with one check, then polls until ctx is cancelled. A successful
// birdc exit advances the remembered hash; it is not proof of BIRD ACK.
func Run(ctx context.Context, ctl Control, cfg Config) error {
if ctl == nil || cfg.Every <= 0 || cfg.ReconcileEvery <= 0 || cfg.CommandTimeout <= 0 {
return fmt.Errorf("agentwatch: control and positive intervals/timeouts are required")
}
if cfg.ActiveDir == "" {
cfg.ActiveDir = "/etc/bird"
}
if cfg.Logf == nil {
cfg.Logf = log.Printf
}
w := &watcher{ctl: ctl, cfg: cfg}
if err := w.step(ctx, time.Now()); err != nil && ctx.Err() == nil {
cfg.Logf("evobgp-agent watch: %v", err)
}
ticker := time.NewTicker(cfg.Every)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case now := <-ticker.C:
if err := w.step(ctx, now); err != nil && ctx.Err() == nil {
cfg.Logf("evobgp-agent watch: %v", err)
}
}
}
}
func (w *watcher) step(ctx context.Context, now time.Time) error {
if err := ctx.Err(); err != nil {
return err
}
digest, scanErr := hashConfigTree(ctx, w.cfg.ActiveDir)
if scanErr != nil {
if errors.Is(scanErr, errMainNotReady) {
return fmt.Errorf("configuration not ready: %w", scanErr)
}
// Unsupported/unhashable trees retain the old every-tick configure cadence.
w.cfg.Logf("evobgp-agent watch: cannot hash complete config tree (%v); using every-tick parse/configure", scanErr)
} else if w.haveHash && digest == w.hash && now.Sub(w.lastConfigured) < w.cfg.ReconcileEvery {
return nil
}
main := filepath.Join(w.cfg.ActiveDir, "bird.conf")
if err := w.command(ctx, func(cctx context.Context) error { return w.ctl.ParseCheck(cctx, main) }); err != nil {
return fmt.Errorf("parse check: %w", err)
}
if scanErr == nil {
stable, err := hashConfigTree(ctx, w.cfg.ActiveDir)
if err != nil {
return fmt.Errorf("configuration changed during parse check: %w", err)
}
if stable != digest {
return fmt.Errorf("configuration changed during parse check; retrying")
}
}
if err := w.command(ctx, w.ctl.Configure); err != nil {
return fmt.Errorf("configure: %w", err)
}
w.haveHash = scanErr == nil
if w.haveHash {
w.hash = digest
}
w.lastConfigured = now
return nil
}
func (w *watcher) command(parent context.Context, run func(context.Context) error) error {
ctx, cancel := context.WithTimeout(parent, w.cfg.CommandTimeout)
defer cancel()
return run(ctx)
}
+311
View File
@@ -0,0 +1,311 @@
package agentwatch
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
type fakeControl struct {
parsed int
configured int
failConfigure int
onParse func()
}
func (f *fakeControl) ParseCheck(ctx context.Context, _ string) error {
if err := ctx.Err(); err != nil {
return err
}
f.parsed++
if f.onParse != nil {
f.onParse()
}
return nil
}
func (f *fakeControl) Configure(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
f.configured++
if f.failConfigure > 0 {
f.failConfigure--
return errors.New("birdc unavailable")
}
return nil
}
func testConfig(t *testing.T) (string, string) {
t.Helper()
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "bird.d"), 0o755); err != nil {
t.Fatal(err)
}
main := filepath.Join(root, "bird.conf")
if err := os.WriteFile(main, []byte("router id 192.0.2.1;\ninclude \"bird.d/*.conf\";\n"), 0o644); err != nil {
t.Fatal(err)
}
fragment := filepath.Join(root, "bird.d", "peer.conf")
if err := os.WriteFile(fragment, []byte("# peer v1\n"), 0o644); err != nil {
t.Fatal(err)
}
return root, fragment
}
func testWatcher(root string, ctl Control) *watcher {
return &watcher{ctl: ctl, cfg: Config{ActiveDir: root, Every: 30 * time.Second, ReconcileEvery: 5 * time.Minute, CommandTimeout: time.Second, Logf: func(string, ...any) {}}}
}
func TestHashConfigTreeTracksMainIncludesAndGlobMembership(t *testing.T) {
root, fragment := testConfig(t)
first, err := hashConfigTree(context.Background(), root)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fragment, []byte("# peer v2\n"), 0o644); err != nil {
t.Fatal(err)
}
second, err := hashConfigTree(context.Background(), root)
if err != nil || second == first {
t.Fatalf("included edit not detected: digest=%x err=%v", second, err)
}
if err := os.WriteFile(filepath.Join(root, "bird.d", "manual.conf"), []byte("# manual\n"), 0o644); err != nil {
t.Fatal(err)
}
third, err := hashConfigTree(context.Background(), root)
if err != nil || third == second {
t.Fatalf("new glob member not detected: digest=%x err=%v", third, err)
}
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("router id 192.0.2.2;\ninclude \"bird.d/*.conf\";\n"), 0o644); err != nil {
t.Fatal(err)
}
fourth, err := hashConfigTree(context.Background(), root)
if err != nil || fourth == third {
t.Fatalf("main edit not detected: digest=%x err=%v", fourth, err)
}
}
func TestWatchSkipsUnchangedRetriesErrorsAndForcesReconciliation(t *testing.T) {
root, fragment := testConfig(t)
ctl := &fakeControl{}
w := testWatcher(root, ctl)
now := time.Now()
if err := w.step(context.Background(), now); err != nil {
t.Fatal(err)
}
if err := w.step(context.Background(), now.Add(30*time.Second)); err != nil {
t.Fatal(err)
}
if ctl.configured != 1 || ctl.parsed != 1 {
t.Fatalf("unchanged tree configured again: %+v", ctl)
}
if err := os.WriteFile(fragment, []byte("# changed\n"), 0o644); err != nil {
t.Fatal(err)
}
ctl.failConfigure = 1
if err := w.step(context.Background(), now.Add(time.Minute)); err == nil {
t.Fatal("expected configure failure")
}
if !w.haveHash || ctl.configured != 2 {
t.Fatalf("failure changed remembered state: %+v", w)
}
if err := w.step(context.Background(), now.Add(90*time.Second)); err != nil {
t.Fatal(err)
}
if ctl.configured != 3 {
t.Fatalf("failed change was not retried: %+v", ctl)
}
if err := w.step(context.Background(), now.Add(7*time.Minute)); err != nil {
t.Fatal(err)
}
if ctl.configured != 4 {
t.Fatalf("forced reconciliation missing: %+v", ctl)
}
// A fresh watcher must reconcile once even when another process already loaded the same bytes.
restarted := testWatcher(root, ctl)
if err := restarted.step(context.Background(), now.Add(8*time.Minute)); err != nil {
t.Fatal(err)
}
if ctl.configured != 5 {
t.Fatalf("restart did not reconcile: %+v", ctl)
}
}
func TestWatchRejectsChangingTreeBeforeConfigure(t *testing.T) {
root, fragment := testConfig(t)
ctl := &fakeControl{onParse: func() { _ = os.WriteFile(fragment, []byte("# raced\n"), 0o644) }}
w := testWatcher(root, ctl)
if err := w.step(context.Background(), time.Now()); err == nil || !strings.Contains(err.Error(), "changed during parse check") {
t.Fatalf("want race retry, got %v", err)
}
if ctl.configured != 0 {
t.Fatal("configured a tree that changed after parse check")
}
ctl.onParse = nil
if err := w.step(context.Background(), time.Now()); err != nil {
t.Fatal(err)
}
if ctl.configured != 1 {
t.Fatal("stable tree was not configured")
}
}
func TestUnsupportedIncludeUsesLegacyEveryTickCadence(t *testing.T) {
root, _ := testConfig(t)
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("include variable;\n"), 0o644); err != nil {
t.Fatal(err)
}
ctl := &fakeControl{}
w := testWatcher(root, ctl)
now := time.Now()
for i := 0; i < 3; i++ {
if err := w.step(context.Background(), now.Add(time.Duration(i)*30*time.Second)); err != nil {
t.Fatal(err)
}
}
if ctl.parsed != 3 || ctl.configured != 3 || w.haveHash {
t.Fatalf("unsupported tree was skipped: %+v", ctl)
}
}
func TestMissingIncludedFileFallsBackButMissingMainWaits(t *testing.T) {
root, fragment := testConfig(t)
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("include \"bird.d/peer.conf\";\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(fragment); err != nil {
t.Fatal(err)
}
ctl := &fakeControl{}
w := testWatcher(root, ctl)
now := time.Now()
for i := 0; i < 2; i++ {
if err := w.step(context.Background(), now.Add(time.Duration(i)*30*time.Second)); err != nil {
t.Fatal(err)
}
}
if ctl.configured != 2 {
t.Fatalf("missing include skipped legacy cadence: %+v", ctl)
}
if err := os.Remove(filepath.Join(root, "bird.conf")); err != nil {
t.Fatal(err)
}
if err := w.step(context.Background(), now.Add(time.Minute)); !errors.Is(err, errMainNotReady) {
t.Fatalf("missing main should wait, got %v", err)
}
if ctl.configured != 2 {
t.Fatal("configured without main bird.conf")
}
}
func TestHashConfigTreeBoundsAndEscapes(t *testing.T) {
root, fragment := testConfig(t)
if err := os.WriteFile(fragment, make([]byte, maxFileBytes+1), 0o644); err != nil {
t.Fatal(err)
}
if _, err := hashConfigTree(context.Background(), root); err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("want size limit, got %v", err)
}
if err := os.WriteFile(fragment, []byte("# small\n"), 0o644); err != nil {
t.Fatal(err)
}
outside := filepath.Join(t.TempDir(), "outside.conf")
if err := os.WriteFile(outside, []byte("# outside\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("include \""+filepath.ToSlash(outside)+"\";\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := hashConfigTree(context.Background(), root); err == nil || !strings.Contains(err.Error(), "escapes active directory") {
t.Fatalf("want outside include rejection, got %v", err)
}
}
func TestHashConfigTreeRejectsSymlinkInclude(t *testing.T) {
root, fragment := testConfig(t)
if err := os.Remove(fragment); err != nil {
t.Fatal(err)
}
outside := filepath.Join(t.TempDir(), "outside.conf")
if err := os.WriteFile(outside, []byte("# outside\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, fragment); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if _, err := hashConfigTree(context.Background(), root); err == nil || !strings.Contains(err.Error(), "not a regular file") {
t.Fatalf("want symlink rejection, got %v", err)
}
}
func TestIncludeCommentsAndUnsupportedForms(t *testing.T) {
patterns, err := includePatterns("# include \"ignored.conf\";\n/*\ninclude \"also-ignored.conf\";\n*/\ninclude \"bird.d/*.conf\";\n")
if err != nil || len(patterns) != 1 || patterns[0] != "bird.d/*.conf" {
t.Fatalf("comment handling failed: patterns=%v err=%v", patterns, err)
}
if _, err := includePatterns("if true then include \"dynamic.conf\";\n"); err == nil {
t.Fatal("inline include should fall back to legacy cadence")
}
if _, err := includePatterns("include \"bird.d/[!a].conf\";\n"); err != nil {
// Bracket glob is rejected by the scanner after lexical parsing.
t.Fatalf("literal include statement should parse before glob validation: %v", err)
}
root, _ := testConfig(t)
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("include \"bird.d/[!a].conf\";\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := hashConfigTree(context.Background(), root); err == nil || !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("bracket glob should fall back, got %v", err)
}
if _, err := includePatterns("define note = \"single\\\";\ninclude \"bird.d/real.conf\";\n"); err == nil {
t.Fatal("backslash in quoted text must force conservative fallback")
}
}
func TestBoundedGlobLimitsMatches(t *testing.T) {
root := t.TempDir()
for i := 0; i <= maxConfigFiles; i++ {
name := filepath.Join(root, fmt.Sprintf("peer-%03d.conf", i))
if err := os.WriteFile(name, nil, 0o644); err != nil {
t.Fatal(err)
}
}
if _, err := boundedGlob(filepath.Join(root, "*.conf")); err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("want bounded glob rejection, got %v", err)
}
}
func TestReadBoundedStopsAfterLimit(t *testing.T) {
r := strings.NewReader("abcdefghij")
if _, err := readBounded(r, 3); err == nil {
t.Fatal("growing file exceeded limit")
}
if got := r.Len(); got != 6 {
t.Fatalf("reader consumed beyond limit+1: %d bytes remain", got)
}
}
func TestRunStopsOnCancellation(t *testing.T) {
root, _ := testConfig(t)
ctx, cancel := context.WithCancel(context.Background())
ctl := &fakeControl{}
done := make(chan error, 1)
go func() {
done <- Run(ctx, ctl, Config{ActiveDir: root, Every: time.Hour, ReconcileEvery: 5 * time.Minute, CommandTimeout: time.Second, Logf: func(string, ...any) {}})
}()
cancel()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("watch did not stop after cancellation")
}
}
+21 -8
View File
@@ -23,6 +23,8 @@ const DefaultASOverviewURL = "https://stat.ripe.net/data/as-overview/data.json"
// AnnouncedPrefixes returns currently announced IPv4/IPv6 prefixes for the ASN (best-effort via RIPEstat).
func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip.Prefix, error) {
ctx, cancel := ripeRequestContext(ctx)
defer cancel()
if hc == nil {
hc = httpclient.New(httpclient.DefaultTimeout)
}
@@ -43,13 +45,14 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return nil, fmt.Errorf("ripestat AS%d: HTTP %s: %s", asn, resp.Status, truncateForErr(body, 200))
}
body, err := httpclient.ReadResponseBody(resp, 32<<20)
if err != nil {
return nil, fmt.Errorf("ripestat AS%d response: %w", asn, err)
}
var wrap struct {
Status string `json:"status"`
@@ -85,6 +88,8 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
// ASHolderName returns the holder / organization label for the ASN from RIPEstat as-overview (best-effort).
func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, error) {
ctx, cancel := ripeRequestContext(ctx)
defer cancel()
if hc == nil {
hc = httpclient.New(httpclient.DefaultTimeout)
}
@@ -105,13 +110,14 @@ func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, erro
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return "", fmt.Errorf("ripestat as-overview AS%d: HTTP %s: %s", asn, resp.Status, truncateForErr(body, 200))
}
body, err := httpclient.ReadResponseBody(resp, 4<<20)
if err != nil {
return "", fmt.Errorf("ripestat as-overview AS%d response: %w", asn, err)
}
var wrap struct {
Status string `json:"status"`
@@ -135,3 +141,10 @@ func truncateForErr(b []byte, n int) string {
}
return s
}
func ripeRequestContext(ctx context.Context) (context.Context, context.CancelFunc) {
if _, ok := ctx.Deadline(); ok {
return context.WithCancel(ctx)
}
return context.WithTimeout(ctx, httpclient.DefaultTimeout)
}
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff. Show more