Netpulse_SasS/server/internal/store/maps.go
byrsapty 65b37a28ff
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Git-двигун NCM: версіювання конфігів, переливання історії, довільне порівняння
go-git, чистий Go без cgo. Голий репозиторій на тенанта, гілка на
пристрій за ідентифікатором (ім'я змінюють, історія не має від цього
розсипатись), файл за іменем. Однаковий вміст нового коміту не створює.

netpulse-gitsync переливає накопичену історію й відтворює втрачений
репозиторій із бази — тіла конфігів там і так лежать зашифрованими.

У вебі з'явився вибір версії, з якою порівнювати: сервер це вмів
(?from=), інтерфейс — ні.

Дорогою виправлено чотири тести, які CI запустив уперше з базою.
Серед них справжня помилка: машинний токен не міг читати мапи —
ACL мап отримував порожній рядок замість uuid і давав 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:15:14 +03:00

399 lines
16 KiB
Go
Raw 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"`
// Живий стан пристрою. Саме це фарбує вузол.
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"`
}
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"`
// До якого боку вузла кріпиться лінія. Без цього після
// перезавантаження всі зв'язки перескакують на типові порти, і
// розкладка, яку людина розводила руками, розсипається.
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"`
// Живе завантаження — джерело швидкості анімації.
LinkStatus string `json:"link_status,omitempty"`
UtilPct *float64 `json:"util_pct,omitempty"`
CapacityBps *int64 `json:"capacity_bps,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(d.status::text,''), d.last_seen_at,
i.rtt_avg_ms, i.loss_pct
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.Status, &n.LastSeenAt, &n.RttMs, &n.LossPct); 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`
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_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,
CASE WHEN l.id IS NULL THEN '' ELSE `+linkStatusExpr+` END,
lv.util_pct, l.capacity_bps
FROM topo.map_edges e
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
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
if err := rows.Scan(&e.ID, &e.SourceNodeID, &e.TargetNodeID,
&e.SourcePort, &e.TargetPort, &e.SourceHandle, &e.TargetHandle,
&e.LinkID, &e.Label,
&e.Style, &e.Dash, &e.Color, &e.WidthPx,
&e.Waypoints, &e.Animation, &e.Thresholds, &e.ShowMetrics,
&e.LinkStatus, &e.UtilPct, &e.CapacityBps); err != nil {
return nil, err
}
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)
}