Compare commits
4
Commits
v1.11.0
...
9639a03bfe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9639a03bfe | ||
|
|
48c10b7436 | ||
|
|
4db6438245 | ||
|
|
fb108ec5ab |
No files matched your search
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["@commitlint/config-conventional"]
|
|
||||||
}
|
|
||||||
@@ -142,6 +142,8 @@ feat(web): add module create dialog on /modules
|
|||||||
| `.cursor/` | `chore` |
|
| `.cursor/` | `chore` |
|
||||||
| прочее в корне | `chore` |
|
| прочее в корне | `chore` |
|
||||||
|
|
||||||
|
**Запрещено:** несколько scope через запятую (`refactor(web, httpapi): …`) — semantic-release не распознает `type`, релиз не будет (см. [docs/releasing.md](../../docs/releasing.md)).
|
||||||
|
|
||||||
`type` определять по **содержимому diff**, не только по пути.
|
`type` определять по **содержимому diff**, не только по пути.
|
||||||
|
|
||||||
## Multi-change
|
## Multi-change
|
||||||
|
|||||||
@@ -303,6 +303,8 @@ jobs:
|
|||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
- name: Install release tooling
|
- name: Install release tooling
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
- name: Verify releasable commit messages
|
||||||
|
run: node scripts/commit/verify-release-commits.mjs
|
||||||
- name: Semantic release
|
- name: Semantic release
|
||||||
run: npx semantic-release
|
run: npx semantic-release
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** @type {import('@commitlint/types').UserConfig} */
|
||||||
|
module.exports = {
|
||||||
|
extends: ['@commitlint/config-conventional'],
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'scope-no-commas': ({ scope }) => {
|
||||||
|
if (scope && scope.includes(',')) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
'scope must not contain commas (semantic-release will not parse the commit type)'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [true];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'scope-no-commas': [2, 'always']
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,6 +11,8 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
|
|||||||
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
||||||
| `docs`, `chore`, `test` | без релиза |
|
| `docs`, `chore`, `test` | без релиза |
|
||||||
|
|
||||||
|
**Scope:** один идентификатор **без запятых** (`web`, `httpapi`, `api`). Заголовок `refactor(a, b): …` **не парсится** semantic-release → релиз не создаётся (commitlint на PR это тоже отклонит). Подробнее — раздел «Scope и semantic-release» ниже.
|
||||||
|
|
||||||
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя. По semver на одном уровне с `fix`, но семантически «мельче» `feat` (не minor).
|
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя. По semver на одном уровне с `fix`, но семантически «мельче» `feat` (не minor).
|
||||||
|
|
||||||
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
|
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
|
||||||
@@ -62,6 +64,21 @@ API: `GET /version`, `GET /v1/version` — поля `version`, `git_sha`, `build
|
|||||||
|
|
||||||
Web UI показывает версию из API (footer sidebar, страница «Мониторинг»).
|
Web UI показывает версию из API (footer sidebar, страница «Мониторинг»).
|
||||||
|
|
||||||
|
## Scope и semantic-release
|
||||||
|
|
||||||
|
Парсер [conventional-commits-parser](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-commits-parser) (его использует semantic-release) **не понимает запятые в scope**:
|
||||||
|
|
||||||
|
| Заголовок | Парсится | Релиз |
|
||||||
|
|-----------|----------|-------|
|
||||||
|
| `refactor(web): fix layout` | да, `refactor` | patch |
|
||||||
|
| `refactor(NetworkOverviewTab, NetworkSpeakersCard): fix layout` | **нет**, `type: null` | **нет** |
|
||||||
|
|
||||||
|
Правило: **один scope** из таблицы в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc) (`web`, `httpapi`, `api`, …).
|
||||||
|
|
||||||
|
На push в `main` job **release** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах.
|
||||||
|
|
||||||
|
Если релиз «не создался», а CI зелёный: смотрите лог release — часто `No releasable commits`. Исправление: новый коммит с корректным заголовком (например `refactor(web): …`).
|
||||||
|
|
||||||
## CHANGELOG
|
## CHANGELOG
|
||||||
|
|
||||||
Release notes — в Gitea Release; файл `CHANGELOG.md` генерируется в CI и прикрепляется как asset, **не** попадает в git history.
|
Release notes — в Gitea Release; файл `CHANGELOG.md` генерируется в CI и прикрепляется как asset, **не** попадает в git history.
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ func MergeSpeakerMetaJSON(existing string, patch SpeakerMeta) string {
|
|||||||
if patch.LastDispatchAt != "" {
|
if patch.LastDispatchAt != "" {
|
||||||
cur.LastDispatchAt = patch.LastDispatchAt
|
cur.LastDispatchAt = patch.LastDispatchAt
|
||||||
}
|
}
|
||||||
if patch.LastDispatchError != "" {
|
if patch.LastDispatchStatus == "ok" {
|
||||||
|
cur.LastDispatchError = ""
|
||||||
|
} else if patch.LastDispatchError != "" {
|
||||||
cur.LastDispatchError = patch.LastDispatchError
|
cur.LastDispatchError = patch.LastDispatchError
|
||||||
}
|
}
|
||||||
if patch.LastDispatchStatus != "" {
|
if patch.LastDispatchStatus != "" {
|
||||||
|
|||||||
@@ -34,3 +34,24 @@ func TestAgentSyncURL(t *testing.T) {
|
|||||||
t.Fatalf("got %q", u)
|
t.Fatalf("got %q", u)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeSpeakerMetaJSON_clearsDispatchErrorOnOk(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
existing := store.SpeakerMetaJSON(store.SpeakerMeta{
|
||||||
|
LastDispatchError: "HTTP 502: bundle 403",
|
||||||
|
LastDispatchStatus: "error",
|
||||||
|
SyncStatus: "error",
|
||||||
|
})
|
||||||
|
merged := store.MergeSpeakerMetaJSON(existing, store.SpeakerMeta{
|
||||||
|
LastDispatchStatus: "ok",
|
||||||
|
SyncStatus: "synced",
|
||||||
|
LastDispatchAt: "2026-05-21T15:06:43Z",
|
||||||
|
})
|
||||||
|
m := store.ParseSpeakerMeta(merged)
|
||||||
|
if m.LastDispatchError != "" {
|
||||||
|
t.Fatalf("LastDispatchError should clear on ok dispatch, got %q", m.LastDispatchError)
|
||||||
|
}
|
||||||
|
if m.LastDispatchStatus != "ok" || m.SyncStatus != "synced" {
|
||||||
|
t.Fatalf("status: dispatch=%q sync=%q", m.LastDispatchStatus, m.SyncStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Warns about commits since the last tag that semantic-release cannot parse.
|
||||||
|
* Exit 0 always — semantic-release still decides release/no-op.
|
||||||
|
*/
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import parser from 'conventional-commits-parser';
|
||||||
|
|
||||||
|
const RELEASABLE = new Set(['feat', 'fix', 'perf', 'ci', 'refactor']);
|
||||||
|
|
||||||
|
function lastTag() {
|
||||||
|
try {
|
||||||
|
return execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitsSince(ref) {
|
||||||
|
const range = ref ? `${ref}..HEAD` : 'HEAD';
|
||||||
|
const out = execSync(`git log ${range} --format=%H%x09%s`, { encoding: 'utf8' }).trim();
|
||||||
|
if (!out) return [];
|
||||||
|
return out.split('\n').map((line) => {
|
||||||
|
const [hash, subject] = line.split('\t');
|
||||||
|
return { hash: hash.trim(), subject: subject.trim() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = lastTag();
|
||||||
|
const commits = commitsSince(tag);
|
||||||
|
const unparseable = [];
|
||||||
|
const releasable = [];
|
||||||
|
|
||||||
|
for (const { hash, subject } of commits) {
|
||||||
|
const parsed = parser.sync(subject);
|
||||||
|
if (!parsed.type) {
|
||||||
|
unparseable.push({ hash: hash.slice(0, 7), subject });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (RELEASABLE.has(parsed.type)) {
|
||||||
|
releasable.push({ hash: hash.slice(0, 7), subject, type: parsed.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commits.length === 0) {
|
||||||
|
console.log(`No new commits since ${tag || 'initial'}.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unparseable.length > 0) {
|
||||||
|
console.warn('::warning:: Commits not parseable by semantic-release (no version bump):');
|
||||||
|
for (const b of unparseable) {
|
||||||
|
console.warn(` ${b.hash} ${b.subject}`);
|
||||||
|
}
|
||||||
|
console.warn('Fix: single scope without commas, e.g. refactor(web): summary');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releasable.length > 0) {
|
||||||
|
console.log(`Releasable since ${tag}: ${releasable.length} commit(s).`);
|
||||||
|
} else {
|
||||||
|
console.warn('::warning:: No releasable commits since last tag — release job will no-op.');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
networkOverallStatusLabel
|
networkOverallStatusLabel
|
||||||
} from '$lib/network/network-metrics.js';
|
} from '$lib/network/network-metrics.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||||
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
|
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
|
||||||
@@ -166,71 +165,76 @@
|
|||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !initialLoading && !loading}
|
<div class="flex min-w-0 flex-col gap-6">
|
||||||
{#if overallStatus === 'ok'}
|
{#if !initialLoading && !loading}
|
||||||
<Alert class="border-success/30 bg-success/5">
|
{#if overallStatus === 'ok'}
|
||||||
<CheckCircle class="text-success" />
|
<Alert class="border-success/30 bg-success/5">
|
||||||
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
<CheckCircle class="text-success" />
|
||||||
<AlertDescription>{overallHint}</AlertDescription>
|
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
</Alert>
|
<AlertDescription>{overallHint}</AlertDescription>
|
||||||
{:else if overallStatus === 'warn'}
|
</Alert>
|
||||||
<Alert class="border-warning/30 bg-warning/5">
|
{:else if overallStatus === 'warn'}
|
||||||
<AlertTriangle class="text-warning" />
|
<Alert class="border-warning/30 bg-warning/5">
|
||||||
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
<AlertTriangle class="text-warning" />
|
||||||
<AlertDescription>
|
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
{overallHint}
|
<AlertDescription>
|
||||||
{#if issues.length > 0}
|
{overallHint}
|
||||||
<ul class="mt-2 list-inside list-disc text-sm">
|
{#if issues.length > 0}
|
||||||
{#each issues as issue (issue.id)}
|
<ul class="mt-2 list-inside list-disc text-sm">
|
||||||
<li>{issue.message}</li>
|
{#each issues as issue (issue.id)}
|
||||||
{/each}
|
<li>{issue.message}</li>
|
||||||
</ul>
|
{/each}
|
||||||
{/if}
|
</ul>
|
||||||
</AlertDescription>
|
{/if}
|
||||||
</Alert>
|
</AlertDescription>
|
||||||
{:else}
|
</Alert>
|
||||||
<Alert variant="destructive">
|
{:else}
|
||||||
<XCircle />
|
<Alert variant="destructive">
|
||||||
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
<XCircle />
|
||||||
<AlertDescription>
|
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
{overallHint}
|
<AlertDescription>
|
||||||
{#if issues.length > 0}
|
{overallHint}
|
||||||
<ul class="mt-2 list-inside list-disc text-sm">
|
{#if issues.length > 0}
|
||||||
{#each issues as issue (issue.id)}
|
<ul class="mt-2 list-inside list-disc text-sm">
|
||||||
<li>{issue.message}</li>
|
{#each issues as issue (issue.id)}
|
||||||
{/each}
|
<li>{issue.message}</li>
|
||||||
</ul>
|
{/each}
|
||||||
{/if}
|
</ul>
|
||||||
</AlertDescription>
|
{/if}
|
||||||
</Alert>
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
<KpiMetricsGrid
|
<KpiMetricsGrid
|
||||||
cards={kpiCards}
|
cards={kpiCards}
|
||||||
loading={initialLoading || loading}
|
loading={initialLoading || loading}
|
||||||
skeletonCount={6}
|
skeletonCount={6}
|
||||||
class="sm:grid-cols-2 xl:grid-cols-3"
|
class="sm:grid-cols-2 xl:grid-cols-3"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
<section class="flex min-w-0 flex-col gap-4">
|
||||||
<h2 class="text-base font-semibold">Ноды</h2>
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
|
<h2 class="text-base font-semibold">Ноды</h2>
|
||||||
<Gauge class="size-3.5" />
|
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
|
||||||
Мониторинг API
|
<Gauge class="size-3.5" />
|
||||||
</Button>
|
Мониторинг API
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if speakers.length === 0 && !initialLoading && !loading}
|
||||||
|
<p class="text-sm text-muted-foreground">Спикеры не зарегистрированы.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid auto-rows-fr gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{#each speakers as speaker (speaker.id)}
|
||||||
|
<NetworkSpeakerStatusCard
|
||||||
|
{speaker}
|
||||||
|
{peers}
|
||||||
|
class="h-full"
|
||||||
|
onclick={onSpeakerSelect ? () => onSpeakerSelect(speaker) : undefined}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if speakers.length === 0 && !initialLoading && !loading}
|
|
||||||
<p class="text-sm text-muted-foreground">Спикеры не зарегистрированы.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{#each speakers as speaker (speaker.id)}
|
|
||||||
<NetworkSpeakerStatusCard
|
|
||||||
{speaker}
|
|
||||||
{peers}
|
|
||||||
onclick={onSpeakerSelect ? () => onSpeakerSelect(speaker) : undefined}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -3,9 +3,13 @@
|
|||||||
import {
|
import {
|
||||||
peersForSpeaker,
|
peersForSpeaker,
|
||||||
speakerDisplayStatus,
|
speakerDisplayStatus,
|
||||||
|
speakerDispatchError,
|
||||||
speakerHasDrift,
|
speakerHasDrift,
|
||||||
speakerLabel
|
speakerLabel,
|
||||||
|
speakerLiveAgentError,
|
||||||
|
speakerLiveBgpError
|
||||||
} from '$lib/network/network-metrics.js';
|
} from '$lib/network/network-metrics.js';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import { Separator } from '$lib/ui/core/separator/index.js';
|
import { Separator } from '$lib/ui/core/separator/index.js';
|
||||||
@@ -24,6 +28,7 @@
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow
|
||||||
} from '$lib/ui/core/table/index.js';
|
} from '$lib/ui/core/table/index.js';
|
||||||
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
speaker: SpeakerRow | null;
|
speaker: SpeakerRow | null;
|
||||||
@@ -39,6 +44,9 @@
|
|||||||
const label = $derived(speaker ? speakerLabel(speaker) : '');
|
const label = $derived(speaker ? speakerLabel(speaker) : '');
|
||||||
const relatedPeers = $derived(speaker ? peersForSpeaker(peers, speaker.id) : []);
|
const relatedPeers = $derived(speaker ? peersForSpeaker(peers, speaker.id) : []);
|
||||||
const sessions = $derived(speaker?.live?.sessions ?? []);
|
const sessions = $derived(speaker?.live?.sessions ?? []);
|
||||||
|
const dispatchError = $derived(speaker ? speakerDispatchError(speaker) : null);
|
||||||
|
const agentError = $derived(speaker ? speakerLiveAgentError(speaker) : null);
|
||||||
|
const bgpError = $derived(speaker ? speakerLiveBgpError(speaker) : null);
|
||||||
|
|
||||||
function driftLabel(s: SpeakerRow): string {
|
function driftLabel(s: SpeakerRow): string {
|
||||||
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
|
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
|
||||||
@@ -46,22 +54,28 @@
|
|||||||
return `${app} / ${pub}`;
|
return `${app} / ${pub}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSyncAt(iso: string | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
onOpenChange?.(open);
|
onOpenChange?.(open);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sheet bind:open>
|
<Sheet bind:open>
|
||||||
<SheetContent class="flex w-full flex-col overflow-y-auto sm:max-w-lg">
|
<SheetContent class="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-md">
|
||||||
{#if speaker}
|
{#if speaker}
|
||||||
<SheetHeader>
|
<div class="flex min-w-0 flex-col gap-4 px-4 pt-4 pb-6">
|
||||||
<SheetTitle class="truncate">{label}</SheetTitle>
|
<SheetHeader class="space-y-1 pr-8 text-left">
|
||||||
<SheetDescription>
|
<SheetTitle class="truncate">{label}</SheetTitle>
|
||||||
{speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
|
<SheetDescription class="truncate">
|
||||||
</SheetDescription>
|
{speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
|
||||||
</SheetHeader>
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
<div class="mt-4 space-y-4">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{#if status}
|
{#if status}
|
||||||
<Badge variant={status.variant}>{status.label}</Badge>
|
<Badge variant={status.variant}>{status.label}</Badge>
|
||||||
@@ -71,101 +85,118 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2 text-sm">
|
<dl class="grid grid-cols-[minmax(0,9rem)_1fr] gap-x-3 gap-y-2 text-sm">
|
||||||
<div class="flex justify-between gap-2">
|
<dt class="text-muted-foreground">BGP Established</dt>
|
||||||
<span class="text-muted-foreground">BGP Established</span>
|
<dd class="text-right font-medium tabular-nums">
|
||||||
<span class="font-medium tabular-nums">
|
{speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
|
||||||
{speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
|
</dd>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#if speaker.live?.agent_last_sync_at}
|
{#if speaker.live?.agent_last_sync_at}
|
||||||
<div class="flex justify-between gap-2">
|
<dt class="text-muted-foreground">Последний sync</dt>
|
||||||
<span class="text-muted-foreground">Последний sync</span>
|
<dd class="text-right text-xs tabular-nums">
|
||||||
<span class="text-xs">{speaker.live.agent_last_sync_at}</span>
|
{formatSyncAt(speaker.live.agent_last_sync_at)}
|
||||||
</div>
|
</dd>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex justify-between gap-2">
|
<dt class="text-muted-foreground">Drift (app / pub)</dt>
|
||||||
<span class="text-muted-foreground">Drift (app / pub)</span>
|
<dd class="truncate text-right font-mono text-xs">{driftLabel(speaker)}</dd>
|
||||||
<span class="font-mono text-xs">{driftLabel(speaker)}</span>
|
|
||||||
</div>
|
|
||||||
{#if speaker.last_dispatch_at}
|
{#if speaker.last_dispatch_at}
|
||||||
<div class="flex justify-between gap-2">
|
<dt class="text-muted-foreground">Dispatch</dt>
|
||||||
<span class="text-muted-foreground">Dispatch</span>
|
<dd class="text-right text-xs tabular-nums">
|
||||||
<span class="text-xs">{speaker.last_dispatch_at}</span>
|
{formatSyncAt(speaker.last_dispatch_at)}
|
||||||
</div>
|
</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if speaker.last_dispatch_error}
|
</dl>
|
||||||
<p class="text-xs text-destructive">{speaker.last_dispatch_error}</p>
|
|
||||||
{/if}
|
{#if dispatchError}
|
||||||
{#if speaker.live?.agent_error}
|
<Alert class="border-warning/30 bg-warning/5">
|
||||||
<p class="text-xs text-destructive">Agent: {speaker.live.agent_error}</p>
|
<AlertTriangle class="text-warning" />
|
||||||
{/if}
|
<AlertTitle class="text-sm">{dispatchError.title}</AlertTitle>
|
||||||
{#if speaker.live?.bgp_poll_error}
|
<AlertDescription class="text-xs leading-relaxed"
|
||||||
<p class="text-xs text-destructive">BGP poll: {speaker.live.bgp_poll_error}</p>
|
>{dispatchError.detail}</AlertDescription
|
||||||
{/if}
|
>
|
||||||
</div>
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
{#if agentError}
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle class="text-sm">{agentError.title}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs">{agentError.detail}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
{#if bgpError}
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle class="text-sm">{bgpError.title}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs">{bgpError.detail}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if onApply && speaker.published_revision_id}
|
{#if onApply && speaker.published_revision_id}
|
||||||
<Button variant="outline" size="sm" onclick={() => onApply(speaker)}
|
<Button variant="outline" size="sm" class="w-fit" onclick={() => onApply(speaker)}>
|
||||||
>Apply revision</Button
|
Apply revision
|
||||||
>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div class="space-y-2">
|
<section class="min-w-0 space-y-2">
|
||||||
<h3 class="text-sm font-medium">BGP-сессии (live)</h3>
|
<h3 class="text-sm font-medium">BGP-сессии (live)</h3>
|
||||||
{#if sessions.length === 0}
|
{#if sessions.length === 0}
|
||||||
<p class="text-sm text-muted-foreground">Нет данных или сессий нет.</p>
|
<p class="text-sm text-muted-foreground">Нет данных или сессий нет.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<Table>
|
<div class="rounded-md border">
|
||||||
<TableHeader>
|
<Table class="table-fixed">
|
||||||
<TableRow>
|
<TableHeader>
|
||||||
<TableHead>Имя</TableHead>
|
|
||||||
<TableHead>Состояние</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{#each sessions as sess, i (sess.name + i)}
|
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell class="font-mono text-xs">
|
<TableHead class="w-[65%]">Имя</TableHead>
|
||||||
{sess.name}
|
<TableHead class="w-[35%] text-right">Состояние</TableHead>
|
||||||
{#if sess.neighbor}
|
|
||||||
<div class="text-muted-foreground">{sess.neighbor}</div>
|
|
||||||
{/if}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline">{sess.state}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{#each sessions as sess, i (sess.name + i)}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell class="align-top">
|
||||||
|
<p class="truncate font-mono text-xs" title={sess.name}>{sess.name}</p>
|
||||||
|
{#if sess.neighbor}
|
||||||
|
<p class="truncate text-xs text-muted-foreground" title={sess.neighbor}>
|
||||||
|
{sess.neighbor}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right align-top">
|
||||||
|
<Badge variant="outline" class="shrink-0">{sess.state}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{/each}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div class="space-y-2">
|
<section class="min-w-0 space-y-2">
|
||||||
<h3 class="text-sm font-medium">Пиры на ноде</h3>
|
<h3 class="text-sm font-medium">Пиры на ноде</h3>
|
||||||
{#if relatedPeers.length === 0}
|
{#if relatedPeers.length === 0}
|
||||||
<p class="text-sm text-muted-foreground">Нет привязанных пиров.</p>
|
<p class="text-sm text-muted-foreground">Нет привязанных пиров.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="space-y-2">
|
<ul class="divide-y rounded-md border">
|
||||||
{#each relatedPeers as p (p.id)}
|
{#each relatedPeers as p (p.id)}
|
||||||
<li class="rounded-lg border px-3 py-2 text-sm">
|
<li class="flex min-w-0 items-start justify-between gap-3 px-3 py-2.5 text-sm">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="min-w-0 flex-1">
|
||||||
<span class="font-medium">{p.name?.trim() || p.neighbor}</span>
|
<p class="truncate font-medium" title={p.name?.trim() || p.neighbor}>
|
||||||
<Badge variant="outline">{p.session_state || '—'}</Badge>
|
{p.name?.trim() || p.neighbor}
|
||||||
|
</p>
|
||||||
|
{#if p.session_mismatch}
|
||||||
|
<p class="mt-0.5 text-xs text-warning">
|
||||||
|
Mismatch: сессия не на назначенной ноде
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if p.session_mismatch}
|
<Badge variant="outline" class="shrink-0">{p.session_state || '—'}</Badge>
|
||||||
<p class="mt-1 text-xs text-warning">Mismatch: сессия не на назначенной ноде</p>
|
|
||||||
{/if}
|
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
@@ -42,8 +42,8 @@
|
|||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
<Card
|
<Card
|
||||||
class={cn(
|
class={cn(
|
||||||
'cursor-pointer transition-colors hover:border-primary/35',
|
'flex h-full flex-col transition-colors',
|
||||||
onclick ? 'cursor-pointer' : '',
|
onclick ? 'cursor-pointer hover:border-primary/35' : '',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
role={onclick ? 'button' : undefined}
|
role={onclick ? 'button' : undefined}
|
||||||
@@ -57,31 +57,29 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardHeader class="pb-2">
|
<CardHeader class="pb-2">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<CardTitle class="flex items-center gap-2 truncate text-sm">
|
<CardTitle class="flex items-center gap-2 text-sm">
|
||||||
<Server class="size-4 shrink-0 text-muted-foreground" />
|
<Server class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
<span class="truncate">{label}</span>
|
<span class="truncate" title={label}>{label}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription class="truncate font-mono text-xs">{speaker.role}</CardDescription>
|
<CardDescription class="truncate font-mono text-xs">{speaker.role}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={status.variant}>{status.label}</Badge>
|
<Badge variant={status.variant} class="shrink-0">{status.label}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-2 pt-0 text-sm">
|
<CardContent class="mt-auto pt-0">
|
||||||
<div class="flex justify-between gap-2">
|
<dl class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-2 text-sm">
|
||||||
<span class="text-muted-foreground">BGP</span>
|
<dt class="text-muted-foreground">BGP</dt>
|
||||||
<span class="font-medium tabular-nums">{speakerBgpText(speaker)}</span>
|
<dd class="font-medium tabular-nums">{speakerBgpText(speaker)}</dd>
|
||||||
</div>
|
<dt class="text-muted-foreground">Пиры</dt>
|
||||||
<div class="flex justify-between gap-2">
|
<dd class="tabular-nums">{peerCount}</dd>
|
||||||
<span class="text-muted-foreground">Пиры</span>
|
<dt class="text-muted-foreground">Drift</dt>
|
||||||
<span class="tabular-nums">{peerCount}</span>
|
<dd>
|
||||||
</div>
|
<Badge variant={drift ? 'secondary' : 'outline'} class="text-xs">
|
||||||
<div class="flex justify-between gap-2">
|
{drift ? 'есть' : 'нет'}
|
||||||
<span class="text-muted-foreground">Drift</span>
|
</Badge>
|
||||||
<Badge variant={drift ? 'secondary' : 'outline'} class="text-xs">
|
</dd>
|
||||||
{drift ? 'есть' : 'нет'}
|
</dl>
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -197,6 +197,13 @@ export function collectNetworkIssues(
|
|||||||
message: `Ошибка BGP-опроса: ${speakerLabel(s)}`,
|
message: `Ошибка BGP-опроса: ${speakerLabel(s)}`,
|
||||||
severity: 'warn'
|
severity: 'warn'
|
||||||
});
|
});
|
||||||
|
} else if (s.last_dispatch_error) {
|
||||||
|
const err = formatSpeakerError(s.last_dispatch_error);
|
||||||
|
issues.push({
|
||||||
|
id: `speaker-dispatch-${s.id}`,
|
||||||
|
message: `Dispatch: ${speakerLabel(s)}${err ? ` — ${err.detail.slice(0, 80)}` : ''}`,
|
||||||
|
severity: 'warn'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,3 +233,63 @@ export function writeNetworkAutoRefresh(enabled: boolean): void {
|
|||||||
if (typeof localStorage === 'undefined') return;
|
if (typeof localStorage === 'undefined') return;
|
||||||
localStorage.setItem(NETWORK_AUTO_REFRESH_KEY, enabled ? '1' : '0');
|
localStorage.setItem(NETWORK_AUTO_REFRESH_KEY, enabled ? '1' : '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FormattedSpeakerError = {
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Humanize stored dispatch/agent errors (avoid raw JSON in UI). */
|
||||||
|
export function formatSpeakerError(raw: string | null | undefined): FormattedSpeakerError | null {
|
||||||
|
if (!raw?.trim()) return null;
|
||||||
|
const text = raw.trim();
|
||||||
|
|
||||||
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(jsonMatch[0]) as {
|
||||||
|
detail?: string;
|
||||||
|
title?: string;
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
const detail = String(obj.detail ?? text);
|
||||||
|
if (/403/.test(detail) && /bundle/i.test(detail)) {
|
||||||
|
return {
|
||||||
|
title: 'Dispatch: доступ к бандлу',
|
||||||
|
detail:
|
||||||
|
'Нода не смогла скачать бандл с CP (403). Проверьте node API-ключ (роль node) и EVOBGP_NODE_TOKEN на реплике — см. docs/access.md.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const httpPrefix = text.match(/^HTTP \d+:\s*/)?.[0] ?? '';
|
||||||
|
return {
|
||||||
|
title: obj.title && obj.title !== 'Bad Gateway' ? obj.title : 'Ошибка dispatch',
|
||||||
|
detail: httpPrefix ? `${httpPrefix.trim()} ${detail}`.trim() : detail
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^HTTP \d+:/.test(text)) {
|
||||||
|
return { title: 'Ошибка HTTP', detail: text };
|
||||||
|
}
|
||||||
|
return { title: 'Ошибка', detail: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerDispatchError(s: SpeakerRow): FormattedSpeakerError | null {
|
||||||
|
const liveRev = s.live?.agent_last_applied_revision_id?.trim();
|
||||||
|
const pub = s.published_revision_id?.trim();
|
||||||
|
// CP meta can keep a stale dispatch error after a later successful agent sync.
|
||||||
|
if (s.live?.agent_ok && liveRev && pub && liveRev === pub) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return formatSpeakerError(s.last_dispatch_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerLiveAgentError(s: SpeakerRow): FormattedSpeakerError | null {
|
||||||
|
return formatSpeakerError(s.live?.agent_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerLiveBgpError(s: SpeakerRow): FormattedSpeakerError | null {
|
||||||
|
return formatSpeakerError(s.live?.bgp_poll_error);
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
|
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={cn('grid gap-4', className)}>
|
<div class={cn('grid auto-rows-fr gap-4', className)}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
{#each Array(skeletonCount) as _, i (i)}
|
{#each Array(skeletonCount) as _, i (i)}
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
{@const a = card.accent}
|
{@const a = card.accent}
|
||||||
<Card
|
<Card
|
||||||
class={cn(
|
class={cn(
|
||||||
'overflow-hidden border-l-4 shadow-sm',
|
'flex h-full flex-col overflow-hidden border-l-4 shadow-sm',
|
||||||
card.href ? 'transition-colors hover:border-primary/35' : '',
|
card.href ? 'transition-colors hover:border-primary/35' : '',
|
||||||
a.border,
|
a.border,
|
||||||
a.bg
|
a.bg
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
>{card.value}</CardTitle
|
>{card.value}</CardTitle
|
||||||
>
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-2">
|
<CardContent class="mt-auto space-y-2">
|
||||||
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
|
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
|
||||||
>{card.badge}</Badge
|
>{card.badge}</Badge
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -204,7 +204,7 @@
|
|||||||
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
|
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" class="mt-4">
|
<TabsContent value="overview" class="mt-4 min-w-0">
|
||||||
<NetworkOverviewTab
|
<NetworkOverviewTab
|
||||||
{peers}
|
{peers}
|
||||||
{speakers}
|
{speakers}
|
||||||
|
|||||||
Reference in new issue
Block a user