Compare commits
5
Commits
v1.10.0
...
9639a03bfe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9639a03bfe | ||
|
|
48c10b7436 | ||
|
|
4db6438245 | ||
|
|
fb108ec5ab | ||
|
|
a1ada06a76 |
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']
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -845,6 +845,89 @@ components:
|
|||||||
description: >
|
description: >
|
||||||
Расширяемый объект. Ключи agent_domain, agent_secret (только при создании),
|
Расширяемый объект. Ключи agent_domain, agent_secret (только при создании),
|
||||||
agent_port, node_ipv4, bird_bgp_source_ipv4, bird_bgp_source_ipv6.
|
agent_port, node_ipv4, bird_bgp_source_ipv4, bird_bgp_source_ipv6.
|
||||||
|
live:
|
||||||
|
$ref: "#/components/schemas/SpeakerLiveStatus"
|
||||||
|
description: >
|
||||||
|
При GET /v1/speakers?live=1 — runtime-статус agent и BGP-опроса на ноде.
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
SpeakerLiveStatus:
|
||||||
|
type: object
|
||||||
|
description: Live runtime snapshot for one speaker (GET /v1/speakers?live=1).
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
description: Человекочитаемая метка ноды (agent domain или CP master).
|
||||||
|
agent_ok:
|
||||||
|
type: boolean
|
||||||
|
description: true если agent /v1/agent/health успешен (master — local birdc poll).
|
||||||
|
agent_error:
|
||||||
|
type: string
|
||||||
|
agent_last_sync_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
agent_last_applied_revision_id:
|
||||||
|
type: string
|
||||||
|
bgp_poll_ok:
|
||||||
|
type: boolean
|
||||||
|
description: true если birdc (CP) или GET /v1/agent/bird/protocols (replica) успешен.
|
||||||
|
bgp_poll_error:
|
||||||
|
type: string
|
||||||
|
bgp_sessions_total:
|
||||||
|
type: integer
|
||||||
|
bgp_established:
|
||||||
|
type: integer
|
||||||
|
sessions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/BgpSessionLive"
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
BgpSessionLive:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
neighbor:
|
||||||
|
type: string
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
LiveSpeakerPoll:
|
||||||
|
type: object
|
||||||
|
description: Метаданные опроса одной ноды в GET /v1/peers?live=1.
|
||||||
|
properties:
|
||||||
|
speaker_id:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
ok:
|
||||||
|
type: boolean
|
||||||
|
session_count:
|
||||||
|
type: integer
|
||||||
|
poll_error:
|
||||||
|
type: string
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
BirdLocalStatus:
|
||||||
|
type: object
|
||||||
|
description: Статус локального BIRD на хосте API (GET /v1/bird/status).
|
||||||
|
properties:
|
||||||
|
birdc_configured:
|
||||||
|
type: boolean
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
protocols_excerpt:
|
||||||
|
type: string
|
||||||
|
bgp_sessions_total:
|
||||||
|
type: integer
|
||||||
|
bgp_established:
|
||||||
|
type: integer
|
||||||
|
healthy:
|
||||||
|
type: ["boolean", "null"]
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
BundleSigningPublicKey:
|
BundleSigningPublicKey:
|
||||||
@@ -2216,6 +2299,12 @@ paths:
|
|||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
has_more:
|
has_more:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
live_speaker_poll:
|
||||||
|
type: array
|
||||||
|
description: >
|
||||||
|
При live=1 — результат опроса каждой ноды (CP birdc + agent protocols).
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/LiveSpeakerPoll"
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
post:
|
post:
|
||||||
@@ -2336,6 +2425,14 @@ paths:
|
|||||||
- $ref: "#/components/parameters/TenantId"
|
- $ref: "#/components/parameters/TenantId"
|
||||||
- $ref: "#/components/parameters/Cursor"
|
- $ref: "#/components/parameters/Cursor"
|
||||||
- $ref: "#/components/parameters/Limit"
|
- $ref: "#/components/parameters/Limit"
|
||||||
|
- name: live
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: ["1"]
|
||||||
|
description: >
|
||||||
|
Live-опрос agent /v1/agent/health и BGP protocols на репликах; CP — local birdc.
|
||||||
|
Обогащает каждый item полем `live`.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Успешно.
|
description: Успешно.
|
||||||
@@ -2670,6 +2767,26 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/bird/status:
|
||||||
|
get:
|
||||||
|
tags: [Deploy]
|
||||||
|
summary: Статус локального BIRD на хосте API
|
||||||
|
description: >
|
||||||
|
Опрос birdc через EVOBGP_BIRDC_SOCKET на процессе API (обычно CP master).
|
||||||
|
На репликах без birdc на CP — birdc_configured=false.
|
||||||
|
operationId: getBirdStatus
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Успешно.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/BirdLocalStatus"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/bird/reload:
|
/v1/bird/reload:
|
||||||
post:
|
post:
|
||||||
tags: [Deploy]
|
tags: [Deploy]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -385,9 +385,22 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
speakers := s.store.ListSpeakersForTenant(a.TenantID)
|
speakers := s.store.ListSpeakersForTenant(a.TenantID)
|
||||||
|
fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1")
|
||||||
|
var liveByID map[string]map[string]any
|
||||||
|
if fresh {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
liveByID = s.collectSpeakerLiveStatus(ctx, a.TenantID, true, speakers)
|
||||||
|
}
|
||||||
items := make([]map[string]any, 0, len(speakers))
|
items := make([]map[string]any, 0, len(speakers))
|
||||||
for _, sp := range speakers {
|
for _, sp := range speakers {
|
||||||
items = append(items, speakerJSONFromStore(s.store, sp))
|
row := speakerJSONFromStore(s.store, sp)
|
||||||
|
if liveByID != nil {
|
||||||
|
if live, ok := liveByID[sp.ID]; ok {
|
||||||
|
row["live"] = live
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, row)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"items": items, "next_cursor": nil, "has_more": false,
|
"items": items, "next_cursor": nil, "has_more": false,
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/birdfmt"
|
||||||
|
"evobgp/internal/nodedispatch"
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func countBGPSessions(sessions []birdfmt.BGPSession) (total, established int) {
|
||||||
|
total = len(sessions)
|
||||||
|
for _, s := range sessions {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(s.State), "Established") {
|
||||||
|
established++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, established
|
||||||
|
}
|
||||||
|
|
||||||
|
func speakerLiveStatusJSON(sp *store.Speaker, view speakerBGPLive, health *nodedispatch.AgentHealthResult) map[string]any {
|
||||||
|
total, established := countBGPSessions(view.Sessions)
|
||||||
|
m := map[string]any{
|
||||||
|
"label": view.Label,
|
||||||
|
"bgp_poll_ok": view.Error == "",
|
||||||
|
"bgp_sessions_total": total,
|
||||||
|
"bgp_established": established,
|
||||||
|
}
|
||||||
|
if view.Error != "" {
|
||||||
|
m["bgp_poll_error"] = view.Error
|
||||||
|
}
|
||||||
|
if health != nil {
|
||||||
|
m["agent_ok"] = health.OK
|
||||||
|
if health.Error != "" {
|
||||||
|
m["agent_error"] = health.Error
|
||||||
|
}
|
||||||
|
if health.LastSyncAt != "" {
|
||||||
|
m["agent_last_sync_at"] = health.LastSyncAt
|
||||||
|
}
|
||||||
|
if health.LastAppliedRevisionID != "" {
|
||||||
|
m["agent_last_applied_revision_id"] = health.LastAppliedRevisionID
|
||||||
|
}
|
||||||
|
} else if sp != nil && strings.EqualFold(strings.TrimSpace(sp.Role), "master") {
|
||||||
|
m["agent_ok"] = view.Error == ""
|
||||||
|
if view.Error != "" {
|
||||||
|
m["agent_error"] = view.Error
|
||||||
|
}
|
||||||
|
} else if sp != nil && store.SpeakerNeedsRemoteDispatch(sp.Role, store.ParseSpeakerMeta(sp.MetaJSON)) {
|
||||||
|
m["agent_ok"] = false
|
||||||
|
m["agent_error"] = "agent health not polled"
|
||||||
|
}
|
||||||
|
if len(view.Sessions) > 0 {
|
||||||
|
sess := make([]map[string]any, 0, len(view.Sessions))
|
||||||
|
for _, s := range view.Sessions {
|
||||||
|
row := map[string]any{
|
||||||
|
"name": s.Name,
|
||||||
|
"state": s.State,
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.Neighbor) != "" {
|
||||||
|
row["neighbor"] = s.Neighbor
|
||||||
|
}
|
||||||
|
sess = append(sess, row)
|
||||||
|
}
|
||||||
|
m["sessions"] = sess
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) collectSpeakerLiveStatus(ctx context.Context, tenantID string, fresh bool, speakers []*store.Speaker) map[string]map[string]any {
|
||||||
|
views := s.collectSpeakerBGPLive(ctx, tenantID, fresh)
|
||||||
|
viewByID := make(map[string]speakerBGPLive, len(views))
|
||||||
|
for _, v := range views {
|
||||||
|
if v.SpeakerID != "" {
|
||||||
|
viewByID[v.SpeakerID] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := nodedispatch.Options{Timeout: 8 * time.Second}
|
||||||
|
type healthWrap struct {
|
||||||
|
id string
|
||||||
|
h nodedispatch.AgentHealthResult
|
||||||
|
}
|
||||||
|
healthCh := make(chan healthWrap, len(speakers))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, sp := range speakers {
|
||||||
|
if sp == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||||
|
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(speaker *store.Speaker) {
|
||||||
|
defer wg.Done()
|
||||||
|
healthCh <- healthWrap{
|
||||||
|
id: speaker.ID,
|
||||||
|
h: nodedispatch.FetchAgentHealth(ctx, speaker, opts),
|
||||||
|
}
|
||||||
|
}(sp)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(healthCh)
|
||||||
|
healthByID := make(map[string]nodedispatch.AgentHealthResult, len(speakers))
|
||||||
|
for hw := range healthCh {
|
||||||
|
healthByID[hw.id] = hw.h
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(map[string]map[string]any, len(speakers))
|
||||||
|
for _, sp := range speakers {
|
||||||
|
if sp == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
view, ok := viewByID[sp.ID]
|
||||||
|
if !ok {
|
||||||
|
view = speakerBGPLive{SpeakerID: sp.ID, Label: speakerDisplayLabel(sp)}
|
||||||
|
}
|
||||||
|
var hp *nodedispatch.AgentHealthResult
|
||||||
|
if h, ok := healthByID[sp.ID]; ok {
|
||||||
|
hCopy := h
|
||||||
|
hp = &hCopy
|
||||||
|
}
|
||||||
|
out[sp.ID] = speakerLiveStatusJSON(sp, view, hp)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/birdfmt"
|
||||||
|
"evobgp/internal/nodedispatch"
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCountBGPSessions(t *testing.T) {
|
||||||
|
total, est := countBGPSessions([]birdfmt.BGPSession{
|
||||||
|
{Name: "p1", State: "Established"},
|
||||||
|
{Name: "p2", State: "Idle"},
|
||||||
|
{Name: "p3", State: "established"},
|
||||||
|
})
|
||||||
|
if total != 3 || est != 2 {
|
||||||
|
t.Fatalf("total=%d established=%d", total, est)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpeakerLiveStatusJSON_masterUsesBirdPoll(t *testing.T) {
|
||||||
|
sp := &store.Speaker{ID: "m1", Role: "master", Endpoint: "https://cp.example"}
|
||||||
|
view := speakerBGPLive{
|
||||||
|
SpeakerID: "m1",
|
||||||
|
Label: "CP · cp.example",
|
||||||
|
Sessions: []birdfmt.BGPSession{
|
||||||
|
{Name: "evobgp_peer_x", State: "Established"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
m := speakerLiveStatusJSON(sp, view, nil)
|
||||||
|
if m["agent_ok"] != true || m["bgp_established"] != 1 || m["bgp_sessions_total"] != 1 {
|
||||||
|
t.Fatalf("got %#v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpeakerLiveStatusJSON_replicaWithHealth(t *testing.T) {
|
||||||
|
sp := &store.Speaker{
|
||||||
|
ID: "r1",
|
||||||
|
Role: "replica",
|
||||||
|
Endpoint: "https://node.example",
|
||||||
|
MetaJSON: `{"agent_domain":"node.example","agent_secret":"s"}`,
|
||||||
|
}
|
||||||
|
view := speakerBGPLive{
|
||||||
|
SpeakerID: "r1",
|
||||||
|
Label: "node.example",
|
||||||
|
Sessions: []birdfmt.BGPSession{{Name: "p", State: "Idle"}},
|
||||||
|
}
|
||||||
|
health := &nodedispatch.AgentHealthResult{
|
||||||
|
OK: true,
|
||||||
|
LastSyncAt: "2026-05-21T12:00:00Z",
|
||||||
|
LastAppliedRevisionID: "rev-1",
|
||||||
|
}
|
||||||
|
m := speakerLiveStatusJSON(sp, view, health)
|
||||||
|
if m["agent_ok"] != true || m["agent_last_sync_at"] != "2026-05-21T12:00:00Z" {
|
||||||
|
t.Fatalf("got %#v", m)
|
||||||
|
}
|
||||||
|
if m["bgp_established"] != 0 || m["bgp_poll_ok"] != true {
|
||||||
|
t.Fatalf("bgp fields: %#v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpeakerLiveStatusJSON_pollError(t *testing.T) {
|
||||||
|
sp := &store.Speaker{ID: "r1", Role: "replica", MetaJSON: `{"agent_domain":"x.example"}`}
|
||||||
|
view := speakerBGPLive{SpeakerID: "r1", Label: "x.example", Error: "HTTP 503"}
|
||||||
|
health := &nodedispatch.AgentHealthResult{OK: false, Error: "timeout"}
|
||||||
|
m := speakerLiveStatusJSON(sp, view, health)
|
||||||
|
if m["bgp_poll_ok"] != false || m["bgp_poll_error"] != "HTTP 503" {
|
||||||
|
t.Fatalf("got %#v", m)
|
||||||
|
}
|
||||||
|
if m["agent_ok"] != false {
|
||||||
|
t.Fatalf("agent_ok: %#v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,33 +156,14 @@ func WakeReplicas(ctx context.Context, st store.Backend, tenantID, revisionID st
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckHealth GETs /v1/agent/health for UI Connected/Offline status.
|
// CheckHealth is deprecated; use FetchAgentHealth.
|
||||||
func CheckHealth(ctx context.Context, sp *store.Speaker, opts Options) (ok bool, detail string) {
|
func CheckHealth(ctx context.Context, sp *store.Speaker, opts Options) (ok bool, detail string) {
|
||||||
if sp == nil {
|
res := FetchAgentHealth(ctx, sp, opts)
|
||||||
return false, "nil speaker"
|
if res.OK {
|
||||||
}
|
|
||||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
|
||||||
url := store.AgentHealthURL(meta)
|
|
||||||
if url == "" {
|
|
||||||
return false, "agent_domain not configured"
|
|
||||||
}
|
|
||||||
secret := strings.TrimSpace(meta.AgentSecret)
|
|
||||||
if secret == "" {
|
|
||||||
return false, "agent_secret missing"
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return false, err.Error()
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+secret)
|
|
||||||
resp, err := opts.client().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return false, err.Error()
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
||||||
return true, "connected"
|
return true, "connected"
|
||||||
}
|
}
|
||||||
b, _ := io.ReadAll(resp.Body)
|
if res.Error != "" {
|
||||||
return false, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
return false, res.Error
|
||||||
|
}
|
||||||
|
return false, "agent unhealthy"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package nodedispatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentHealthResult is the parsed outcome of GET /v1/agent/health on a replica.
|
||||||
|
type AgentHealthResult struct {
|
||||||
|
OK bool
|
||||||
|
Error string
|
||||||
|
LastAppliedRevisionID string
|
||||||
|
LastSyncAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAgentHealth GETs /v1/agent/health for UI Connected/Offline status.
|
||||||
|
func FetchAgentHealth(ctx context.Context, sp *store.Speaker, opts Options) AgentHealthResult {
|
||||||
|
if sp == nil {
|
||||||
|
return AgentHealthResult{Error: "nil speaker"}
|
||||||
|
}
|
||||||
|
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||||
|
url := store.AgentHealthURL(meta)
|
||||||
|
if url == "" {
|
||||||
|
return AgentHealthResult{Error: "agent_domain not configured"}
|
||||||
|
}
|
||||||
|
secret := strings.TrimSpace(meta.AgentSecret)
|
||||||
|
if secret == "" {
|
||||||
|
return AgentHealthResult{Error: "agent_secret missing"}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return AgentHealthResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+secret)
|
||||||
|
resp, err := opts.client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return AgentHealthResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return AgentHealthResult{
|
||||||
|
Error: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
LastAppliedRevisionID string `json:"last_applied_revision_id"`
|
||||||
|
LastSyncAt string `json:"last_sync_at"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
return AgentHealthResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
res := AgentHealthResult{
|
||||||
|
OK: out.OK,
|
||||||
|
LastAppliedRevisionID: strings.TrimSpace(out.LastAppliedRevisionID),
|
||||||
|
LastSyncAt: strings.TrimSpace(out.LastSyncAt),
|
||||||
|
}
|
||||||
|
if !res.OK {
|
||||||
|
res.Error = "agent reported ok=false"
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -173,7 +173,14 @@ export type PeerRow = {
|
|||||||
established_on_speakers?: PeerSessionOnSpeaker[];
|
established_on_speakers?: PeerSessionOnSpeaker[];
|
||||||
session_mismatch?: boolean;
|
session_mismatch?: boolean;
|
||||||
};
|
};
|
||||||
export type PeersResponse = Page<PeerRow>;
|
export type LiveSpeakerPoll = {
|
||||||
|
speaker_id: string;
|
||||||
|
label: string;
|
||||||
|
ok: boolean;
|
||||||
|
session_count: number;
|
||||||
|
poll_error?: string;
|
||||||
|
};
|
||||||
|
export type PeersResponse = Page<PeerRow> & { live_speaker_poll?: LiveSpeakerPoll[] };
|
||||||
export type BgpPeerCreate = {
|
export type BgpPeerCreate = {
|
||||||
name?: string;
|
name?: string;
|
||||||
neighbor: string;
|
neighbor: string;
|
||||||
@@ -184,6 +191,25 @@ export type BgpPeerCreate = {
|
|||||||
export type BgpPeerPatch = Partial<BgpPeerCreate>;
|
export type BgpPeerPatch = Partial<BgpPeerCreate>;
|
||||||
|
|
||||||
// ---- Speakers ----
|
// ---- Speakers ----
|
||||||
|
export type BgpSessionLive = {
|
||||||
|
name: string;
|
||||||
|
neighbor?: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpeakerLiveStatus = {
|
||||||
|
label?: string;
|
||||||
|
agent_ok?: boolean;
|
||||||
|
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;
|
||||||
|
sessions?: BgpSessionLive[];
|
||||||
|
};
|
||||||
|
|
||||||
export type SpeakerRow = {
|
export type SpeakerRow = {
|
||||||
id: string;
|
id: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -200,6 +226,7 @@ export type SpeakerRow = {
|
|||||||
last_dispatch_error?: string | null;
|
last_dispatch_error?: string | null;
|
||||||
meta_json?: Record<string, unknown>;
|
meta_json?: Record<string, unknown>;
|
||||||
agent_secret?: string;
|
agent_secret?: string;
|
||||||
|
live?: SpeakerLiveStatus;
|
||||||
};
|
};
|
||||||
export type SpeakersResponse = Page<SpeakerRow>;
|
export type SpeakersResponse = Page<SpeakerRow>;
|
||||||
export type BgpSpeakerCreate = {
|
export type BgpSpeakerCreate = {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import { Switch } from '$lib/ui/core/switch/index.js';
|
||||||
|
import { readNetworkAutoRefresh, writeNetworkAutoRefresh } from '$lib/network/network-metrics.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
enabled?: boolean;
|
||||||
|
onchange?: (enabled: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
enabled = $bindable(readNetworkAutoRefresh()),
|
||||||
|
onchange,
|
||||||
|
disabled = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function onToggle(checked: boolean) {
|
||||||
|
enabled = checked;
|
||||||
|
writeNetworkAutoRefresh(checked);
|
||||||
|
onchange?.(checked);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Switch id="network-auto-refresh" bind:checked={enabled} onCheckedChange={onToggle} {disabled} />
|
||||||
|
<Label for="network-auto-refresh" class="cursor-pointer text-sm text-muted-foreground">
|
||||||
|
Авто (~15 с)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { BirdStatus, PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
aggregateNetworkMetrics,
|
||||||
|
collectNetworkIssues,
|
||||||
|
deriveNetworkOverallStatus,
|
||||||
|
networkOverallStatusHint,
|
||||||
|
networkOverallStatusLabel
|
||||||
|
} from '$lib/network/network-metrics.js';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||||
|
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
|
||||||
|
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||||
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
|
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||||
|
import Share2 from '@lucide/svelte/icons/share-2';
|
||||||
|
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
|
||||||
|
import Server from '@lucide/svelte/icons/server';
|
||||||
|
import GitBranch from '@lucide/svelte/icons/git-branch';
|
||||||
|
import Activity from '@lucide/svelte/icons/activity';
|
||||||
|
import Bird from '@lucide/svelte/icons/bird';
|
||||||
|
import Gauge from '@lucide/svelte/icons/gauge';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
peers: PeerRow[];
|
||||||
|
speakers: SpeakerRow[];
|
||||||
|
bird: BirdStatus | null;
|
||||||
|
loading?: boolean;
|
||||||
|
initialLoading?: boolean;
|
||||||
|
onSpeakerSelect?: (speaker: SpeakerRow) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
peers,
|
||||||
|
speakers,
|
||||||
|
bird,
|
||||||
|
loading = false,
|
||||||
|
initialLoading = false,
|
||||||
|
onSpeakerSelect
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const statAccents = [
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-3',
|
||||||
|
bg: 'bg-chart-3/5',
|
||||||
|
iconBg: 'bg-chart-3/15',
|
||||||
|
iconText: 'text-chart-3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-2',
|
||||||
|
bg: 'bg-chart-2/5',
|
||||||
|
iconBg: 'bg-chart-2/15',
|
||||||
|
iconText: 'text-chart-2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-4',
|
||||||
|
bg: 'bg-chart-4/5',
|
||||||
|
iconBg: 'bg-chart-4/15',
|
||||||
|
iconText: 'text-chart-4'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-warning',
|
||||||
|
bg: 'bg-warning/5',
|
||||||
|
iconBg: 'bg-warning/15',
|
||||||
|
iconText: 'text-warning'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-destructive',
|
||||||
|
bg: 'bg-destructive/5',
|
||||||
|
iconBg: 'bg-destructive/15',
|
||||||
|
iconText: 'text-destructive'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-info',
|
||||||
|
bg: 'bg-info/10',
|
||||||
|
iconBg: 'bg-info/15',
|
||||||
|
iconText: 'text-info'
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const metrics = $derived(aggregateNetworkMetrics(peers, speakers, bird));
|
||||||
|
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
|
||||||
|
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
|
||||||
|
const issues = $derived(collectNetworkIssues(peers, speakers, 5));
|
||||||
|
|
||||||
|
const birdText = $derived.by(() => {
|
||||||
|
if (!bird?.birdc_configured) return '—';
|
||||||
|
if (bird.error) return '—';
|
||||||
|
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const kpiCards = $derived.by(() => [
|
||||||
|
{
|
||||||
|
id: 'peers',
|
||||||
|
label: 'BGP-пиры',
|
||||||
|
value: initialLoading ? '—' : String(metrics.peersTotal),
|
||||||
|
description: initialLoading
|
||||||
|
? ''
|
||||||
|
: `${metrics.peersEstablished} Established из ${metrics.peersEnabled} вкл.`,
|
||||||
|
icon: Share2,
|
||||||
|
accent: statAccents[0],
|
||||||
|
badge: metrics.peersMismatch > 0 ? `mismatch ${metrics.peersMismatch}` : 'peers',
|
||||||
|
badgeClass:
|
||||||
|
metrics.peersMismatch > 0 ? 'border-warning/30 bg-warning/15 text-warning' : undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'established',
|
||||||
|
label: 'Активные сессии',
|
||||||
|
value: initialLoading ? '—' : String(metrics.peersEstablished),
|
||||||
|
description: 'Established среди включённых пиров',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
accent: statAccents[1],
|
||||||
|
badge: metrics.peersEstablished > 0 ? 'Established' : 'нет сессий',
|
||||||
|
badgeClass:
|
||||||
|
metrics.peersEstablished > 0 ? 'border-success/30 bg-success/15 text-success' : undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'speakers',
|
||||||
|
label: 'Спикеры online',
|
||||||
|
value: initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`,
|
||||||
|
description: 'agent + BGP poll',
|
||||||
|
icon: Server,
|
||||||
|
accent: statAccents[2],
|
||||||
|
badge: metrics.speakersOnline === metrics.speakersTotal ? 'все online' : 'есть offline',
|
||||||
|
badgeClass:
|
||||||
|
metrics.speakersOnline === metrics.speakersTotal
|
||||||
|
? 'border-success/30 bg-success/15 text-success'
|
||||||
|
: 'border-warning/30 bg-warning/15 text-warning'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drift',
|
||||||
|
label: 'Drift',
|
||||||
|
value: initialLoading ? '—' : String(metrics.speakersDrift),
|
||||||
|
description: 'applied ≠ published',
|
||||||
|
icon: GitBranch,
|
||||||
|
accent: statAccents[3],
|
||||||
|
badge: metrics.speakersDrift > 0 ? 'требует apply' : 'синхронно',
|
||||||
|
badgeVariant: metrics.speakersDrift > 0 ? ('secondary' as const) : ('outline' as const)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'poll-errors',
|
||||||
|
label: 'Ошибки опроса',
|
||||||
|
value: initialLoading ? '—' : String(metrics.pollErrors),
|
||||||
|
description: 'agent или BGP poll',
|
||||||
|
icon: Activity,
|
||||||
|
accent: statAccents[4],
|
||||||
|
badge: metrics.pollErrors > 0 ? 'ошибки' : 'ok',
|
||||||
|
badgeClass:
|
||||||
|
metrics.pollErrors === 0 ? 'border-success/30 bg-success/15 text-success' : undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cp-bird',
|
||||||
|
label: 'BGP на CP',
|
||||||
|
value: initialLoading ? '—' : birdText,
|
||||||
|
description: bird?.birdc_configured
|
||||||
|
? 'Established / total на API-хосте'
|
||||||
|
: (bird?.message ?? 'birdc не настроен'),
|
||||||
|
icon: Bird,
|
||||||
|
accent: statAccents[5],
|
||||||
|
badge: !bird?.birdc_configured ? 'N/A' : bird?.healthy ? 'В норме' : 'Деградация',
|
||||||
|
href: '/monitoring' as const
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-col gap-6">
|
||||||
|
{#if !initialLoading && !loading}
|
||||||
|
{#if overallStatus === 'ok'}
|
||||||
|
<Alert class="border-success/30 bg-success/5">
|
||||||
|
<CheckCircle class="text-success" />
|
||||||
|
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
|
<AlertDescription>{overallHint}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{:else if overallStatus === 'warn'}
|
||||||
|
<Alert class="border-warning/30 bg-warning/5">
|
||||||
|
<AlertTriangle class="text-warning" />
|
||||||
|
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{overallHint}
|
||||||
|
{#if issues.length > 0}
|
||||||
|
<ul class="mt-2 list-inside list-disc text-sm">
|
||||||
|
{#each issues as issue (issue.id)}
|
||||||
|
<li>{issue.message}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{:else}
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<XCircle />
|
||||||
|
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{overallHint}
|
||||||
|
{#if issues.length > 0}
|
||||||
|
<ul class="mt-2 list-inside list-disc text-sm">
|
||||||
|
{#each issues as issue (issue.id)}
|
||||||
|
<li>{issue.message}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<KpiMetricsGrid
|
||||||
|
cards={kpiCards}
|
||||||
|
loading={initialLoading || loading}
|
||||||
|
skeletonCount={6}
|
||||||
|
class="sm:grid-cols-2 xl:grid-cols-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="flex min-w-0 flex-col gap-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 class="text-base font-semibold">Ноды</h2>
|
||||||
|
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
|
||||||
|
<Gauge class="size-3.5" />
|
||||||
|
Мониторинг 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>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
peersForSpeaker,
|
||||||
|
speakerDisplayStatus,
|
||||||
|
speakerDispatchError,
|
||||||
|
speakerHasDrift,
|
||||||
|
speakerLabel,
|
||||||
|
speakerLiveAgentError,
|
||||||
|
speakerLiveBgpError
|
||||||
|
} 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 { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Separator } from '$lib/ui/core/separator/index.js';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle
|
||||||
|
} from '$lib/ui/core/sheet/index.js';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from '$lib/ui/core/table/index.js';
|
||||||
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
speaker: SpeakerRow | null;
|
||||||
|
peers: PeerRow[];
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
onApply?: (speaker: SpeakerRow) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { speaker, peers, open = $bindable(false), onOpenChange, onApply }: Props = $props();
|
||||||
|
|
||||||
|
const status = $derived(speaker ? speakerDisplayStatus(speaker) : null);
|
||||||
|
const label = $derived(speaker ? speakerLabel(speaker) : '');
|
||||||
|
const relatedPeers = $derived(speaker ? peersForSpeaker(peers, speaker.id) : []);
|
||||||
|
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 {
|
||||||
|
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
|
||||||
|
const app = s.last_applied_revision_id?.slice(0, 8) ?? '—';
|
||||||
|
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(() => {
|
||||||
|
onOpenChange?.(open);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sheet bind:open>
|
||||||
|
<SheetContent class="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-md">
|
||||||
|
{#if speaker}
|
||||||
|
<div class="flex min-w-0 flex-col gap-4 px-4 pt-4 pb-6">
|
||||||
|
<SheetHeader class="space-y-1 pr-8 text-left">
|
||||||
|
<SheetTitle class="truncate">{label}</SheetTitle>
|
||||||
|
<SheetDescription class="truncate">
|
||||||
|
{speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
{#if status}
|
||||||
|
<Badge variant={status.variant}>{status.label}</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if speakerHasDrift(speaker)}
|
||||||
|
<Badge variant="secondary">Drift</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="grid grid-cols-[minmax(0,9rem)_1fr] gap-x-3 gap-y-2 text-sm">
|
||||||
|
<dt class="text-muted-foreground">BGP Established</dt>
|
||||||
|
<dd class="text-right font-medium tabular-nums">
|
||||||
|
{speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
|
||||||
|
</dd>
|
||||||
|
{#if speaker.live?.agent_last_sync_at}
|
||||||
|
<dt class="text-muted-foreground">Последний sync</dt>
|
||||||
|
<dd class="text-right text-xs tabular-nums">
|
||||||
|
{formatSyncAt(speaker.live.agent_last_sync_at)}
|
||||||
|
</dd>
|
||||||
|
{/if}
|
||||||
|
<dt class="text-muted-foreground">Drift (app / pub)</dt>
|
||||||
|
<dd class="truncate text-right font-mono text-xs">{driftLabel(speaker)}</dd>
|
||||||
|
{#if speaker.last_dispatch_at}
|
||||||
|
<dt class="text-muted-foreground">Dispatch</dt>
|
||||||
|
<dd class="text-right text-xs tabular-nums">
|
||||||
|
{formatSyncAt(speaker.last_dispatch_at)}
|
||||||
|
</dd>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{#if dispatchError}
|
||||||
|
<Alert class="border-warning/30 bg-warning/5">
|
||||||
|
<AlertTriangle class="text-warning" />
|
||||||
|
<AlertTitle class="text-sm">{dispatchError.title}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs leading-relaxed"
|
||||||
|
>{dispatchError.detail}</AlertDescription
|
||||||
|
>
|
||||||
|
</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}
|
||||||
|
<Button variant="outline" size="sm" class="w-fit" onclick={() => onApply(speaker)}>
|
||||||
|
Apply revision
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<section class="min-w-0 space-y-2">
|
||||||
|
<h3 class="text-sm font-medium">BGP-сессии (live)</h3>
|
||||||
|
{#if sessions.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">Нет данных или сессий нет.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-md border">
|
||||||
|
<Table class="table-fixed">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead class="w-[65%]">Имя</TableHead>
|
||||||
|
<TableHead class="w-[35%] text-right">Состояние</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{#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}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<section class="min-w-0 space-y-2">
|
||||||
|
<h3 class="text-sm font-medium">Пиры на ноде</h3>
|
||||||
|
{#if relatedPeers.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">Нет привязанных пиров.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="divide-y rounded-md border">
|
||||||
|
{#each relatedPeers as p (p.id)}
|
||||||
|
<li class="flex min-w-0 items-start justify-between gap-3 px-3 py-2.5 text-sm">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate font-medium" title={p.name?.trim() || p.neighbor}>
|
||||||
|
{p.name?.trim() || p.neighbor}
|
||||||
|
</p>
|
||||||
|
{#if p.session_mismatch}
|
||||||
|
<p class="mt-0.5 text-xs text-warning">
|
||||||
|
Mismatch: сессия не на назначенной ноде
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" class="shrink-0">{p.session_state || '—'}</Badge>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
speakerBgpText,
|
||||||
|
speakerDisplayStatus,
|
||||||
|
speakerHasDrift,
|
||||||
|
speakerLabel
|
||||||
|
} from '$lib/network/network-metrics.js';
|
||||||
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import Server from '@lucide/svelte/icons/server';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
speaker: SpeakerRow;
|
||||||
|
peers?: PeerRow[];
|
||||||
|
onclick?: () => void;
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { speaker, peers = [], onclick, class: className }: Props = $props();
|
||||||
|
|
||||||
|
const status = $derived(speakerDisplayStatus(speaker));
|
||||||
|
const label = $derived(speakerLabel(speaker));
|
||||||
|
const drift = $derived(speakerHasDrift(speaker));
|
||||||
|
const peerCount = $derived(
|
||||||
|
peers.filter(
|
||||||
|
(p) =>
|
||||||
|
p.bgp_speaker_id === speaker.id ||
|
||||||
|
p.bgp_speaker_id === null ||
|
||||||
|
p.bgp_speaker_id === undefined
|
||||||
|
).length
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<Card
|
||||||
|
class={cn(
|
||||||
|
'flex h-full flex-col transition-colors',
|
||||||
|
onclick ? 'cursor-pointer hover:border-primary/35' : '',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role={onclick ? 'button' : undefined}
|
||||||
|
tabindex={onclick ? 0 : undefined}
|
||||||
|
{onclick}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (onclick && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault();
|
||||||
|
onclick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="flex items-center gap-2 text-sm">
|
||||||
|
<Server class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<span class="truncate" title={label}>{label}</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription class="truncate font-mono text-xs">{speaker.role}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant={status.variant} class="shrink-0">{status.label}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="mt-auto pt-0">
|
||||||
|
<dl class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-2 text-sm">
|
||||||
|
<dt class="text-muted-foreground">BGP</dt>
|
||||||
|
<dd class="font-medium tabular-nums">{speakerBgpText(speaker)}</dd>
|
||||||
|
<dt class="text-muted-foreground">Пиры</dt>
|
||||||
|
<dd class="tabular-nums">{peerCount}</dd>
|
||||||
|
<dt class="text-muted-foreground">Drift</dt>
|
||||||
|
<dd>
|
||||||
|
<Badge variant={drift ? 'secondary' : 'outline'} class="text-xs">
|
||||||
|
{drift ? 'есть' : 'нет'}
|
||||||
|
</Badge>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||||
import type { SpeakerRow, BgpSpeakerCreate, BundleSigningPublicKey } from '$lib/api/types.js';
|
import type { SpeakerRow, BgpSpeakerCreate, BundleSigningPublicKey } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
speakerBgpText,
|
||||||
|
speakerDisplayStatus,
|
||||||
|
speakerHasDrift
|
||||||
|
} from '$lib/network/network-metrics.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 {
|
import {
|
||||||
@@ -29,6 +34,7 @@
|
|||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import Copy from '@lucide/svelte/icons/copy';
|
import Copy from '@lucide/svelte/icons/copy';
|
||||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
import Eye from '@lucide/svelte/icons/eye';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
items: SpeakerRow[];
|
items: SpeakerRow[];
|
||||||
@@ -36,9 +42,17 @@
|
|||||||
initialLoading?: boolean;
|
initialLoading?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
onRefresh: () => void | Promise<void>;
|
onRefresh: () => void | Promise<void>;
|
||||||
|
onSpeakerSelect?: (speaker: SpeakerRow) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
|
let {
|
||||||
|
items,
|
||||||
|
loading = false,
|
||||||
|
initialLoading = false,
|
||||||
|
error = null,
|
||||||
|
onRefresh,
|
||||||
|
onSpeakerSelect
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
type SpeakerForm = {
|
type SpeakerForm = {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -72,6 +86,8 @@
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ id: 'status', label: 'Статус' },
|
{ id: 'status', label: 'Статус' },
|
||||||
|
{ id: 'live_agent', label: 'Agent' },
|
||||||
|
{ id: 'bgp', label: 'BGP' },
|
||||||
{
|
{
|
||||||
id: 'agent_domain',
|
id: 'agent_domain',
|
||||||
label: 'Agent domain',
|
label: 'Agent domain',
|
||||||
@@ -80,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
|
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
|
||||||
{ id: 'drift', label: 'Drift' },
|
{ id: 'drift', label: 'Drift' },
|
||||||
{ id: 'actions', label: '', class: 'w-40' }
|
{ id: 'actions', label: '', class: 'w-44' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function parseIpv4FromEndpoint(ep: string): string {
|
function parseIpv4FromEndpoint(ep: string): string {
|
||||||
@@ -152,16 +168,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusVariant(s: SpeakerRow): 'default' | 'secondary' | 'destructive' | 'outline' {
|
function statusVariant(s: SpeakerRow): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
if (s.sync_status === 'synced' || s.dispatch_status === 'ok') return 'default';
|
return speakerDisplayStatus(s).variant;
|
||||||
if (s.sync_status === 'error' || s.dispatch_status === 'error') return 'destructive';
|
|
||||||
return 'outline';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(s: SpeakerRow): string {
|
function statusLabel(s: SpeakerRow): string {
|
||||||
if (s.sync_status === 'synced') return 'Connected';
|
return speakerDisplayStatus(s).label;
|
||||||
if (s.sync_status === 'error' || s.last_dispatch_error) return 'Offline';
|
}
|
||||||
if (s.dispatch_status === 'ok') return 'Synced';
|
|
||||||
return 'Unknown';
|
function liveAgentLabel(s: SpeakerRow): string {
|
||||||
|
if (!s.live) return '—';
|
||||||
|
if (s.live.agent_ok === true) return 'OK';
|
||||||
|
return s.live.agent_error ? 'Error' : 'Offline';
|
||||||
}
|
}
|
||||||
|
|
||||||
function driftLabel(s: SpeakerRow): string {
|
function driftLabel(s: SpeakerRow): string {
|
||||||
@@ -333,16 +350,29 @@ CF_DNS_API_TOKEN=<cloudflare token>
|
|||||||
{#snippet cell({ row: s, column })}
|
{#snippet cell({ row: s, column })}
|
||||||
{#if column.id === 'status'}
|
{#if column.id === 'status'}
|
||||||
<Badge variant={statusVariant(s)}>{statusLabel(s)}</Badge>
|
<Badge variant={statusVariant(s)}>{statusLabel(s)}</Badge>
|
||||||
|
{:else if column.id === 'live_agent'}
|
||||||
|
<Badge variant={s.live?.agent_ok ? 'outline' : 'destructive'}>{liveAgentLabel(s)}</Badge>
|
||||||
|
{:else if column.id === 'bgp'}
|
||||||
|
<span class="font-mono text-xs tabular-nums">{speakerBgpText(s)}</span>
|
||||||
{:else if column.id === 'agent_domain'}
|
{:else if column.id === 'agent_domain'}
|
||||||
<span class="font-mono text-sm">{s.agent_domain ?? s.endpoint}</span>
|
<span class="font-mono text-sm">{s.agent_domain ?? s.endpoint}</span>
|
||||||
{:else if column.id === 'role'}
|
{:else if column.id === 'role'}
|
||||||
<Badge variant="outline">{s.role}</Badge>
|
<Badge variant="outline">{s.role}</Badge>
|
||||||
{:else if column.id === 'drift'}
|
{:else if column.id === 'drift'}
|
||||||
<span class="font-mono text-xs text-muted-foreground" title="applied / published">
|
<span
|
||||||
|
class="font-mono text-xs text-muted-foreground"
|
||||||
|
title="applied / published"
|
||||||
|
class:text-warning={speakerHasDrift(s)}
|
||||||
|
>
|
||||||
{driftLabel(s)}
|
{driftLabel(s)}
|
||||||
</span>
|
</span>
|
||||||
{:else if column.id === 'actions'}
|
{:else if column.id === 'actions'}
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#if onSpeakerSelect}
|
||||||
|
<Button variant="outline" size="xs" title="Детали" onclick={() => onSpeakerSelect(s)}>
|
||||||
|
<Eye class="size-3" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
<Button variant="outline" size="xs" title="Copy compose" onclick={() => openCompose(s)}>
|
<Button variant="outline" size="xs" title="Copy compose" onclick={() => openCompose(s)}>
|
||||||
<Copy class="size-3" />
|
<Copy class="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
aggregateNetworkMetrics,
|
||||||
|
collectNetworkIssues,
|
||||||
|
deriveNetworkOverallStatus,
|
||||||
|
networkOverallStatusHint,
|
||||||
|
networkOverallStatusLabel
|
||||||
|
} from '$lib/network/network-metrics.js';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||||
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
|
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||||
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
|
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
peers: PeerRow[];
|
||||||
|
speakers: SpeakerRow[];
|
||||||
|
loading?: boolean;
|
||||||
|
initialLoading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { peers, speakers, loading = false, initialLoading = false, error = null }: Props = $props();
|
||||||
|
|
||||||
|
const metrics = $derived(aggregateNetworkMetrics(peers, speakers));
|
||||||
|
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
|
||||||
|
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
|
||||||
|
const issues = $derived(collectNetworkIssues(peers, speakers, 3));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<NetworkIcon class="size-4" />
|
||||||
|
Сеть (BGP)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Live-статус пиров и спикеров</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" href={resolve('/network?tab=overview')}>
|
||||||
|
Подробнее
|
||||||
|
<ArrowRight class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3 p-4 pt-4">
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-destructive">{error}</p>
|
||||||
|
{:else if initialLoading || loading}
|
||||||
|
<p class="text-sm text-muted-foreground">Загрузка live-метрик…</p>
|
||||||
|
{:else if overallStatus === 'ok'}
|
||||||
|
<Alert class="border-success/30 bg-success/5 py-3">
|
||||||
|
<CheckCircle class="text-success" />
|
||||||
|
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs">{overallHint}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{:else if overallStatus === 'warn'}
|
||||||
|
<Alert class="border-warning/30 bg-warning/5 py-3">
|
||||||
|
<AlertTriangle class="text-warning" />
|
||||||
|
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs">
|
||||||
|
{overallHint}
|
||||||
|
{#if issues.length > 0}
|
||||||
|
<ul class="mt-2 list-inside list-disc">
|
||||||
|
{#each issues as issue (issue.id)}
|
||||||
|
<li>{issue.message}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{:else}
|
||||||
|
<Alert variant="destructive" class="py-3">
|
||||||
|
<XCircle />
|
||||||
|
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs">
|
||||||
|
{overallHint}
|
||||||
|
{#if issues.length > 0}
|
||||||
|
<ul class="mt-2 list-inside list-disc">
|
||||||
|
{#each issues as issue (issue.id)}
|
||||||
|
<li>{issue.message}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">Пиры Established</p>
|
||||||
|
<p class="text-xl font-bold tabular-nums">
|
||||||
|
{initialLoading ? '—' : `${metrics.peersEstablished}/${metrics.peersEnabled}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">Спикеры online</p>
|
||||||
|
<p class="text-xl font-bold tabular-nums">
|
||||||
|
{initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">Drift</p>
|
||||||
|
<p class="text-xl font-bold tabular-nums">{initialLoading ? '—' : metrics.speakersDrift}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import type { BirdStatus, PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||||
|
|
||||||
|
export type NetworkOverallStatus = 'ok' | 'warn' | 'error';
|
||||||
|
|
||||||
|
export type NetworkMetrics = {
|
||||||
|
peersTotal: number;
|
||||||
|
peersEnabled: number;
|
||||||
|
peersEstablished: number;
|
||||||
|
peersMismatch: number;
|
||||||
|
speakersTotal: number;
|
||||||
|
speakersOnline: number;
|
||||||
|
speakersRemote: number;
|
||||||
|
speakersRemoteOnline: number;
|
||||||
|
speakersDrift: number;
|
||||||
|
pollErrors: number;
|
||||||
|
hasLiveData: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpeakerStatusBadge = {
|
||||||
|
label: string;
|
||||||
|
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NetworkIssue = {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
severity: 'warn' | 'error';
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRemoteSpeaker(s: SpeakerRow): boolean {
|
||||||
|
const role = (s.role ?? '').toLowerCase();
|
||||||
|
return role !== 'master' && Boolean(s.agent_domain?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerHasDrift(s: SpeakerRow): boolean {
|
||||||
|
const pub = s.published_revision_id?.trim();
|
||||||
|
if (!pub) return false;
|
||||||
|
return (s.last_applied_revision_id ?? '') !== pub;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerIsOnline(s: SpeakerRow): boolean {
|
||||||
|
if (s.live) {
|
||||||
|
return s.live.agent_ok === true && s.live.bgp_poll_ok !== false;
|
||||||
|
}
|
||||||
|
if (s.sync_status === 'synced') return true;
|
||||||
|
if (s.sync_status === 'error' || s.last_dispatch_error) return false;
|
||||||
|
return s.dispatch_status === 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerDisplayStatus(s: SpeakerRow): SpeakerStatusBadge {
|
||||||
|
if (s.live) {
|
||||||
|
if (s.live.agent_ok === true && s.live.bgp_poll_ok !== false) {
|
||||||
|
return { label: 'Online', variant: 'default' };
|
||||||
|
}
|
||||||
|
if (s.live.bgp_poll_error || s.live.agent_error) {
|
||||||
|
return { label: 'Offline', variant: 'destructive' };
|
||||||
|
}
|
||||||
|
return { label: 'Degraded', variant: 'secondary' };
|
||||||
|
}
|
||||||
|
if (s.sync_status === 'synced') return { label: 'Connected', variant: 'default' };
|
||||||
|
if (s.sync_status === 'error' || s.last_dispatch_error) {
|
||||||
|
return { label: 'Offline', variant: 'destructive' };
|
||||||
|
}
|
||||||
|
if (s.dispatch_status === 'ok') return { label: 'Synced', variant: 'outline' };
|
||||||
|
return { label: 'Unknown', variant: 'outline' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerLabel(s: SpeakerRow): string {
|
||||||
|
return s.live?.label ?? s.agent_domain ?? s.endpoint ?? s.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speakerBgpText(s: SpeakerRow): string {
|
||||||
|
if (s.live) {
|
||||||
|
return `${s.live.bgp_established ?? 0}/${s.live.bgp_sessions_total ?? 0}`;
|
||||||
|
}
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peersForSpeaker(peers: PeerRow[], speakerId: string): PeerRow[] {
|
||||||
|
return peers.filter(
|
||||||
|
(p) =>
|
||||||
|
p.bgp_speaker_id === speakerId || p.bgp_speaker_id === null || p.bgp_speaker_id === undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateNetworkMetrics(
|
||||||
|
peers: PeerRow[],
|
||||||
|
speakers: SpeakerRow[],
|
||||||
|
bird?: BirdStatus | null
|
||||||
|
): NetworkMetrics {
|
||||||
|
const enabledPeers = peers.filter((p) => p.enabled !== false);
|
||||||
|
const established = enabledPeers.filter((p) => p.session_state === 'Established').length;
|
||||||
|
const mismatch = peers.filter((p) => p.session_mismatch).length;
|
||||||
|
const remoteSpeakers = speakers.filter(isRemoteSpeaker);
|
||||||
|
const online = speakers.filter(speakerIsOnline).length;
|
||||||
|
const remoteOnline = remoteSpeakers.filter(speakerIsOnline).length;
|
||||||
|
const drift = speakers.filter(speakerHasDrift).length;
|
||||||
|
const pollErrors = speakers.filter(
|
||||||
|
(s) => s.live?.bgp_poll_error || (s.live && s.live.agent_ok === false)
|
||||||
|
).length;
|
||||||
|
const hasLiveData =
|
||||||
|
speakers.some((s) => s.live != null) || peers.some((p) => p.session_on_speakers);
|
||||||
|
|
||||||
|
void bird;
|
||||||
|
|
||||||
|
return {
|
||||||
|
peersTotal: peers.length,
|
||||||
|
peersEnabled: enabledPeers.length,
|
||||||
|
peersEstablished: established,
|
||||||
|
peersMismatch: mismatch,
|
||||||
|
speakersTotal: speakers.length,
|
||||||
|
speakersOnline: online,
|
||||||
|
speakersRemote: remoteSpeakers.length,
|
||||||
|
speakersRemoteOnline: remoteOnline,
|
||||||
|
speakersDrift: drift,
|
||||||
|
pollErrors,
|
||||||
|
hasLiveData
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveNetworkOverallStatus(metrics: NetworkMetrics): NetworkOverallStatus {
|
||||||
|
if (!metrics.hasLiveData && metrics.speakersTotal === 0 && metrics.peersTotal === 0) {
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledNotEstablished =
|
||||||
|
metrics.peersEnabled > 0 ? metrics.peersEnabled - metrics.peersEstablished : 0;
|
||||||
|
const majorityPeersDown =
|
||||||
|
metrics.peersEnabled > 0 && enabledNotEstablished / metrics.peersEnabled > 0.5;
|
||||||
|
|
||||||
|
if ((metrics.speakersRemote > 0 && metrics.speakersRemoteOnline === 0) || majorityPeersDown) {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
metrics.pollErrors > 0 ||
|
||||||
|
metrics.peersMismatch > 0 ||
|
||||||
|
metrics.speakersDrift > 0 ||
|
||||||
|
metrics.speakersOnline < metrics.speakersTotal
|
||||||
|
) {
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function networkOverallStatusLabel(status: NetworkOverallStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'ok':
|
||||||
|
return 'В норме';
|
||||||
|
case 'warn':
|
||||||
|
return 'Требует внимания';
|
||||||
|
case 'error':
|
||||||
|
return 'Проблема';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function networkOverallStatusHint(
|
||||||
|
status: NetworkOverallStatus,
|
||||||
|
metrics: NetworkMetrics
|
||||||
|
): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'ok':
|
||||||
|
return metrics.hasLiveData
|
||||||
|
? `${metrics.peersEstablished} Established, ${metrics.speakersOnline}/${metrics.speakersTotal} спикеров online`
|
||||||
|
: 'Сеть настроена; обновите для live-статуса';
|
||||||
|
case 'warn':
|
||||||
|
return 'Есть drift, mismatch или недоступные ноды — проверьте детали';
|
||||||
|
case 'error':
|
||||||
|
return 'Критичная деградация BGP или все remote-ноды недоступны';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectNetworkIssues(
|
||||||
|
peers: PeerRow[],
|
||||||
|
speakers: SpeakerRow[],
|
||||||
|
limit = 3
|
||||||
|
): NetworkIssue[] {
|
||||||
|
const issues: NetworkIssue[] = [];
|
||||||
|
|
||||||
|
for (const s of speakers) {
|
||||||
|
if (!speakerIsOnline(s)) {
|
||||||
|
issues.push({
|
||||||
|
id: `speaker-offline-${s.id}`,
|
||||||
|
message: `Нода offline: ${speakerLabel(s)}`,
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
} else if (speakerHasDrift(s)) {
|
||||||
|
issues.push({
|
||||||
|
id: `speaker-drift-${s.id}`,
|
||||||
|
message: `Drift ревизии: ${speakerLabel(s)}`,
|
||||||
|
severity: 'warn'
|
||||||
|
});
|
||||||
|
} else if (s.live?.bgp_poll_error) {
|
||||||
|
issues.push({
|
||||||
|
id: `speaker-poll-${s.id}`,
|
||||||
|
message: `Ошибка BGP-опроса: ${speakerLabel(s)}`,
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of peers) {
|
||||||
|
if (p.session_mismatch) {
|
||||||
|
const name = p.name?.trim() || p.neighbor;
|
||||||
|
issues.push({
|
||||||
|
id: `peer-mismatch-${p.id}`,
|
||||||
|
message: `Mismatch сессии: ${name}`,
|
||||||
|
severity: 'warn'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NETWORK_AUTO_REFRESH_KEY = 'evobgp.network.autoRefresh';
|
||||||
|
export const NETWORK_AUTO_REFRESH_MS = 15_000;
|
||||||
|
|
||||||
|
export function readNetworkAutoRefresh(): boolean {
|
||||||
|
if (typeof localStorage === 'undefined') return false;
|
||||||
|
return localStorage.getItem(NETWORK_AUTO_REFRESH_KEY) === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeNetworkAutoRefresh(enabled: boolean): void {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
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
|
||||||
>
|
>
|
||||||
|
|||||||
+51
-16
@@ -31,6 +31,8 @@
|
|||||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
|
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
|
||||||
import OverviewRecentRevisionsCard from '$lib/components/overview/OverviewRecentRevisionsCard.svelte';
|
import OverviewRecentRevisionsCard from '$lib/components/overview/OverviewRecentRevisionsCard.svelte';
|
||||||
|
import OverviewNetworkStatusCard from '$lib/components/overview/OverviewNetworkStatusCard.svelte';
|
||||||
|
import { aggregateNetworkMetrics } from '$lib/network/network-metrics.js';
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from '$lib/utils.js';
|
||||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||||
import XCircle from '@lucide/svelte/icons/x-circle';
|
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
import Share2 from '@lucide/svelte/icons/share-2';
|
import Share2 from '@lucide/svelte/icons/share-2';
|
||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import Gauge from '@lucide/svelte/icons/gauge';
|
import Gauge from '@lucide/svelte/icons/gauge';
|
||||||
|
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||||
|
|
||||||
let healthy = $state<boolean | null>(null);
|
let healthy = $state<boolean | null>(null);
|
||||||
let moduleItems = $state<ModuleRow[]>([]);
|
let moduleItems = $state<ModuleRow[]>([]);
|
||||||
@@ -106,6 +109,8 @@
|
|||||||
return suffix;
|
return suffix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const networkMetrics = $derived(aggregateNetworkMetrics(peerItems, speakerItems));
|
||||||
|
|
||||||
const kpiCards = $derived.by(() => [
|
const kpiCards = $derived.by(() => [
|
||||||
{
|
{
|
||||||
id: 'modules',
|
id: 'modules',
|
||||||
@@ -120,22 +125,40 @@
|
|||||||
{
|
{
|
||||||
id: 'peers',
|
id: 'peers',
|
||||||
label: 'Пиры',
|
label: 'Пиры',
|
||||||
value: initialLoading ? '—' : String(peerItems.length),
|
value: initialLoading
|
||||||
href: '/network' as const,
|
? '—'
|
||||||
|
: `${networkMetrics.peersEstablished}/${networkMetrics.peersEnabled}`,
|
||||||
|
href: '/network?tab=peers' as const,
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
description: 'BGP-соседи',
|
description: 'Established / включённых',
|
||||||
accent: statAccents[1],
|
accent: statAccents[1],
|
||||||
badge: countBadge(peerItems.length, peersHasMore, 'peers')
|
badge:
|
||||||
|
networkMetrics.peersMismatch > 0
|
||||||
|
? `mismatch ${networkMetrics.peersMismatch}`
|
||||||
|
: countBadge(peerItems.length, peersHasMore, 'peers'),
|
||||||
|
badgeClass:
|
||||||
|
networkMetrics.peersMismatch > 0
|
||||||
|
? 'border-warning/30 bg-warning/15 text-warning'
|
||||||
|
: undefined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'speakers',
|
id: 'speakers',
|
||||||
label: 'Спикеры',
|
label: 'Спикеры',
|
||||||
value: initialLoading ? '—' : String(speakerItems.length),
|
value: initialLoading
|
||||||
href: '/network' as const,
|
? '—'
|
||||||
|
: `${networkMetrics.speakersOnline}/${networkMetrics.speakersTotal}`,
|
||||||
|
href: '/network?tab=overview' as const,
|
||||||
icon: Radio,
|
icon: Radio,
|
||||||
description: 'BIRD-агенты',
|
description: 'online / всего',
|
||||||
accent: statAccents[2],
|
accent: statAccents[2],
|
||||||
badge: countBadge(speakerItems.length, speakersHasMore, 'agents')
|
badge:
|
||||||
|
networkMetrics.speakersOnline < networkMetrics.speakersTotal
|
||||||
|
? 'есть offline'
|
||||||
|
: countBadge(speakerItems.length, speakersHasMore, 'agents'),
|
||||||
|
badgeClass:
|
||||||
|
networkMetrics.speakersOnline === networkMetrics.speakersTotal
|
||||||
|
? 'border-success/30 bg-success/15 text-success'
|
||||||
|
: 'border-warning/30 bg-warning/15 text-warning'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'revisions',
|
id: 'revisions',
|
||||||
@@ -183,8 +206,8 @@
|
|||||||
|
|
||||||
const [m, p, s, r, j] = await Promise.allSettled([
|
const [m, p, s, r, j] = await Promise.allSettled([
|
||||||
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
||||||
apiJSON<PeersResponse>('/v1/peers?limit=200'),
|
apiJSON<PeersResponse>('/v1/peers?limit=200&live=1'),
|
||||||
apiJSON<SpeakersResponse>('/v1/speakers?limit=200'),
|
apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1'),
|
||||||
apiJSON<RevisionsResponse>('/v1/revisions?limit=10'),
|
apiJSON<RevisionsResponse>('/v1/revisions?limit=10'),
|
||||||
apiJSON<JobsResponse>('/v1/jobs?limit=10')
|
apiJSON<JobsResponse>('/v1/jobs?limit=10')
|
||||||
]);
|
]);
|
||||||
@@ -251,11 +274,12 @@
|
|||||||
<Info class="text-info" />
|
<Info class="text-info" />
|
||||||
<AlertTitle>Панель управления EvoBGP</AlertTitle>
|
<AlertTitle>Панель управления EvoBGP</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Сводка по модулям, сети и фоновым задачам. Настройка префиксов — на странице
|
Сводка по модулям, сети и фоновым задачам. BGP и ноды —
|
||||||
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>, деплой и
|
<Button variant="link" class="h-auto p-0" href={resolve('/network?tab=overview')}>Сеть</Button
|
||||||
ревизии —
|
>, префиксы —
|
||||||
|
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>, деплой —
|
||||||
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>,
|
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>,
|
||||||
здоровье системы —
|
здоровье API —
|
||||||
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
|
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -301,7 +325,7 @@
|
|||||||
class="sm:grid-cols-2 lg:grid-cols-3"
|
class="sm:grid-cols-2 lg:grid-cols-3"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
<OverviewRecentJobsCard
|
<OverviewRecentJobsCard
|
||||||
items={recentJobs}
|
items={recentJobs}
|
||||||
{moduleNameById}
|
{moduleNameById}
|
||||||
@@ -315,6 +339,13 @@
|
|||||||
{initialLoading}
|
{initialLoading}
|
||||||
error={loadError}
|
error={loadError}
|
||||||
/>
|
/>
|
||||||
|
<OverviewNetworkStatusCard
|
||||||
|
peers={peerItems}
|
||||||
|
speakers={speakerItems}
|
||||||
|
loading={refreshing}
|
||||||
|
{initialLoading}
|
||||||
|
error={loadError}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -331,7 +362,11 @@
|
|||||||
<Tags class="size-4" />
|
<Tags class="size-4" />
|
||||||
Добавить community
|
Добавить community
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" href={resolve('/network')}>
|
<Button variant="outline" size="sm" href={resolve('/network?tab=overview')}>
|
||||||
|
<NetworkIcon class="size-4" />
|
||||||
|
Сеть
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" href={resolve('/network?tab=peers')}>
|
||||||
<Share2 class="size-4" />
|
<Share2 class="size-4" />
|
||||||
Добавить пира
|
Добавить пира
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -198,7 +198,7 @@
|
|||||||
? 'border-success/30 bg-success/15 text-success'
|
? 'border-success/30 bg-success/15 text-success'
|
||||||
: undefined,
|
: undefined,
|
||||||
error: birdError ?? bird?.error ?? null,
|
error: birdError ?? bird?.error ?? null,
|
||||||
href: '/network' as const
|
href: '/network?tab=overview' as const
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'jobs',
|
id: 'jobs',
|
||||||
@@ -468,7 +468,9 @@
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>GET /v1/bird/status</CardDescription>
|
<CardDescription>GET /v1/bird/status</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" href={resolve('/network')}>Пиры и спикеры</Button>
|
<Button variant="outline" size="sm" href={resolve('/network?tab=overview')}
|
||||||
|
>Пиры и спикеры</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
@@ -633,7 +635,9 @@
|
|||||||
<AlertTitle>Низкий ratio BGP</AlertTitle>
|
<AlertTitle>Низкий ratio BGP</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Проверьте <code class="text-xs">/v1/bird/status</code>, затем состояние пиров в
|
Проверьте <code class="text-xs">/v1/bird/status</code>, затем состояние пиров в
|
||||||
<Button variant="link" class="h-auto p-0" href={resolve('/network')}>Сети</Button>.
|
<Button variant="link" class="h-auto p-0" href={resolve('/network?tab=overview')}
|
||||||
|
>Сети</Button
|
||||||
|
>.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Alert>
|
<Alert>
|
||||||
|
|||||||
@@ -4,94 +4,50 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { apiJSON } from '$lib/api/client.js';
|
import { apiJSON } from '$lib/api/client.js';
|
||||||
import type { PeerRow, PeersResponse, SpeakerRow, SpeakersResponse } from '$lib/api/types.js';
|
import type {
|
||||||
|
BirdStatus,
|
||||||
|
PeerRow,
|
||||||
|
PeersResponse,
|
||||||
|
SpeakerRow,
|
||||||
|
SpeakersResponse
|
||||||
|
} from '$lib/api/types.js';
|
||||||
|
import { NETWORK_AUTO_REFRESH_MS } from '$lib/network/network-metrics.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
|
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
|
||||||
import NetworkSpeakersCard from '$lib/components/network/NetworkSpeakersCard.svelte';
|
import NetworkSpeakersCard from '$lib/components/network/NetworkSpeakersCard.svelte';
|
||||||
import BirdSettingsForm from '$lib/components/network/BirdSettingsForm.svelte';
|
import BirdSettingsForm from '$lib/components/network/BirdSettingsForm.svelte';
|
||||||
|
import NetworkOverviewTab from '$lib/components/network/NetworkOverviewTab.svelte';
|
||||||
|
import NetworkSpeakerDetailSheet from '$lib/components/network/NetworkSpeakerDetailSheet.svelte';
|
||||||
|
import NetworkAutoRefreshToggle from '$lib/components/network/NetworkAutoRefreshToggle.svelte';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import NetworkIcon from '@lucide/svelte/icons/network';
|
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||||
import Info from '@lucide/svelte/icons/info';
|
import Info from '@lucide/svelte/icons/info';
|
||||||
import Share2 from '@lucide/svelte/icons/share-2';
|
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||||
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
|
|
||||||
import Server from '@lucide/svelte/icons/server';
|
|
||||||
|
|
||||||
type NetworkTab = 'peers' | 'speakers' | 'control-plane';
|
type NetworkTab = 'overview' | 'peers' | 'speakers' | 'control-plane';
|
||||||
|
|
||||||
function parseNetworkTab(value: string | null): NetworkTab {
|
function parseNetworkTab(value: string | null): NetworkTab {
|
||||||
if (value === 'speakers' || value === 'control-plane') return value;
|
if (value === 'peers' || value === 'speakers' || value === 'control-plane') return value;
|
||||||
return 'peers';
|
return 'overview';
|
||||||
}
|
}
|
||||||
|
|
||||||
let peers = $state<PeerRow[]>([]);
|
let peers = $state<PeerRow[]>([]);
|
||||||
let speakers = $state<SpeakerRow[]>([]);
|
let speakers = $state<SpeakerRow[]>([]);
|
||||||
|
let birdStatus = $state<BirdStatus | null>(null);
|
||||||
let peersLoading = $state(false);
|
let peersLoading = $state(false);
|
||||||
let speakersLoading = $state(false);
|
let speakersLoading = $state(false);
|
||||||
let initialLoading = $state(true);
|
let initialLoading = $state(true);
|
||||||
let loadError = $state<string | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
let lastUpdated = $state<Date | null>(null);
|
let lastUpdated = $state<Date | null>(null);
|
||||||
let activeTab = $state<NetworkTab>('peers');
|
let activeTab = $state<NetworkTab>('overview');
|
||||||
let tabSyncReady = $state(false);
|
let tabSyncReady = $state(false);
|
||||||
|
let autoRefresh = $state(false);
|
||||||
const establishedCount = $derived(peers.filter((p) => p.session_state === 'Established').length);
|
let detailSpeaker = $state<SpeakerRow | null>(null);
|
||||||
|
let detailOpen = $state(false);
|
||||||
const statAccents = [
|
|
||||||
{
|
|
||||||
border: 'border-l-chart-3',
|
|
||||||
bg: 'bg-chart-3/5',
|
|
||||||
iconBg: 'bg-chart-3/15',
|
|
||||||
iconText: 'text-chart-3'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
border: 'border-l-chart-2',
|
|
||||||
bg: 'bg-chart-2/5',
|
|
||||||
iconBg: 'bg-chart-2/15',
|
|
||||||
iconText: 'text-chart-2'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
border: 'border-l-chart-4',
|
|
||||||
bg: 'bg-chart-4/5',
|
|
||||||
iconBg: 'bg-chart-4/15',
|
|
||||||
iconText: 'text-chart-4'
|
|
||||||
}
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const kpiCards = $derived.by(() => [
|
|
||||||
{
|
|
||||||
id: 'peers',
|
|
||||||
label: 'BGP-пиры',
|
|
||||||
value: initialLoading ? '—' : String(peers.length),
|
|
||||||
description: 'настроенные BGP-соседи',
|
|
||||||
icon: Share2,
|
|
||||||
accent: statAccents[0],
|
|
||||||
badge: 'peers'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'established',
|
|
||||||
label: 'Активные сессии',
|
|
||||||
value: initialLoading ? '—' : String(establishedCount),
|
|
||||||
description:
|
|
||||||
establishedCount > 0 ? 'Established из текущей выборки' : 'нет установленных сессий',
|
|
||||||
icon: CheckCircle2,
|
|
||||||
accent: statAccents[1],
|
|
||||||
badge: establishedCount > 0 ? 'Established' : 'нет сессий',
|
|
||||||
badgeClass: establishedCount > 0 ? 'border-success/30 bg-success/15 text-success' : undefined
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'speakers',
|
|
||||||
label: 'Спикеры',
|
|
||||||
value: initialLoading ? '—' : String(speakers.length),
|
|
||||||
description: 'BIRD-агенты на нодах',
|
|
||||||
icon: Server,
|
|
||||||
accent: statAccents[2],
|
|
||||||
badge: 'agents'
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const refreshing = $derived(peersLoading || speakersLoading);
|
const refreshing = $derived(peersLoading || speakersLoading);
|
||||||
|
|
||||||
@@ -112,7 +68,7 @@
|
|||||||
async function loadSpeakers() {
|
async function loadSpeakers() {
|
||||||
speakersLoading = true;
|
speakersLoading = true;
|
||||||
try {
|
try {
|
||||||
const sr = await apiJSON<SpeakersResponse>('/v1/speakers?limit=200');
|
const sr = await apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1');
|
||||||
speakers = sr.items;
|
speakers = sr.items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError = e instanceof Error ? e.message : String(e);
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
@@ -123,10 +79,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadBirdStatus() {
|
||||||
|
try {
|
||||||
|
birdStatus = await apiJSON<BirdStatus>('/v1/bird/status');
|
||||||
|
} catch {
|
||||||
|
birdStatus = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loadError = null;
|
loadError = null;
|
||||||
try {
|
try {
|
||||||
await Promise.all([loadPeers(), loadSpeakers()]);
|
await Promise.all([loadPeers(), loadSpeakers(), loadBirdStatus()]);
|
||||||
lastUpdated = new Date();
|
lastUpdated = new Date();
|
||||||
} catch {
|
} catch {
|
||||||
// errors handled in loaders
|
// errors handled in loaders
|
||||||
@@ -153,6 +117,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSpeakerDetail(speaker: SpeakerRow) {
|
||||||
|
detailSpeaker = speaker;
|
||||||
|
detailOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApplyFromDetail(_speaker: SpeakerRow) {
|
||||||
|
detailOpen = false;
|
||||||
|
activeTab = 'speakers';
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
activeTab = parseNetworkTab(page.url.searchParams.get('tab'));
|
activeTab = parseNetworkTab(page.url.searchParams.get('tab'));
|
||||||
tabSyncReady = true;
|
tabSyncReady = true;
|
||||||
@@ -162,7 +136,7 @@
|
|||||||
function syncTabToUrl(tab: NetworkTab) {
|
function syncTabToUrl(tab: NetworkTab) {
|
||||||
if (!tabSyncReady) return;
|
if (!tabSyncReady) return;
|
||||||
const url = new URL(page.url);
|
const url = new URL(page.url);
|
||||||
if (tab === 'peers') url.searchParams.delete('tab');
|
if (tab === 'overview') url.searchParams.delete('tab');
|
||||||
else url.searchParams.set('tab', tab);
|
else url.searchParams.set('tab', tab);
|
||||||
const next = `${url.pathname}${url.search}${url.hash}`;
|
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||||
if (next !== `${page.url.pathname}${page.url.search}${page.url.hash}`) {
|
if (next !== `${page.url.pathname}${page.url.search}${page.url.hash}`) {
|
||||||
@@ -174,18 +148,38 @@
|
|||||||
if (!tabSyncReady) return;
|
if (!tabSyncReady) return;
|
||||||
syncTabToUrl(activeTab);
|
syncTabToUrl(activeTab);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!autoRefresh) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
void load();
|
||||||
|
}, NETWORK_AUTO_REFRESH_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (detailSpeaker) {
|
||||||
|
const updated = speakers.find((s) => s.id === detailSpeaker!.id);
|
||||||
|
if (updated) detailSpeaker = updated;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Сеть"
|
title="Сеть"
|
||||||
description={lastUpdated
|
description={lastUpdated
|
||||||
? `BGP-пиры и спикеры (BIRD-агенты). Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
? `BGP-топология CP и нод. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||||
: 'BGP-пиры и спикеры (BIRD-агенты).'}
|
: 'BGP-пиры, спикеры и live-метрики нод.'}
|
||||||
icon={NetworkIcon}
|
icon={NetworkIcon}
|
||||||
iconClass="bg-chart-3/15 text-chart-3"
|
iconClass="bg-chart-3/15 text-chart-3"
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
|
<Button variant="ghost" size="sm" href={resolve('/')}>
|
||||||
|
<LayoutDashboard class="size-3.5" />
|
||||||
|
Обзор
|
||||||
|
</Button>
|
||||||
|
<NetworkAutoRefreshToggle bind:enabled={autoRefresh} disabled={refreshing} />
|
||||||
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
||||||
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||||
Обновить
|
Обновить
|
||||||
@@ -197,26 +191,30 @@
|
|||||||
<Info class="text-info" />
|
<Info class="text-info" />
|
||||||
<AlertTitle>О сетевой конфигурации</AlertTitle>
|
<AlertTitle>О сетевой конфигурации</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Пиры привязаны к спикерам (BIRD-агентам). Apply запускает применение ревизии на ноде. Полный
|
Вкладка «Обзор» — live-статус agent и BGP на CP и репликах. Apply и ревизии — на
|
||||||
список ревизий и задач — на странице
|
|
||||||
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>.
|
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<KpiMetricsGrid
|
|
||||||
cards={kpiCards}
|
|
||||||
loading={initialLoading}
|
|
||||||
skeletonCount={3}
|
|
||||||
class="sm:grid-cols-3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs bind:value={activeTab}>
|
<Tabs bind:value={activeTab}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Обзор</TabsTrigger>
|
||||||
<TabsTrigger value="peers">Пиры</TabsTrigger>
|
<TabsTrigger value="peers">Пиры</TabsTrigger>
|
||||||
<TabsTrigger value="speakers">Спикеры</TabsTrigger>
|
<TabsTrigger value="speakers">Спикеры</TabsTrigger>
|
||||||
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
|
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" class="mt-4 min-w-0">
|
||||||
|
<NetworkOverviewTab
|
||||||
|
{peers}
|
||||||
|
{speakers}
|
||||||
|
bird={birdStatus}
|
||||||
|
loading={refreshing}
|
||||||
|
{initialLoading}
|
||||||
|
onSpeakerSelect={openSpeakerDetail}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="peers" class="mt-4">
|
<TabsContent value="peers" class="mt-4">
|
||||||
<NetworkPeersCard
|
<NetworkPeersCard
|
||||||
items={peers}
|
items={peers}
|
||||||
@@ -235,6 +233,7 @@
|
|||||||
{initialLoading}
|
{initialLoading}
|
||||||
error={loadError}
|
error={loadError}
|
||||||
onRefresh={refreshSpeakers}
|
onRefresh={refreshSpeakers}
|
||||||
|
onSpeakerSelect={openSpeakerDetail}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -243,3 +242,10 @@
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NetworkSpeakerDetailSheet
|
||||||
|
speaker={detailSpeaker}
|
||||||
|
{peers}
|
||||||
|
bind:open={detailOpen}
|
||||||
|
onApply={handleApplyFromDetail}
|
||||||
|
/>
|
||||||
Reference in new issue
Block a user