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) }