Netpulse_SasS/server/internal/store/maps.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
2026-08-27 17:32:49 +03:00

524 lines
23 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}