Плагін http стояв у сіді як базовий — тобто обіцяний усім одразу, — а модуля не існувало. Тепер є: код відповіді, час, збіг слова в тілі й залишок днів до кінця сертифіката. Недоступність повертається нулем, а не помилкою чека: на графіку це читається як провал, і саме за цим ставлять тригер. Довіру до ланцюга сертифікатів свідомо не перевіряємо — питають строк, а самопідписаний теж має дату. Повторна лінія між вузлами й повторно доданий хост давали однакове «такий запис уже існує». Тепер кожен випадок каже, що робити далі. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
689 lines
26 KiB
Go
689 lines
26 KiB
Go
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(¤t)
|
||
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
|
||
}
|