go-git, чистий Go без cgo. Голий репозиторій на тенанта, гілка на пристрій за ідентифікатором (ім'я змінюють, історія не має від цього розсипатись), файл за іменем. Однаковий вміст нового коміту не створює. netpulse-gitsync переливає накопичену історію й відтворює втрачений репозиторій із бази — тіла конфігів там і так лежать зашифрованими. У вебі з'явився вибір версії, з якою порівнювати: сервер це вмів (?from=), інтерфейс — ні. Дорогою виправлено чотири тести, які CI запустив уперше з базою. Серед них справжня помилка: машинний токен не міг читати мапи — ACL мап отримував порожній рядок замість uuid і давав 500. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
399 lines
16 KiB
Go
399 lines
16 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"`
|
||
|
||
// Живий стан пристрою. Саме це фарбує вузол.
|
||
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)
|
||
}
|