Compare commits

..
4 Commits
Author SHA1 Message Date
Denozordec 0af37d55c4 fix(pagination): enhance DataGridPagination to dynamically populate Select items
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m0s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m37s
Updated the DataGridPagination component to map available sizes into Select items for better user experience. This change allows for dynamic selection of page sizes based on the provided props.
2026-07-03 14:43:46 +07:00
Denozordec 78f2ecc246 fix(auth): update Select components to include SelectValue for better placeholder handling
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 57s
CI / go (push) Successful in 1m5s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m0s
Modified the Select components in operations.tsx, settings.tsx, and tenant-settings.tsx to incorporate SelectValue for improved placeholder functionality. Additionally, updated the daemon PID and timestamps in .codegraph/daemon.pid for consistency.
2026-07-03 14:10:23 +07:00
DenozordecandCursor 8c97445f7e fix(jobs): make deploy coalescing deterministic via inflight counter
CI / changes (push) Successful in 12s
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m27s
TestParallelModuleRefresh_CoalescesDeployApply падал на CI под -race
(want exactly one deploy_apply job, got 2). Локально тест проходил
стабильно (100/500 итераций с -cpu), но узкая гонка проявлялась при
замедлении под race-детектором.

Корень: коалесцирование решало «делать ли deploy» через
CountOtherActiveRefresh, который опрашивал статусы job-ов (queued/
running). Статусы меняются асинхронно относительно tenantRefreshMu,
поэтому в редких таймингах оба параллельных refresh могли решить,
что другой уже не активен, и каждый породил свой deploy_apply.

Решение — детерминированный inflight-счётчик refresh-kind job-ов в
Registry (inflightRefresh map[string]int), управляемый под r.mu:
- инкремент в Enqueue при создании нового refresh-kind job-а;
- декремент + проверка «последний ли я» в finishModuleRefreshSuccess
  через новый метод finalizeRefreshCoalesce (под tenantRefreshMu).

Последний refresh (счётчик <= 1) делает render + deploy_apply; все
остальные defer-ят. Решение больше не зависит от опроса статусов и
таймингов ingest.

Чтобы счётчик не утёк на error/cancel путях (где refresh не доходит
до finishModuleRefreshSuccess), обработка module_refresh и
tenant_refresh вынесена в runModuleRefresh / runTenantRefresh с
defer-обёрткой, которая гарантированно освобождает слот, если
finishModuleRefreshSuccess не отработал.

CountOtherActiveRefresh / CountOtherActiveModuleRefresh оставлены
как публичные методы (могут использоваться в мониторинге); из
продакшн-логики коалесцирования убраны.

Проверки: go build, go vet, go test ./internal/... -count=1 — exit 0.
Стресс-тест коалесцирования: 200 итераций с -cpu=4 — стабильно.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 01:31:32 +07:00
DenozordecandCursor 0ea5b3b738 fix(bird2): switch download host from bird.network.cz to bird.nic.cz
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Failing after 41s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
Docker-сборка birdc падала с HTTP 403 при скачивании исходников BIRD
2.14 с bird.network.cz. Старый домен bird.network.cz больше не отдаёт
файлы (403 для всех путей и User-Agent), официальный сайт BIRD переехал
на bird.nic.cz.

URL bird.nic.cz/download/bird-2.14.tar.gz проверен: 200 OK, ~1.4 МБ,
совпадает с размером на старом домене. Страница old-releases на новом
домене подтверждает тот же путь /download/bird-2.14.tar.gz.

Изменены все 5 вхождений старого домена в репозитории:
- deploy/docker/bird/bird-from-source.sh — критичный curl в сборке;
- .cursor/rules/networking-bird.mdc — BIRD2 docs и DOC-SYNC-05;
- .cursor/rules/context7-stack.mdc — приоритет источников;
- .cursor/rules/engineering.mdc — таблица Documentation Sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 23:30:56 +07:00
12 changed files with 171 additions and 46 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"pid": 52448,
"pid": 48400,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1782969267595
"startedAt": 1783060842523
}
+1 -1
View File
@@ -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 не покрыл кейс).
## Примеры запросов
+1 -1
View File
@@ -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` с официальной документацией.
+2 -2
View File
@@ -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
@@ -153,6 +153,10 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
{mergedProps.rowsPerPageLabel}
</div>
<Select
items={mergedProps?.sizes?.map((size: number) => ({
value: `${size}`,
label: `${size}`,
}))}
value={`${pageSize}`}
onValueChange={(value) => {
const newPageSize = Number(value)
+18 -5
View File
@@ -2,7 +2,7 @@ import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
@@ -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 {
@@ -367,6 +368,14 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
const [a, setA] = useState('')
const [b, setB] = useState('')
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
const revisionItems = useMemo(
() =>
revisions.map((r) => ({
value: r.id,
label: `${r.id.slice(0, 12)}`,
})),
[revisions],
)
return (
<Card>
@@ -377,8 +386,10 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
<div className="flex flex-wrap items-end gap-3">
<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>
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
@@ -390,8 +401,10 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
</div>
<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>
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
+21 -3
View File
@@ -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'
@@ -19,6 +25,12 @@ export const Route = createFileRoute('/_auth/settings')({
component: SettingsComponent,
})
const THEME_SELECT_ITEMS = [
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' },
{ value: 'system', label: 'Как в системе' },
] as const
function SettingsComponent() {
const { data: session } = useQuery(authSessionQueryOptions())
const { theme, setTheme } = useTheme()
@@ -84,8 +96,14 @@ function SettingsComponent() {
</CardHeader>
<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" />
<Select
items={[...THEME_SELECT_ITEMS]}
value={theme ?? 'system'}
onValueChange={(v) => v && setTheme(v)}
>
<SelectTrigger id="theme-select" className="w-full max-w-xs">
<SelectValue placeholder="Выберите тему" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Светлая</SelectItem>
<SelectItem value="dark">Тёмная</SelectItem>
+17 -4
View File
@@ -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 {
@@ -48,6 +49,16 @@ export const Route = createFileRoute('/_auth/tenant-settings')({
}),
})
const RUNTIME_LOGS_ENABLED_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const RUNTIME_LOGS_MODE_ITEMS = [
{ value: 'truncate', label: 'truncate — обнулить' },
{ value: 'delete', label: 'delete — удалить файл' },
] as const
const BIRD_LABELS: Record<BirdSettingKey, string> = {
bird_router_id: 'Router ID',
bird_local_ipv4: 'Локальный IPv4',
@@ -241,6 +252,7 @@ function TenantSettingsComponent() {
<div className="flex flex-col gap-1.5">
<Label>Авто-очистка включена</Label>
<Select
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
onValueChange={(v) =>
v &&
@@ -251,7 +263,7 @@ function TenantSettingsComponent() {
}
>
<SelectTrigger>
{runtimeLogsForm.runtime_logs_auto_enabled === 'true' ? 'Вкл' : 'Выкл'}
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Вкл</SelectItem>
@@ -298,6 +310,7 @@ function TenantSettingsComponent() {
<div className="flex flex-col gap-1.5">
<Label>Режим очистки</Label>
<Select
items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
onValueChange={(v) =>
v &&
@@ -308,11 +321,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">
+1 -1
View File
@@ -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)"
+40 -4
View File
@@ -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
View File
@@ -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",
+4
View File
@@ -111,11 +111,15 @@ function SelectLabel({
function SelectItem({
className,
children,
label,
...props
}: SelectPrimitive.Item.Props) {
const resolvedLabel = label ?? (typeof children === "string" ? children : undefined)
return (
<SelectPrimitive.Item
data-slot="select-item"
label={resolvedLabel}
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className