Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
524 lines
23 KiB
Go
524 lines
23 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// Тут живлять мапу: усе, що потрібно полотну, віддається одним викликом.
|
||
//
|
||
// Структури мають json-теги, бо їдуть у фронтенд як є. Це свідомо:
|
||
// проміжний DTO-шар між БД і React додав би роботи й нічого не дав —
|
||
// форма полотна й так визначена схемою (topo.map_nodes / map_edges).
|
||
|
||
type MapSummary struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Slug string `json:"slug"`
|
||
Kind string `json:"kind"`
|
||
SiteID string `json:"site_id,omitempty"`
|
||
IsDefault bool `json:"is_default"`
|
||
NodeCount int `json:"node_count"`
|
||
EdgeCount int `json:"edge_count"`
|
||
Revision int64 `json:"revision"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
// Що людина може робити саме з цією мапою: read або write.
|
||
// Рахується з груп доступу; мапа без жодного запису — write для всіх,
|
||
// хто взагалі має maps:write.
|
||
Access string `json:"access"`
|
||
}
|
||
|
||
type MapState struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Slug string `json:"slug"`
|
||
Kind string `json:"kind"`
|
||
LayoutAlgo string `json:"layout_algo"`
|
||
Viewport json.RawMessage `json:"viewport"`
|
||
Grid json.RawMessage `json:"grid"`
|
||
Clustering json.RawMessage `json:"clustering"`
|
||
Revision int64 `json:"revision"`
|
||
|
||
Backgrounds []MapBackground `json:"backgrounds"`
|
||
Nodes []MapNode `json:"nodes"`
|
||
Edges []MapEdge `json:"edges"`
|
||
}
|
||
|
||
type MapBackground struct {
|
||
ID string `json:"id"`
|
||
Kind string `json:"kind"`
|
||
StorageKey string `json:"storage_key,omitempty"`
|
||
MimeType string `json:"mime_type,omitempty"`
|
||
X float64 `json:"x"`
|
||
Y float64 `json:"y"`
|
||
Width *float64 `json:"width,omitempty"`
|
||
Height *float64 `json:"height,omitempty"`
|
||
Rotation float64 `json:"rotation"`
|
||
Opacity float64 `json:"opacity"`
|
||
Locked bool `json:"locked"`
|
||
ZIndex int `json:"z_index"`
|
||
Geo json.RawMessage `json:"geo,omitempty"`
|
||
Rack json.RawMessage `json:"rack,omitempty"`
|
||
}
|
||
|
||
type MapNode struct {
|
||
ID string `json:"id"`
|
||
Kind string `json:"kind"`
|
||
Label string `json:"label,omitempty"`
|
||
DeviceID string `json:"device_id,omitempty"`
|
||
ParentID string `json:"parent_id,omitempty"`
|
||
X float64 `json:"x"`
|
||
Y float64 `json:"y"`
|
||
Width *float64 `json:"width,omitempty"`
|
||
Height *float64 `json:"height,omitempty"`
|
||
ZIndex int `json:"z_index"`
|
||
Style json.RawMessage `json:"style"`
|
||
Data json.RawMessage `json:"data"`
|
||
Collapsed bool `json:"collapsed"`
|
||
Locked bool `json:"locked"`
|
||
|
||
// Адреса хоста. Не для запитів — для підпису на схемі: вузол з
|
||
// іменем «ЦЕХ-2» нічого не каже тому, хто прийшов лагодити, а
|
||
// 10.0.3.7 під іменем економить перехід у перелік хостів.
|
||
Address string `json:"address,omitempty"`
|
||
|
||
// Живий стан пристрою. Саме це фарбує вузол.
|
||
Status string `json:"status,omitempty"`
|
||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||
RttMs *float32 `json:"rtt_ms,omitempty"`
|
||
LossPct *float32 `json:"loss_pct,omitempty"`
|
||
|
||
// Хост видалено (м'яко), а вузол лишився стояти.
|
||
//
|
||
// Вузол не ховаємо: людина розводила схему руками, і мовчазне
|
||
// зникнення вузла разом із лініями, які до нього вели, виглядає як
|
||
// поламана мапа. Чесніше показати стан і дати прибрати його одним
|
||
// рухом — рішення, що робити зі схемою, за людиною.
|
||
DeviceDeleted bool `json:"device_deleted,omitempty"`
|
||
}
|
||
|
||
type MapEdge struct {
|
||
ID string `json:"id"`
|
||
SourceNodeID string `json:"source_node_id"`
|
||
TargetNodeID string `json:"target_node_id"`
|
||
SourcePort string `json:"source_port,omitempty"`
|
||
TargetPort string `json:"target_port,omitempty"`
|
||
// Порти, задані руками або автопобудовою. Ідентифікатори потрібні
|
||
// самій формі: без них вибір порту нічим показати як обраний.
|
||
SourceInterfaceID string `json:"source_interface_id,omitempty"`
|
||
TargetInterfaceID string `json:"target_interface_id,omitempty"`
|
||
// До якого боку вузла кріпиться лінія. Без цього після
|
||
// перезавантаження всі зв'язки перескакують на типові порти, і
|
||
// розкладка, яку людина розводила руками, розсипається.
|
||
SourceHandle string `json:"source_handle,omitempty"`
|
||
TargetHandle string `json:"target_handle,omitempty"`
|
||
LinkID string `json:"link_id,omitempty"`
|
||
Label string `json:"label,omitempty"`
|
||
Style string `json:"style"`
|
||
Dash string `json:"dash"`
|
||
Color string `json:"color,omitempty"`
|
||
WidthPx float64 `json:"width_px"`
|
||
Waypoints json.RawMessage `json:"waypoints"`
|
||
Animation json.RawMessage `json:"animation"`
|
||
Thresholds json.RawMessage `json:"thresholds"`
|
||
ShowMetrics bool `json:"show_metrics"`
|
||
// Подання: що писати на лінії, де саме й чи ставити стрілку.
|
||
// Порожньо — рішення за клієнтом (див. 0049_map_edge_view.sql).
|
||
View json.RawMessage `json:"view,omitempty"`
|
||
|
||
// Живе завантаження — джерело швидкості анімації.
|
||
LinkStatus string `json:"link_status,omitempty"`
|
||
UtilPct *float64 `json:"util_pct,omitempty"`
|
||
CapacityBps *int64 `json:"capacity_bps,omitempty"`
|
||
|
||
// Звірка намальованого з тим, що видно в мережі.
|
||
//
|
||
// Ребро без link_id — не обов'язково помилка: його могли намалювати
|
||
// раніше, ніж зонд обійшов мережу. Але «не привʼязане» і
|
||
// «суперечить виявленому» — різні стани, і плутати їх не можна:
|
||
// перший лікується прив'язкою, другий — виправленням схеми.
|
||
Discovered *DiscoveredLink `json:"discovered,omitempty"`
|
||
// Обидва кінці вже мають виявлених сусідів. Разом із порожнім
|
||
// Discovered це означає: мережа каже, що ці двоє не з'єднані.
|
||
PeersDiscovered bool `json:"peers_discovered,omitempty"`
|
||
}
|
||
|
||
// DiscoveredLink — виявлений зв'язок, який відповідає намальованій лінії.
|
||
//
|
||
// Порти вже повернуті в бік ребра (A — це його source), щоб клієнт не
|
||
// вгадував орієнтацію лінка: у topo.links сторони нормалізовані за
|
||
// ідентифікаторами пристроїв, а не за тим, як людина малювала.
|
||
type DiscoveredLink struct {
|
||
LinkID string `json:"link_id"`
|
||
SourceInterfaceID string `json:"source_interface_id,omitempty"`
|
||
TargetInterfaceID string `json:"target_interface_id,omitempty"`
|
||
SourcePort string `json:"source_port,omitempty"`
|
||
TargetPort string `json:"target_port,omitempty"`
|
||
}
|
||
|
||
var ErrNotFound = errors.New("не знайдено")
|
||
|
||
// ListMaps віддає перелік мап тенанта з лічильниками.
|
||
// ListMaps віддає лише ті мапи, які людині видно, і каже, що з ними
|
||
// можна робити.
|
||
//
|
||
// Фільтр у самому запиті, а не після вибірки: відсіювати вже прочитане
|
||
// означало б тягнути з БД чужі рядки й покладатися на те, що жоден із
|
||
// них не проскочить у відповідь.
|
||
func (s *Store) ListMaps(ctx context.Context, tenantID, userID string) ([]MapSummary, error) {
|
||
var out []MapSummary
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT m.id::text, m.name, m.slug, m.kind::text,
|
||
COALESCE(m.site_id::text, ''), m.is_default, m.revision, m.updated_at,
|
||
(SELECT count(*) FROM topo.map_nodes n WHERE n.map_id = m.id),
|
||
(SELECT count(*) FROM topo.map_edges e WHERE e.map_id = m.id),
|
||
-- Порожній користувач — машинний токен: групового
|
||
-- ACL до нього не застосувати, межі задають scopes,
|
||
-- які вже перевірив обробник.
|
||
--
|
||
-- Перевірка на NULL обов'язкова, а не COALESCE:
|
||
-- map_access_level не STRICT і з NULL-користувачем
|
||
-- чесно доходить до 'deny'. Порожній рядок замість
|
||
-- NULL ламав запит зовсім — '' не приводиться до
|
||
-- uuid, і список мап відповідав п'ятисоткою на
|
||
-- кожен виклик із токеном.
|
||
CASE WHEN $2::uuid IS NULL THEN 'write'::core.access_level
|
||
ELSE topo.map_access_level($2, m.id) END::text
|
||
FROM topo.maps m
|
||
WHERE m.tenant_id = $1 AND m.deleted_at IS NULL
|
||
AND ($2::uuid IS NULL OR topo.map_access_level($2, m.id) <> 'deny')
|
||
ORDER BY m.is_default DESC, m.name
|
||
`, tenantID, nullUUID(userID))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var m MapSummary
|
||
if err := rows.Scan(&m.ID, &m.Name, &m.Slug, &m.Kind, &m.SiteID,
|
||
&m.IsDefault, &m.Revision, &m.UpdatedAt, &m.NodeCount, &m.EdgeCount,
|
||
&m.Access); err != nil {
|
||
return err
|
||
}
|
||
out = append(out, m)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
|
||
return out, err
|
||
}
|
||
|
||
// GetMapState збирає повний стан полотна разом із живими статусами.
|
||
//
|
||
// Чотири запити в одній транзакції замість N+1 на кожен вузол: фронтенд
|
||
// отримує готове до рендеру полотно, а не сотні дозапитів. Живий стан
|
||
// підмішується тут же — інакше мапа малювалася б сірою й лише потім
|
||
// доганяла кольори.
|
||
func (s *Store) GetMapState(ctx context.Context, tenantID, mapID string) (*MapState, error) {
|
||
var st *MapState
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
m := &MapState{}
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT id::text, name, slug, kind::text, layout_algo::text,
|
||
viewport::text, grid::text, clustering::text, revision
|
||
FROM topo.maps
|
||
WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL
|
||
`, tenantID, mapID).Scan(&m.ID, &m.Name, &m.Slug, &m.Kind, &m.LayoutAlgo,
|
||
&m.Viewport, &m.Grid, &m.Clustering, &m.Revision)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if m.Backgrounds, err = mapBackgrounds(ctx, tx, mapID); err != nil {
|
||
return err
|
||
}
|
||
if m.Nodes, err = mapNodes(ctx, tx, tenantID, mapID); err != nil {
|
||
return err
|
||
}
|
||
if m.Edges, err = mapEdges(ctx, tx, tenantID, mapID); err != nil {
|
||
return err
|
||
}
|
||
|
||
st = m
|
||
return nil
|
||
})
|
||
|
||
return st, err
|
||
}
|
||
|
||
func mapBackgrounds(ctx context.Context, tx pgx.Tx, mapID string) ([]MapBackground, error) {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id::text, kind::text, COALESCE(storage_key,''), COALESCE(mime_type,''),
|
||
x, y, width, height, rotation, opacity, locked, z_index,
|
||
COALESCE(geo::text,''), COALESCE(rack::text,'')
|
||
FROM topo.map_backgrounds
|
||
WHERE map_id = $1
|
||
ORDER BY z_index
|
||
`, mapID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := []MapBackground{}
|
||
for rows.Next() {
|
||
var b MapBackground
|
||
var geo, rack string
|
||
if err := rows.Scan(&b.ID, &b.Kind, &b.StorageKey, &b.MimeType,
|
||
&b.X, &b.Y, &b.Width, &b.Height, &b.Rotation, &b.Opacity,
|
||
&b.Locked, &b.ZIndex, &geo, &rack); err != nil {
|
||
return nil, err
|
||
}
|
||
b.Geo = rawOrNil(geo)
|
||
b.Rack = rawOrNil(rack)
|
||
out = append(out, b)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func mapNodes(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapNode, error) {
|
||
// ts.device_last_icmp вже обмежений останніми 15 хвилинами: старіші
|
||
// дані не характеризують поточний стан, і показувати їх на мапі
|
||
// означало б брехати про живість пристрою.
|
||
rows, err := tx.Query(ctx, `
|
||
-- Підпис вузла-пристрою — це ім'я самого пристрою, доки його не
|
||
-- перевизначили руками. Без COALESCE вузол, доданий кнопкою
|
||
-- «+ Хости», приходив без підпису й малювався прочерком: на
|
||
-- схемі з десятка таких неможливо зрозуміти, що є що.
|
||
SELECT n.id::text, n.kind::text, COALESCE(NULLIF(n.label,''), d.name, ''),
|
||
COALESCE(n.device_id::text,''), COALESCE(n.parent_node_id::text,''),
|
||
n.x, n.y, n.width, n.height, n.z_index,
|
||
n.style::text, n.data::text, n.collapsed, n.locked,
|
||
COALESCE(host(d.address), ''),
|
||
-- Стан видаленого хоста — «невідомо», а не останній
|
||
-- відомий: опитувати його вже нікому, і зелена крапка
|
||
-- на схемі означала б живий пристрій, якого немає.
|
||
CASE WHEN d.deleted_at IS NOT NULL THEN ''
|
||
ELSE COALESCE(d.status::text,'') END,
|
||
CASE WHEN d.deleted_at IS NOT NULL THEN NULL ELSE d.last_seen_at END,
|
||
CASE WHEN d.deleted_at IS NOT NULL THEN NULL ELSE i.rtt_avg_ms END,
|
||
CASE WHEN d.deleted_at IS NOT NULL THEN NULL ELSE i.loss_pct END,
|
||
d.deleted_at IS NOT NULL
|
||
FROM topo.map_nodes n
|
||
LEFT JOIN inv.devices d ON d.id = n.device_id AND d.tenant_id = $1
|
||
LEFT JOIN ts.device_last_icmp i ON i.device_id = n.device_id
|
||
WHERE n.map_id = $2 AND n.tenant_id = $1 AND NOT n.hidden
|
||
ORDER BY n.z_index, n.id
|
||
`, tenantID, mapID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := []MapNode{}
|
||
for rows.Next() {
|
||
var n MapNode
|
||
if err := rows.Scan(&n.ID, &n.Kind, &n.Label, &n.DeviceID, &n.ParentID,
|
||
&n.X, &n.Y, &n.Width, &n.Height, &n.ZIndex,
|
||
&n.Style, &n.Data, &n.Collapsed, &n.Locked, &n.Address,
|
||
&n.Status, &n.LastSeenAt, &n.RttMs, &n.LossPct,
|
||
&n.DeviceDeleted); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, n)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// linkStatusExpr виводить стан лінка з його кінців.
|
||
//
|
||
// Колонка topo.links.status існує, але її ніхто не підтримує: писати
|
||
// туди означало б оновлювати всі лінки пристрою на кожну зміну його
|
||
// статусу й тримати це узгодженим. Дешевше й чесніше порахувати на
|
||
// читанні — стан лінка є похідною величиною, а не фактом.
|
||
//
|
||
// Порядок гілок важливий: обрив (down) перекриває все інше, бо саме він
|
||
// вимагає уваги оператора; «невідомо» стоїть перед «up», щоб мапа не
|
||
// малювала зеленим те, чого ще жодного разу не опитували.
|
||
const linkStatusExpr = `
|
||
CASE
|
||
WHEN da.status = 'down' OR db.status = 'down' THEN 'down'
|
||
WHEN COALESCE(ia.oper_status::text,'up') NOT IN ('up','unknown')
|
||
OR COALESCE(ib.oper_status::text,'up') NOT IN ('up','unknown') THEN 'down'
|
||
WHEN da.status = 'maintenance' OR db.status = 'maintenance' THEN 'maintenance'
|
||
WHEN da.status = 'unknown' OR db.status = 'unknown' THEN 'unknown'
|
||
WHEN da.status = 'warning' OR db.status = 'warning' THEN 'warning'
|
||
ELSE 'up'
|
||
END`
|
||
|
||
// discoveredMatchExpr — виявлений зв'язок для пари вузлів ребра.
|
||
//
|
||
// Пара шукається тим самим виразом LEAST/GREATEST, що й унікальний
|
||
// індекс links_pair_uniq: у topo.links сторони нормалізовані за
|
||
// ідентифікаторами пристроїв, і A→B та B→A — це один рядок. Порти
|
||
// одразу розвертаються в бік ребра, щоб клієнту не доводилось вгадувати
|
||
// орієнтацію.
|
||
//
|
||
// Перевірка на NULL обов'язкова: LEAST(NULL, x) у Postgres дорівнює x,
|
||
// тож без неї ребро між двома текстовими анотаціями «знайшло» б
|
||
// випадковий лінк.
|
||
const discoveredMatchExpr = `
|
||
LEFT JOIN LATERAL (
|
||
SELECT l2.id::text AS id,
|
||
CASE WHEN l2.a_device_id = ns.device_id
|
||
THEN l2.a_interface_id ELSE l2.b_interface_id END AS src_if,
|
||
CASE WHEN l2.a_device_id = ns.device_id
|
||
THEN l2.b_interface_id ELSE l2.a_interface_id END AS dst_if
|
||
FROM topo.links l2
|
||
WHERE l2.tenant_id = $1
|
||
AND ns.device_id IS NOT NULL AND nt.device_id IS NOT NULL
|
||
AND LEAST(l2.a_device_id, l2.b_device_id) = LEAST(ns.device_id, nt.device_id)
|
||
AND GREATEST(l2.a_device_id, l2.b_device_id) = GREATEST(ns.device_id, nt.device_id)
|
||
ORDER BY l2.confidence DESC, l2.last_seen_at DESC
|
||
LIMIT 1
|
||
) dl ON e.link_id IS NULL`
|
||
|
||
func mapEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapEdge, error) {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT e.id::text, e.source_node_id::text, e.target_node_id::text,
|
||
COALESCE(si.name,''), COALESCE(ti.name,''),
|
||
COALESCE(e.source_interface_id::text,''), COALESCE(e.target_interface_id::text,''),
|
||
COALESCE(e.source_handle,''), COALESCE(e.target_handle,''),
|
||
COALESCE(e.link_id::text,''), COALESCE(e.label,''),
|
||
e.style::text, e.dash::text, COALESCE(e.color,''), e.width_px,
|
||
e.waypoints::text, e.animation::text, e.thresholds::text, e.show_metrics,
|
||
COALESCE(e.view::text,''),
|
||
CASE WHEN l.id IS NULL THEN '' ELSE `+linkStatusExpr+` END,
|
||
lv.util_pct,
|
||
-- Без лінка швидкість каналу все одно відома: її дає
|
||
-- повільніший з двох обраних портів. Інакше лінія з
|
||
-- руками зіставленими портами лишалась би без підпису,
|
||
-- хоча все потрібне для нього вже є в інвентарі.
|
||
COALESCE(l.capacity_bps, LEAST(si.speed_bps, ti.speed_bps)),
|
||
COALESCE(dl.id,''), COALESCE(dl.src_if::text,''), COALESCE(dl.dst_if::text,''),
|
||
COALESCE(dsi.name,''), COALESCE(dti.name,''),
|
||
EXISTS (
|
||
SELECT 1 FROM topo.links lp
|
||
WHERE lp.tenant_id = $1 AND lp.discovered_by <> 'manual'
|
||
AND ns.device_id IS NOT NULL
|
||
AND (lp.a_device_id = ns.device_id OR lp.b_device_id = ns.device_id)
|
||
) AND EXISTS (
|
||
SELECT 1 FROM topo.links lp
|
||
WHERE lp.tenant_id = $1 AND lp.discovered_by <> 'manual'
|
||
AND nt.device_id IS NOT NULL
|
||
AND (lp.a_device_id = nt.device_id OR lp.b_device_id = nt.device_id)
|
||
)
|
||
FROM topo.map_edges e
|
||
JOIN topo.map_nodes ns ON ns.id = e.source_node_id
|
||
JOIN topo.map_nodes nt ON nt.id = e.target_node_id
|
||
LEFT JOIN inv.interfaces si ON si.id = e.source_interface_id
|
||
LEFT JOIN inv.interfaces ti ON ti.id = e.target_interface_id
|
||
LEFT JOIN topo.links l ON l.id = e.link_id
|
||
LEFT JOIN inv.devices da ON da.id = l.a_device_id
|
||
LEFT JOIN inv.devices db ON db.id = l.b_device_id
|
||
LEFT JOIN inv.interfaces ia ON ia.id = l.a_interface_id
|
||
LEFT JOIN inv.interfaces ib ON ib.id = l.b_interface_id
|
||
LEFT JOIN topo.link_live lv ON lv.link_id = e.link_id`+
|
||
discoveredMatchExpr+`
|
||
LEFT JOIN inv.interfaces dsi ON dsi.id = dl.src_if
|
||
LEFT JOIN inv.interfaces dti ON dti.id = dl.dst_if
|
||
WHERE e.map_id = $2 AND e.tenant_id = $1 AND NOT e.hidden
|
||
ORDER BY e.z_index, e.id
|
||
`, tenantID, mapID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := []MapEdge{}
|
||
for rows.Next() {
|
||
var (
|
||
e MapEdge
|
||
view string
|
||
dLink string
|
||
dSrcIf, dDstIf string
|
||
dSrcPort, dDstPort string
|
||
)
|
||
if err := rows.Scan(&e.ID, &e.SourceNodeID, &e.TargetNodeID,
|
||
&e.SourcePort, &e.TargetPort,
|
||
&e.SourceInterfaceID, &e.TargetInterfaceID,
|
||
&e.SourceHandle, &e.TargetHandle,
|
||
&e.LinkID, &e.Label,
|
||
&e.Style, &e.Dash, &e.Color, &e.WidthPx,
|
||
&e.Waypoints, &e.Animation, &e.Thresholds, &e.ShowMetrics, &view,
|
||
&e.LinkStatus, &e.UtilPct, &e.CapacityBps,
|
||
&dLink, &dSrcIf, &dDstIf, &dSrcPort, &dDstPort,
|
||
&e.PeersDiscovered); err != nil {
|
||
return nil, err
|
||
}
|
||
e.View = rawOrNil(view)
|
||
if dLink != "" {
|
||
e.Discovered = &DiscoveredLink{
|
||
LinkID: dLink,
|
||
SourceInterfaceID: dSrcIf,
|
||
TargetInterfaceID: dDstIf,
|
||
SourcePort: dSrcPort,
|
||
TargetPort: dDstPort,
|
||
}
|
||
}
|
||
out = append(out, e)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// LinkLoad — поточне завантаження лінка для WebSocket-оновлень.
|
||
type LinkLoad struct {
|
||
LinkID string `json:"link_id"`
|
||
Status string `json:"status"`
|
||
UtilPct *float64 `json:"util_pct,omitempty"`
|
||
}
|
||
|
||
// MapLinkLoads віддає завантаження всіх лінків мапи.
|
||
//
|
||
// Окремо від повного стану: WebSocket штовхає лише ці кілька чисел
|
||
// кожні кілька секунд, а не перемальовує полотно.
|
||
func (s *Store) MapLinkLoads(ctx context.Context, tenantID, mapID string) ([]LinkLoad, error) {
|
||
var out []LinkLoad
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT DISTINCT l.id::text, `+linkStatusExpr+`, lv.util_pct
|
||
FROM topo.map_edges e
|
||
JOIN topo.links l ON l.id = e.link_id
|
||
LEFT JOIN inv.devices da ON da.id = l.a_device_id
|
||
LEFT JOIN inv.devices db ON db.id = l.b_device_id
|
||
LEFT JOIN inv.interfaces ia ON ia.id = l.a_interface_id
|
||
LEFT JOIN inv.interfaces ib ON ib.id = l.b_interface_id
|
||
LEFT JOIN topo.link_live lv ON lv.link_id = l.id
|
||
WHERE e.map_id = $2 AND e.tenant_id = $1
|
||
`, tenantID, mapID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var l LinkLoad
|
||
if err := rows.Scan(&l.LinkID, &l.Status, &l.UtilPct); err != nil {
|
||
return err
|
||
}
|
||
out = append(out, l)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
|
||
return out, err
|
||
}
|
||
|
||
func rawOrNil(s string) json.RawMessage {
|
||
if s == "" || s == "null" {
|
||
return nil
|
||
}
|
||
return json.RawMessage(s)
|
||
}
|