Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78f2ecc246 | ||
|
|
8c97445f7e | ||
|
|
0ea5b3b738 | ||
|
|
66b785f7cb | ||
|
|
cb14194a5f | ||
|
|
9c38e1bc57 | ||
|
|
4ac99e43ae |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 44608,
|
||||
"pid": 48400,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1781240018712
|
||||
"startedAt": 1783060842523
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (MCP +
|
||||
1. **Контракт HTTP** — `docs/openapi.yaml` (не Context7).
|
||||
2. **Context7** — синтаксис и API библиотек из таблицы.
|
||||
3. **Локальные docs** — `docs/`, `web/README.md`, `AGENTS.md`.
|
||||
4. **Официальный сайт** — BIRD: https://bird.network.cz/?get_doc (если Context7 не покрыл кейс).
|
||||
4. **Официальный сайт** — BIRD: https://bird.nic.cz/?get_doc (если Context7 не покрыл кейс).
|
||||
|
||||
## Примеры запросов
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ alwaysApply: true
|
||||
| OpenAPI / problem+json | `docs/openapi.yaml`, RFC 9457 |
|
||||
| Svelte / Kit | https://svelte.dev/docs , https://kit.svelte.dev/docs |
|
||||
| shadcn-svelte | https://shadcn-svelte.com/docs |
|
||||
| BIRD 2 | https://bird.network.cz/?get_doc |
|
||||
| BIRD 2 | https://bird.nic.cz/?get_doc |
|
||||
| Prometheus Go | https://pkg.go.dev/github.com/prometheus/client_golang |
|
||||
|
||||
**DOC-SYNC-01** | MUST | Новый API библиотеки — сверка версии в `go.mod`/`package.json` с официальной документацией.
|
||||
|
||||
@@ -23,7 +23,7 @@ alwaysApply: false
|
||||
| Параметры BIRD tenant | Global settings: `bird_router_id`, `bird_local_asn`, … (`docs/manual.md`) |
|
||||
| BGP peers | `BGPPeer` + `ParsePeerNeighbor` |
|
||||
|
||||
**BIRD2 docs:** https://bird.network.cz/?get_doc
|
||||
**BIRD2 docs:** https://bird.nic.cz/?get_doc
|
||||
|
||||
---
|
||||
|
||||
@@ -199,7 +199,7 @@ alwaysApply: false
|
||||
|
||||
## Documentation Sync
|
||||
|
||||
**DOC-SYNC-05** | MUST | BIRD — https://bird.network.cz/?get_doc
|
||||
**DOC-SYNC-05** | MUST | BIRD — https://bird.nic.cz/?get_doc
|
||||
**DOC-SYNC-08** | MUST | BGP policy — RFC 4271, 4760, 7454 + BIRD docs + `birdfmt`
|
||||
**DOC-SYNC-09** | MUST | CIDR — https://pkg.go.dev/net/netip ; примеры — RFC 5737, 3849
|
||||
|
||||
|
||||
@@ -167,14 +167,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: pnpm install, typecheck, lint, build
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
@@ -378,7 +379,9 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
|
||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
||||
<Select value={a} onValueChange={(v) => v && setA(v)}>
|
||||
<SelectTrigger>{a ? a.slice(0, 12) + '…' : 'Выберите'}</SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{revisions.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
@@ -391,7 +394,9 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
|
||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Ревизия B</span>
|
||||
<Select value={b} onValueChange={(v) => v && setB(v)}>
|
||||
<SelectTrigger>{b ? b.slice(0, 12) + '…' : 'Выберите'}</SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{revisions.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
|
||||
@@ -4,7 +4,13 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@evobgp/ui/components/select'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
@@ -85,7 +91,9 @@ function SettingsComponent() {
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Label htmlFor="theme-select">Тема</Label>
|
||||
<Select value={theme ?? 'system'} onValueChange={(v) => v && setTheme(v)}>
|
||||
<SelectTrigger id="theme-select" className="w-full max-w-xs" />
|
||||
<SelectTrigger id="theme-select" className="w-full max-w-xs">
|
||||
<SelectValue placeholder="Выберите тему" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Светлая</SelectItem>
|
||||
<SelectItem value="dark">Тёмная</SelectItem>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
@@ -251,7 +252,7 @@ function TenantSettingsComponent() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{runtimeLogsForm.runtime_logs_auto_enabled === 'true' ? 'Вкл' : 'Выкл'}
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Вкл</SelectItem>
|
||||
@@ -308,11 +309,11 @@ function TenantSettingsComponent() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{runtimeLogsForm.runtime_logs_auto_mode || 'Выберите'}
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="delete">Delete</SelectItem>
|
||||
<SelectItem value="truncate">Truncate</SelectItem>
|
||||
<SelectItem value="truncate">truncate — обнулить</SelectItem>
|
||||
<SelectItem value="delete">delete — удалить файл</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
|
||||
@@ -18,7 +18,7 @@ apt-get install -y --no-install-recommends \
|
||||
|
||||
cd /tmp
|
||||
rm -rf "bird-${BIRD_VERSION}"
|
||||
curl -fsSL "https://bird.network.cz/download/bird-${BIRD_VERSION}.tar.gz" | tar xz
|
||||
curl -fsSL "https://bird.nic.cz/download/bird-${BIRD_VERSION}.tar.gz" | tar xz
|
||||
cd "bird-${BIRD_VERSION}"
|
||||
./configure --prefix=/usr/local --enable-client
|
||||
make -j"$(nproc)"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
FROM public.ecr.aws/docker/library/node:22-alpine AS deps
|
||||
WORKDIR /repo
|
||||
RUN corepack enable
|
||||
RUN corepack enable && corepack prepare [email protected] --activate
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
|
||||
+40
-4
@@ -184,6 +184,10 @@ type Registry struct {
|
||||
onTerminal func(j *Job)
|
||||
onEnqueued func(j *Job)
|
||||
onRunning func(j *Job)
|
||||
// inflightRefresh counts refresh-kind jobs (module_refresh, tenant_refresh) per tenant that
|
||||
// have been enqueued but not yet finalized in finishModuleRefreshSuccess. Used for deterministic
|
||||
// deploy coalescing under tenantRefreshMu (instead of polling job statuses).
|
||||
inflightRefresh map[string]int
|
||||
}
|
||||
|
||||
type idempoKey struct {
|
||||
@@ -194,10 +198,11 @@ type idempoKey struct {
|
||||
func NewRegistry(workerStart func(j *Job)) *Registry {
|
||||
maxWorkers := registryMaxConcurrentJobs()
|
||||
return &Registry{
|
||||
byID: make(map[string]*Job),
|
||||
byIdempo: make(map[idempoKey]*Job),
|
||||
workerStart: workerStart,
|
||||
workerSem: make(chan struct{}, maxWorkers),
|
||||
byID: make(map[string]*Job),
|
||||
byIdempo: make(map[idempoKey]*Job),
|
||||
workerStart: workerStart,
|
||||
workerSem: make(chan struct{}, maxWorkers),
|
||||
inflightRefresh: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +345,9 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
|
||||
r.byIdempo[idempoKey{tenant: tenantID, key: *idempotencyKey}] = j
|
||||
}
|
||||
r.byID[j.ID] = j
|
||||
if isRefreshKind(kind) {
|
||||
r.inflightRefresh[tenantID]++
|
||||
}
|
||||
r.pruneTerminalIfOver(maxJobs)
|
||||
enqueuedHook := r.onEnqueued
|
||||
workerStart := r.workerStart
|
||||
@@ -474,6 +482,34 @@ func (r *Registry) CountOtherActiveRefresh(tenantID, excludeJobID string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// isRefreshKind reports whether a job kind participates in deploy coalescing.
|
||||
func isRefreshKind(kind string) bool {
|
||||
return kind == KindModuleRefresh || kind == KindTenantRefresh
|
||||
}
|
||||
|
||||
// finalizeRefreshCoalesce is called from finishModuleRefreshSuccess under tenantRefreshMu.
|
||||
// It atomically decrements the per-tenant inflight refresh counter and reports whether the
|
||||
// caller is the last outstanding refresh for the tenant (and therefore should render+deploy).
|
||||
//
|
||||
// Unlike CountOtherActiveRefresh (which polls job statuses and races under -race), this counter
|
||||
// is incremented in Enqueue under r.mu and decremented here, so the "last one" decision is
|
||||
// deterministic regardless of how fast each refresh's ingest completes.
|
||||
func (r *Registry) finalizeRefreshCoalesce(tenantID string) bool {
|
||||
if r == nil {
|
||||
return true
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
n := r.inflightRefresh[tenantID]
|
||||
if n <= 1 {
|
||||
// Last (or already-balanced to zero) — clear the slot and let the caller deploy.
|
||||
delete(r.inflightRefresh, tenantID)
|
||||
return true
|
||||
}
|
||||
r.inflightRefresh[tenantID] = n - 1
|
||||
return false
|
||||
}
|
||||
|
||||
func parseCursor(s string, off *int) error {
|
||||
_, err := fmt.Sscanf(s, "%d", off)
|
||||
return err
|
||||
|
||||
+60
-23
@@ -119,26 +119,7 @@ func (w *Worker) Process(j *Job) {
|
||||
|
||||
switch j.Kind {
|
||||
case KindModuleRefresh:
|
||||
mid, _ := j.Meta["module_id"].(string)
|
||||
if strings.TrimSpace(mid) == "" {
|
||||
j.Fail("missing module_id in job meta")
|
||||
return
|
||||
}
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
w.finishModuleRefreshSuccess(j, mid)
|
||||
w.runModuleRefresh(j)
|
||||
case KindTenantRefresh:
|
||||
w.runTenantRefresh(j)
|
||||
case KindPeerReconcile:
|
||||
@@ -295,7 +276,55 @@ func (w *Worker) tenantRefreshMu(tenantID string) *sync.Mutex {
|
||||
|
||||
// finishModuleRefreshSuccess marks the refresh job and, for the last active refresh in tenant,
|
||||
// creates one aggregate revision and enqueues a single deploy_apply.
|
||||
// runModuleRefresh handles a single module_refresh job and guarantees the per-tenant inflight
|
||||
// slot is released exactly once — even on failure/cancellation before finishModuleRefreshSuccess.
|
||||
func (w *Worker) runModuleRefresh(j *Job) {
|
||||
coalesceFinalized := false
|
||||
defer func() {
|
||||
if !coalesceFinalized && w != nil && w.Registry != nil {
|
||||
// Refresh failed/was cancelled before reaching finishModuleRefreshSuccess.
|
||||
// Decrement the counter under the tenant mutex so the "last one" logic stays sound.
|
||||
mu := w.tenantRefreshMu(j.TenantID)
|
||||
mu.Lock()
|
||||
w.Registry.finalizeRefreshCoalesce(j.TenantID)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
mid, _ := j.Meta["module_id"].(string)
|
||||
if strings.TrimSpace(mid) == "" {
|
||||
j.Fail("missing module_id in job meta")
|
||||
return
|
||||
}
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
w.finishModuleRefreshSuccess(j, mid)
|
||||
coalesceFinalized = true
|
||||
}
|
||||
|
||||
func (w *Worker) runTenantRefresh(j *Job) {
|
||||
coalesceFinalized := false
|
||||
defer func() {
|
||||
if !coalesceFinalized && w != nil && w.Registry != nil {
|
||||
mu := w.tenantRefreshMu(j.TenantID)
|
||||
mu.Lock()
|
||||
w.Registry.finalizeRefreshCoalesce(j.TenantID)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
moduleIDs := moduleIDsFromJobMeta(j.Meta)
|
||||
if len(moduleIDs) == 0 {
|
||||
j.Fail("missing module_ids in job meta")
|
||||
@@ -318,6 +347,7 @@ func (w *Worker) runTenantRefresh(j *Job) {
|
||||
}
|
||||
j.mergeMeta(map[string]any{"module_ids": moduleIDs, "modules_refreshed": len(moduleIDs)})
|
||||
w.finishModuleRefreshSuccess(j, trigger)
|
||||
coalesceFinalized = true
|
||||
}
|
||||
|
||||
func moduleIDsFromJobMeta(meta map[string]any) []string {
|
||||
@@ -346,6 +376,9 @@ func moduleIDsFromJobMeta(meta map[string]any) []string {
|
||||
|
||||
func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
||||
if w == nil || w.Store == nil {
|
||||
if w != nil && w.Registry != nil {
|
||||
w.Registry.finalizeRefreshCoalesce(j.TenantID)
|
||||
}
|
||||
j.Succeed()
|
||||
return
|
||||
}
|
||||
@@ -354,11 +387,15 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
||||
defer mu.Unlock()
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
deferDeploy := false
|
||||
// Determine whether this is the last outstanding refresh for the tenant. The counter is
|
||||
// incremented in Enqueue (under r.mu) and decremented here, so the "last one" decision is
|
||||
// deterministic regardless of ingest timing — unlike the previous status-polling approach
|
||||
// (CountOtherActiveRefresh) which could race under -race.
|
||||
isLastRefresh := true
|
||||
if w.Registry != nil {
|
||||
deferDeploy = w.Registry.CountOtherActiveRefresh(j.TenantID, j.ID) > 0
|
||||
isLastRefresh = w.Registry.finalizeRefreshCoalesce(j.TenantID)
|
||||
}
|
||||
if deferDeploy {
|
||||
if !isLastRefresh {
|
||||
j.mergeMeta(map[string]any{
|
||||
"deploy_apply_deferred": true,
|
||||
"deploy_apply_defer_reason": "parallel_module_refresh",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "evobgp-release",
|
||||
"private": true,
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter @evobgp/web dev",
|
||||
"build": "pnpm --filter @evobgp/web build",
|
||||
|
||||
Reference in New Issue
Block a user