Порти на вузлі отримали імена (l/r/t/b) з парою «джерело + ціль» під спільним іменем — інакше збережене ребро не знаходить пари й лінія не малюється. Бік підключення тепер правиться: UPDATE ребра розрізняє три стани — не чіпати, зняти прив'язку (auto), задати конкретний бік. Раніше UPDATE боків не торкався, і криво причеплену лінію лишалось видаляти. Лінія без прив'язки бере бік із взаємного розташування вузлів, а не перший-ліпший порт. Типовий вузол — крапка з підписом знизу, без значка й цифр. Підпис бере ім'я хоста, якщо власного немає. Полотно возиться лівою кнопкою в обох режимах; рамка виділення — на Ctrl/Shift. fitView при відкритті та після масового додавання хостів. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
388 lines
15 KiB
Go
388 lines
15 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),
|
||
topo.map_access_level($2, m.id)::text
|
||
FROM topo.maps m
|
||
WHERE m.tenant_id = $1 AND m.deleted_at IS NULL
|
||
AND topo.map_access_level($2, m.id) <> 'deny'
|
||
ORDER BY m.is_default DESC, m.name
|
||
`, tenantID, 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)
|
||
}
|