Netpulse_SasS/server/internal/store/maps_write.go
byrsapty 32b0b003df
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Дрібний борг: модуль http/ssl і зрозумілі помилки на мапі
Плагін http стояв у сіді як базовий — тобто обіцяний усім одразу, — а
модуля не існувало. Тепер є: код відповіді, час, збіг слова в тілі й
залишок днів до кінця сертифіката.

Недоступність повертається нулем, а не помилкою чека: на графіку це
читається як провал, і саме за цим ставлять тригер. Довіру до ланцюга
сертифікатів свідомо не перевіряємо — питають строк, а самопідписаний
теж має дату.

Повторна лінія між вузлами й повторно доданий хост давали однакове
«такий запис уже існує». Тепер кожен випадок каже, що робити далі.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:37:58 +03:00

689 lines
26 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"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// Скільки знімків полотна тримати. Undo глибше 50 кроків ніхто не
// робить, а кожен знімок — це повна копія мапи в jsonb.
const maxRevisionsKept = 50
var (
// ErrConflict — мапу змінив хтось інший, поки клієнт редагував.
ErrConflict = errors.New("мапу змінено іншим користувачем")
// ErrPlanLimit — упёрлись у ліміт тарифу (тригер у БД).
ErrPlanLimit = errors.New("досягнуто ліміт тарифу")
// ErrInvalid — некоректні дані від клієнта.
ErrInvalid = errors.New("некоректний запит")
)
// ---------------------------------------------------------------------
// Форма патча
//
// Усі скалярні поля — вказівники: nil означає «не чіпати». Це не
// формальність. Перетягування вузла шле лише x/y, і якби відсутні поля
// трактувались як порожні, кожен рух миші стирав би стиль, розмір і
// прив'язку до пристрою.
// ---------------------------------------------------------------------
type MapPatch struct {
// Очікувана ревізія. nil вимикає перевірку — так робить лише
// серверний код (автопобудова), не UI.
Revision *int64 `json:"revision"`
Name *string `json:"name,omitempty"`
LayoutAlgo *string `json:"layout_algo,omitempty"`
Viewport json.RawMessage `json:"viewport,omitempty"`
Grid json.RawMessage `json:"grid,omitempty"`
Clustering json.RawMessage `json:"clustering,omitempty"`
Nodes NodePatch `json:"nodes"`
Edges EdgePatch `json:"edges"`
Backgrounds BackgroundPatch `json:"backgrounds"`
Comment string `json:"comment,omitempty"`
}
type NodePatch struct {
Upsert []NodeInput `json:"upsert"`
Remove []string `json:"remove"`
}
type NodeInput struct {
ID string `json:"id,omitempty"`
// Тимчасовий ідентифікатор нового вузла. Сервер поверне мапу
// client_id → id, щоб полотно не перемальовувалось повністю після
// кожного створення.
ClientID string `json:"client_id,omitempty"`
Kind string `json:"kind,omitempty"`
DeviceID string `json:"device_id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
TargetMapID string `json:"target_map_id,omitempty"`
Label *string `json:"label,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
Width *float64 `json:"width,omitempty"`
Height *float64 `json:"height,omitempty"`
ZIndex *int `json:"z_index,omitempty"`
Style json.RawMessage `json:"style,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
Collapsed *bool `json:"collapsed,omitempty"`
Locked *bool `json:"locked,omitempty"`
Hidden *bool `json:"hidden,omitempty"`
}
type EdgePatch struct {
Upsert []EdgeInput `json:"upsert"`
Remove []string `json:"remove"`
}
type EdgeInput struct {
ID string `json:"id,omitempty"`
ClientID string `json:"client_id,omitempty"`
SourceNodeID string `json:"source_node_id,omitempty"`
TargetNodeID string `json:"target_node_id,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,omitempty"`
Dash *string `json:"dash,omitempty"`
Color *string `json:"color,omitempty"`
WidthPx *float64 `json:"width_px,omitempty"`
Waypoints json.RawMessage `json:"waypoints,omitempty"`
Animation json.RawMessage `json:"animation,omitempty"`
Thresholds json.RawMessage `json:"thresholds,omitempty"`
ShowMetrics *bool `json:"show_metrics,omitempty"`
ZIndex *int `json:"z_index,omitempty"`
Locked *bool `json:"locked,omitempty"`
Hidden *bool `json:"hidden,omitempty"`
}
type BackgroundPatch struct {
Upsert []BackgroundInput `json:"upsert"`
Remove []string `json:"remove"`
}
type BackgroundInput struct {
ID string `json:"id,omitempty"`
ClientID string `json:"client_id,omitempty"`
Kind string `json:"kind,omitempty"`
StorageKey *string `json:"storage_key,omitempty"`
MimeType *string `json:"mime_type,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
Width *float64 `json:"width,omitempty"`
Height *float64 `json:"height,omitempty"`
Rotation *float64 `json:"rotation,omitempty"`
Opacity *float64 `json:"opacity,omitempty"`
Locked *bool `json:"locked,omitempty"`
ZIndex *int `json:"z_index,omitempty"`
Geo json.RawMessage `json:"geo,omitempty"`
Rack json.RawMessage `json:"rack,omitempty"`
}
// PatchResult — що повернути клієнту після збереження.
type PatchResult struct {
Revision int64 `json:"revision"`
NodeIDs map[string]string `json:"node_ids,omitempty"`
EdgeIDs map[string]string `json:"edge_ids,omitempty"`
BgIDs map[string]string `json:"background_ids,omitempty"`
}
// ---------------------------------------------------------------------
// Створення й видалення мапи
// ---------------------------------------------------------------------
func (s *Store) CreateMap(ctx context.Context, tenantID, name, slug, kind string) (*MapSummary, error) {
if name == "" || slug == "" {
return nil, fmt.Errorf("%w: потрібні name і slug", ErrInvalid)
}
if kind == "" {
kind = "logical"
}
var m MapSummary
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
INSERT INTO topo.maps (tenant_id, name, slug, kind)
VALUES ($1, $2, $3, $4::topo.map_kind)
RETURNING id::text, name, slug, kind::text, is_default, revision, updated_at
`, tenantID, name, slug, kind).Scan(&m.ID, &m.Name, &m.Slug, &m.Kind,
&m.IsDefault, &m.Revision, &m.UpdatedAt)
})
if err != nil {
return nil, mapPgError(err)
}
return &m, nil
}
// DeleteMap — м'яке видалення: топологія й історія лишаються, зникає
// лише полотно. Мапа — це подання, а не дані.
func (s *Store) DeleteMap(ctx context.Context, tenantID, mapID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE topo.maps SET deleted_at = now(), updated_at = now()
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, mapID, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// ---------------------------------------------------------------------
// Патч
// ---------------------------------------------------------------------
// ApplyMapPatch застосовує зміни полотна атомарно.
//
// Оптимістичне блокування: клієнт надсилає ревізію, яку бачив. Якщо за
// цей час мапу змінив хтось інший — 409, а не тихе затирання. У NOC над
// однією мапою часто працюють кілька людей, і мовчазна втрата чужих
// правок гірша за помилку.
func (s *Store) ApplyMapPatch(ctx context.Context, tenantID, mapID, authorID string, p *MapPatch) (*PatchResult, error) {
res := &PatchResult{
NodeIDs: map[string]string{},
EdgeIDs: map[string]string{},
BgIDs: map[string]string{},
}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Блокуємо рядок мапи: усі зміни полотна серіалізуються через
// нього, тому паралельні патчі не переплітаються.
var current int64
err := tx.QueryRow(ctx, `
SELECT revision FROM topo.maps
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
FOR UPDATE
`, mapID, tenantID).Scan(&current)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if p.Revision != nil && *p.Revision != current {
return fmt.Errorf("%w: очікувано %d, у базі %d", ErrConflict, *p.Revision, current)
}
if err := applyNodes(ctx, tx, tenantID, mapID, &p.Nodes, res); err != nil {
return err
}
// Ребра після вузлів: нове ребро може посилатися на вузол,
// створений у цьому ж патчі.
if err := applyEdges(ctx, tx, tenantID, mapID, &p.Edges, res); err != nil {
return err
}
if err := applyBackgrounds(ctx, tx, tenantID, mapID, &p.Backgrounds, res); err != nil {
return err
}
var newRev int64
if err := tx.QueryRow(ctx, `
UPDATE topo.maps SET
name = COALESCE($3, name),
layout_algo = COALESCE($4::topo.layout_algo, layout_algo),
viewport = COALESCE($5::jsonb, viewport),
grid = COALESCE($6::jsonb, grid),
clustering = COALESCE($7::jsonb, clustering),
revision = revision + 1,
updated_at = now()
WHERE id = $1 AND tenant_id = $2
RETURNING revision
`, mapID, tenantID, p.Name, p.LayoutAlgo,
rawArg(p.Viewport), rawArg(p.Grid), rawArg(p.Clustering)).Scan(&newRev); err != nil {
return err
}
res.Revision = newRev
return snapshotMap(ctx, tx, tenantID, mapID, newRev, authorID, p.Comment)
})
if err != nil {
return nil, mapPgError(err)
}
return res, nil
}
func applyNodes(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *NodePatch, res *PatchResult) error {
// Видаляємо першими: клієнт міг видалити вузол і одразу створити
// новий на тому ж місці.
if len(p.Remove) > 0 {
if _, err := tx.Exec(ctx, `
DELETE FROM topo.map_nodes
WHERE map_id = $1 AND tenant_id = $2 AND id = ANY($3::uuid[])
`, mapID, tenantID, p.Remove); err != nil {
return err
}
}
for i, n := range p.Upsert {
if n.ID == "" {
if n.Kind == "" {
return fmt.Errorf("%w: вузол[%d] без kind", ErrInvalid, i)
}
if n.X == nil || n.Y == nil {
return fmt.Errorf("%w: вузол[%d] без координат", ErrInvalid, i)
}
var id string
if err := tx.QueryRow(ctx, `
INSERT INTO topo.map_nodes
(tenant_id, map_id, kind, device_id, parent_node_id, target_map_id,
label, x, y, width, height, z_index, style, data, collapsed, locked, hidden)
VALUES ($1,$2,$3::topo.node_kind,$4,$5,$6,$7,$8,$9,$10,$11,
COALESCE($12,0), COALESCE($13::jsonb,'{}'::jsonb),
COALESCE($14::jsonb,'{}'::jsonb),
COALESCE($15,false), COALESCE($16,false), COALESCE($17,false))
RETURNING id::text
`, tenantID, mapID, n.Kind, nullUUID(n.DeviceID), nullUUID(n.ParentID),
nullUUID(n.TargetMapID), n.Label, *n.X, *n.Y, n.Width, n.Height,
n.ZIndex, rawArg(n.Style), rawArg(n.Data),
n.Collapsed, n.Locked, n.Hidden).Scan(&id); err != nil {
return err
}
if n.ClientID != "" {
res.NodeIDs[n.ClientID] = id
}
continue
}
tag, err := tx.Exec(ctx, `
UPDATE topo.map_nodes SET
label = COALESCE($4, label),
x = COALESCE($5, x),
y = COALESCE($6, y),
width = COALESCE($7, width),
height = COALESCE($8, height),
z_index = COALESCE($9, z_index),
style = COALESCE($10::jsonb, style),
data = COALESCE($11::jsonb, data),
collapsed = COALESCE($12, collapsed),
locked = COALESCE($13, locked),
hidden = COALESCE($14, hidden),
parent_node_id = COALESCE($15, parent_node_id),
updated_at = now()
WHERE id = $1 AND map_id = $2 AND tenant_id = $3
`, n.ID, mapID, tenantID, n.Label, n.X, n.Y, n.Width, n.Height, n.ZIndex,
rawArg(n.Style), rawArg(n.Data), n.Collapsed, n.Locked, n.Hidden,
nullUUID(n.ParentID))
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: вузол %s не належить цій мапі", ErrInvalid, n.ID)
}
}
return nil
}
func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgePatch, res *PatchResult) error {
if len(p.Remove) > 0 {
if _, err := tx.Exec(ctx, `
DELETE FROM topo.map_edges
WHERE map_id = $1 AND tenant_id = $2 AND id = ANY($3::uuid[])
`, mapID, tenantID, p.Remove); err != nil {
return err
}
}
for i, e := range p.Upsert {
src, dst := e.SourceNodeID, e.TargetNodeID
// Ребро могло бути намальоване до вузлів, створених цим же
// патчем, — підставляємо їхні справжні id.
if id, ok := res.NodeIDs[src]; ok {
src = id
}
if id, ok := res.NodeIDs[dst]; ok {
dst = id
}
if e.ID == "" {
if src == "" || dst == "" {
return fmt.Errorf("%w: ребро[%d] без кінців", ErrInvalid, i)
}
var id string
if err := tx.QueryRow(ctx, `
INSERT INTO topo.map_edges
(tenant_id, map_id, source_node_id, target_node_id,
source_interface_id, target_interface_id, link_id, label,
style, dash, color, width_px, waypoints, animation, thresholds,
show_metrics, z_index, locked, hidden,
source_handle, target_handle)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,
COALESCE($9::topo.edge_style,'smoothstep'),
COALESCE($10::topo.edge_dash,'solid'),
$11, COALESCE($12,2),
COALESCE($13::jsonb,'[]'::jsonb),
COALESCE($14::jsonb,'{"enabled":true,"speed_source":"utilization"}'::jsonb),
COALESCE($15::jsonb,'{}'::jsonb),
COALESCE($16,true), COALESCE($17,0),
COALESCE($18,false), COALESCE($19,false),
NULLIF($20,''), NULLIF($21,''))
RETURNING id::text
`, tenantID, mapID, src, dst,
nullUUID(e.SourceInterfaceID), nullUUID(e.TargetInterfaceID),
nullUUID(e.LinkID), e.Label, e.Style, e.Dash, e.Color, e.WidthPx,
rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds),
e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden,
e.SourceHandle, e.TargetHandle).Scan(&id); err != nil {
return err
}
if e.ClientID != "" {
res.EdgeIDs[e.ClientID] = id
}
continue
}
tag, err := tx.Exec(ctx, `
UPDATE topo.map_edges SET
label = COALESCE($4, label),
style = COALESCE($5::topo.edge_style, style),
dash = COALESCE($6::topo.edge_dash, dash),
color = COALESCE($7, color),
width_px = COALESCE($8, width_px),
waypoints = COALESCE($9::jsonb, waypoints),
animation = COALESCE($10::jsonb, animation),
thresholds = COALESCE($11::jsonb, thresholds),
show_metrics = COALESCE($12, show_metrics),
z_index = COALESCE($13, z_index),
locked = COALESCE($14, locked),
hidden = COALESCE($15, hidden),
source_interface_id = COALESCE($16, source_interface_id),
target_interface_id = COALESCE($17, target_interface_id),
link_id = COALESCE($18, link_id),
-- Бік підключення міняється трьома станами, а не двома:
-- порожньо — не чіпати, 'auto' — зняти привʼязку й
-- віддати вибік боку полотну, решта — конкретний бік.
-- Без цього лінію, що причепилась не до того краю,
-- лишалось хіба видалити й намалювати заново.
source_handle = CASE $19 WHEN '' THEN source_handle
WHEN 'auto' THEN NULL ELSE $19 END,
target_handle = CASE $20 WHEN '' THEN target_handle
WHEN 'auto' THEN NULL ELSE $20 END,
updated_at = now()
WHERE id = $1 AND map_id = $2 AND tenant_id = $3
`, e.ID, mapID, tenantID, e.Label, e.Style, e.Dash, e.Color, e.WidthPx,
rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds),
e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden,
nullUUID(e.SourceInterfaceID), nullUUID(e.TargetInterfaceID), nullUUID(e.LinkID),
e.SourceHandle, e.TargetHandle)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: ребро %s не належить цій мапі", ErrInvalid, e.ID)
}
}
return nil
}
func applyBackgrounds(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *BackgroundPatch, res *PatchResult) error {
if len(p.Remove) > 0 {
if _, err := tx.Exec(ctx, `
DELETE FROM topo.map_backgrounds
WHERE map_id = $1 AND tenant_id = $2 AND id = ANY($3::uuid[])
`, mapID, tenantID, p.Remove); err != nil {
return err
}
}
for i, b := range p.Upsert {
if b.ID == "" {
if b.Kind == "" {
return fmt.Errorf("%w: підкладка[%d] без kind", ErrInvalid, i)
}
var id string
if err := tx.QueryRow(ctx, `
INSERT INTO topo.map_backgrounds
(tenant_id, map_id, kind, storage_key, mime_type,
x, y, width, height, rotation, opacity, locked, z_index, geo, rack)
VALUES ($1,$2,$3::topo.background_kind,$4,$5,
COALESCE($6,0), COALESCE($7,0), $8, $9,
COALESCE($10,0), COALESCE($11,1),
COALESCE($12,true), COALESCE($13,0),
$14::jsonb, $15::jsonb)
RETURNING id::text
`, tenantID, mapID, b.Kind, b.StorageKey, b.MimeType,
b.X, b.Y, b.Width, b.Height, b.Rotation, b.Opacity,
b.Locked, b.ZIndex, rawArg(b.Geo), rawArg(b.Rack)).Scan(&id); err != nil {
return err
}
if b.ClientID != "" {
res.BgIDs[b.ClientID] = id
}
continue
}
tag, err := tx.Exec(ctx, `
UPDATE topo.map_backgrounds SET
storage_key = COALESCE($4, storage_key),
mime_type = COALESCE($5, mime_type),
x = COALESCE($6, x), y = COALESCE($7, y),
width = COALESCE($8, width), height = COALESCE($9, height),
rotation = COALESCE($10, rotation),
opacity = COALESCE($11, opacity),
locked = COALESCE($12, locked),
z_index = COALESCE($13, z_index),
geo = COALESCE($14::jsonb, geo),
rack = COALESCE($15::jsonb, rack),
updated_at = now()
WHERE id = $1 AND map_id = $2 AND tenant_id = $3
`, b.ID, mapID, tenantID, b.StorageKey, b.MimeType, b.X, b.Y,
b.Width, b.Height, b.Rotation, b.Opacity, b.Locked, b.ZIndex,
rawArg(b.Geo), rawArg(b.Rack))
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: підкладка %s не належить цій мапі", ErrInvalid, b.ID)
}
}
return nil
}
// snapshotMap зберігає повний стан полотна для undo.
//
// Знімок робиться в тій самій транзакції, що й зміна: інакше після збою
// в історії лишався б крок, якого в мапі немає, і відкат ламав би її.
func snapshotMap(ctx context.Context, tx pgx.Tx, tenantID, mapID string, revision int64, authorID, comment string) error {
if _, err := tx.Exec(ctx, `
INSERT INTO topo.map_revisions (tenant_id, map_id, revision, author_id, comment, snapshot)
SELECT $1, $2, $3, $4, NULLIF($5,''), jsonb_build_object(
'nodes', COALESCE((SELECT jsonb_agg(to_jsonb(n) ORDER BY n.id)
FROM topo.map_nodes n WHERE n.map_id = $2), '[]'::jsonb),
'edges', COALESCE((SELECT jsonb_agg(to_jsonb(e) ORDER BY e.id)
FROM topo.map_edges e WHERE e.map_id = $2), '[]'::jsonb),
'backgrounds', COALESCE((SELECT jsonb_agg(to_jsonb(b) ORDER BY b.id)
FROM topo.map_backgrounds b WHERE b.map_id = $2), '[]'::jsonb)
)
ON CONFLICT (map_id, revision) DO NOTHING
`, tenantID, mapID, revision, nullUUID(authorID), comment); err != nil {
return err
}
// Історія не має рости вічно: кожен знімок — повна копія полотна.
//
// Типи вказані явно: віднімання двох нетипізованих параметрів
// Postgres розібрати не може («operator is not unique: unknown - unknown»).
_, err := tx.Exec(ctx, `
DELETE FROM topo.map_revisions
WHERE map_id = $1 AND revision <= $2::bigint - $3::int
`, mapID, revision, maxRevisionsKept)
return err
}
// ---------------------------------------------------------------------
// Автопобудова
// ---------------------------------------------------------------------
type BuildStats struct {
NodesAdded int `json:"nodes_added"`
EdgesAdded int `json:"edges_added"`
Revision int64 `json:"revision"`
}
// BuildFromTopology доповнює мапу тим, що виявив агент.
//
// Ідемпотентна: вже наявні вузли й ребра не дублюються, координати
// існуючих не чіпаються. Це принципово — інакше кожен запуск скидав би
// розкладку, яку оператор робив руками.
//
// Нові вузли розставляються сіткою: осмислену розкладку дає лише
// клієнт (він знає розміри полотна й алгоритм), а сервер має покласти
// їх хоч кудись, але не в одну точку.
func (s *Store) BuildFromTopology(ctx context.Context, tenantID, mapID, authorID string) (*BuildStats, error) {
st := &BuildStats{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var revision int64
err := tx.QueryRow(ctx, `
SELECT revision FROM topo.maps
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
FOR UPDATE
`, mapID, tenantID).Scan(&revision)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
// Пристрої, які є в лінках, але ще не на мапі.
tag, err := tx.Exec(ctx, `
WITH existing AS (
SELECT count(*) AS n FROM topo.map_nodes WHERE map_id = $1
), missing AS (
SELECT d.id, d.name,
row_number() OVER (ORDER BY d.name) - 1 AS pos
FROM inv.devices d
WHERE d.tenant_id = $2
AND d.deleted_at IS NULL
AND d.enabled
AND EXISTS (
SELECT 1 FROM topo.links l
WHERE l.tenant_id = $2 AND (l.a_device_id = d.id OR l.b_device_id = d.id)
)
AND NOT EXISTS (
SELECT 1 FROM topo.map_nodes n
WHERE n.map_id = $1 AND n.device_id = d.id
)
)
INSERT INTO topo.map_nodes (tenant_id, map_id, kind, device_id, label, x, y)
SELECT $2, $1, 'device', m.id, m.name,
120 + 260 * ((existing.n + m.pos) % 6),
120 + 180 * ((existing.n + m.pos) / 6)
FROM missing m, existing
`, mapID, tenantID)
if err != nil {
return err
}
st.NodesAdded = int(tag.RowsAffected())
// Ребра для лінків, обидва кінці яких уже на мапі.
tag, err = tx.Exec(ctx, `
INSERT INTO topo.map_edges
(tenant_id, map_id, source_node_id, target_node_id,
source_interface_id, target_interface_id, link_id, label)
SELECT $2, $1, na.id, nb.id, l.a_interface_id, l.b_interface_id, l.id,
COALESCE(ia.name,'?') || ' → ' || COALESCE(ib.name,'?')
FROM topo.links l
JOIN topo.map_nodes na ON na.map_id = $1 AND na.device_id = l.a_device_id
JOIN topo.map_nodes nb ON nb.map_id = $1 AND nb.device_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
WHERE l.tenant_id = $2
AND NOT EXISTS (
SELECT 1 FROM topo.map_edges e
WHERE e.map_id = $1 AND e.link_id = l.id
)
`, mapID, tenantID)
if err != nil {
return err
}
st.EdgesAdded = int(tag.RowsAffected())
if st.NodesAdded == 0 && st.EdgesAdded == 0 {
st.Revision = revision
return nil
}
if err := tx.QueryRow(ctx, `
UPDATE topo.maps SET revision = revision + 1, updated_at = now()
WHERE id = $1 AND tenant_id = $2
RETURNING revision
`, mapID, tenantID).Scan(&st.Revision); err != nil {
return err
}
return snapshotMap(ctx, tx, tenantID, mapID, st.Revision, authorID,
fmt.Sprintf("автопобудова: +%d вузлів, +%d ребер", st.NodesAdded, st.EdgesAdded))
})
if err != nil {
return nil, mapPgError(err)
}
return st, nil
}
// ---------------------------------------------------------------------
// rawArg перетворює порожній json.RawMessage на NULL, щоб COALESCE у
// запиті лишив старе значення.
func rawArg(m json.RawMessage) any {
if len(m) == 0 {
return nil
}
return string(m)
}
// mapPgError перекладає помилки БД у доменні.
//
// Ліміт тарифу піднімає тригер bill.assert_device_limit /
// assert_map_node_limit з підказкою upgrade_plan. Клієнту треба
// показати пропозицію змінити тариф, а не «внутрішню помилку».
func mapPgError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Hint == "upgrade_plan" {
return fmt.Errorf("%w: %s", ErrPlanLimit, pgErr.Message)
}
switch pgErr.Code {
case "23505": // unique_violation
// Ім'я індексу — єдине, що відрізняє «намалював лінію
// вдруге» від «додав хост, який уже на схемі». Обидва
// випадки трапляються щодня, і загальне «такий запис уже
// існує» не каже людині, що саме робити далі.
switch pgErr.ConstraintName {
case "map_edges_ports_uniq":
return fmt.Errorf("%w: між цими вузлами вже є звʼязок — "+
"щоб змінити його вигляд, оберіть наявну лінію", ErrInvalid)
case "map_nodes_device_uniq":
return fmt.Errorf("%w: цей хост уже на схемі", ErrInvalid)
}
return fmt.Errorf("%w: такий запис уже існує", ErrInvalid)
case "23503": // foreign_key_violation
return fmt.Errorf("%w: посилання на неіснуючий об'єкт", ErrInvalid)
case "23514", "22P02": // check_violation, invalid_text_representation
return fmt.Errorf("%w: %s", ErrInvalid, pgErr.Message)
}
}
return err
}