Netpulse_SasS/server/migrations/0004_topology.sql
byrsapty 205dd5e079 Пакування: runner міграцій і вшитий у бінарник фронтенд
netpulse-migrate замість PowerShell-скрипта: у контейнері немає ані
psql, ані PowerShell, а тягнути клієнт Postgres в образ заради одного
запуску — це половина дистрибутива на порожньому місці.

Міграції вшиті через embed і переїхали в server/migrations: embed не
бачить нічого за межами кореня свого модуля, а міграції поруч із
бінарником, який їх накочує, не можуть розійтися версіями.

Накочування під advisory-блокуванням: два інстанси при rolling update
інакше застосували б ту саму міграцію двічі. Кожен файл в одній
транзакції разом із записом у schema_migrations; виняток — continuous
aggregates, які TimescaleDB забороняє в транзакції. Змінена вже
застосована міграція зупиняє запуск: у різних інсталяціях інакше
опиниться різна схема під одним номером.

Перевірено на чистій базі: 23 міграції, 101 таблиця, повторний запуск
каже «схема актуальна».

Веб віддає сам API через embed: на self-hosted це прибирає з інструкції
встановлення цілий компонент. Три політики кешування — назавжди для
assets із хешем у імені, ніколи для index.html, коротко для решти.

Знайдено живим прогоном: невідомий шлях під /api/ віддавав 200 з
index.html, і клієнт падав на розборі HTML як JSON замість чесного 404.

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

354 lines
18 KiB
SQL
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.

-- =====================================================================
-- NetPulse :: 0004_topology.sql
-- Мапи, вузли з координатами, зв'язки port->port, підкладки (floor plans),
-- сире автовиявлення (LLDP/CDP/ARP/FDB) та зведені фізичні лінки.
-- =====================================================================
-- =====================================================================
-- ЧАСТИНА A. ФІЗИЧНА ТОПОЛОГІЯ (те, що існує в мережі)
-- Не залежить від мап: один лінк може бути показаний на N мапах.
-- =====================================================================
CREATE TYPE topo.discovery_proto AS ENUM ('lldp','cdp','arp','fdb','stp','routing','manual','snmp_topo');
-- Сирі сусіди, як їх повідомив агент. Історія перезаписується per (interface, proto).
CREATE TABLE topo.neighbors (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
interface_id uuid REFERENCES inv.interfaces(id) ON DELETE CASCADE,
proto topo.discovery_proto NOT NULL,
-- Те, що бачимо "з іншого боку" (може ще не бути в інвентарі)
remote_chassis_id text,
remote_system_name text,
remote_port_id text,
remote_port_descr text,
remote_mgmt_ip inet,
remote_mac macaddr,
remote_platform text,
remote_capabilities text[], -- ['bridge','router','wlan-ap']
-- Резолвлений збіг з інвентарем (заповнює сервер-резолвер)
resolved_device_id uuid REFERENCES inv.devices(id) ON DELETE SET NULL,
resolved_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
confidence smallint NOT NULL DEFAULT 0 CHECK (confidence BETWEEN 0 AND 100),
first_seen_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
raw jsonb NOT NULL DEFAULT '{}'::jsonb
);
-- Ключ ідентичності сусіда залежить від протоколу, тому в індексі всі
-- три ознаки. LLDP/CDP розрізняють сусідів парою chassis+port; ARP і FDB
-- не мають жодної з них — там єдиний розрізняльник це MAC. Без нього всі
-- ARP-записи одного порту схлопуються в один рядок (перевірено на живій
-- ARP-таблиці: з двох сусідів зберігався один).
CREATE UNIQUE INDEX neighbors_uniq
ON topo.neighbors (device_id, COALESCE(interface_id, '00000000-0000-0000-0000-000000000000'::uuid),
proto, COALESCE(remote_chassis_id,''), COALESCE(remote_port_id,''),
COALESCE(remote_mac::text,''));
CREATE INDEX neighbors_tenant_idx ON topo.neighbors (tenant_id, last_seen_at DESC);
CREATE INDEX neighbors_resolved_idx ON topo.neighbors (resolved_device_id);
-- Зведений фізичний/логічний лінк між двома портами.
CREATE TYPE topo.link_kind AS ENUM ('physical','lag','wireless','vpn','stack','logical','virtual','power','serial');
CREATE TABLE topo.links (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
a_device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
a_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
b_device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
b_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
kind topo.link_kind NOT NULL DEFAULT 'physical',
-- Пропускна здатність каналу (для % завантаження та швидкості анімації)
capacity_bps bigint,
discovered_by topo.discovery_proto NOT NULL DEFAULT 'manual',
confidence smallint NOT NULL DEFAULT 100 CHECK (confidence BETWEEN 0 AND 100),
-- Підтверджений людиною лінк не перезаписується автовиявленням
is_pinned boolean NOT NULL DEFAULT false,
status inv.device_status NOT NULL DEFAULT 'unknown',
status_changed_at timestamptz,
first_seen_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
CHECK (a_device_id <> b_device_id OR a_interface_id IS DISTINCT FROM b_interface_id)
);
-- Нормалізований ключ, щоб A->B і B->A не дублювались.
CREATE UNIQUE INDEX links_pair_uniq ON topo.links (
tenant_id,
LEAST(a_device_id, b_device_id),
GREATEST(a_device_id, b_device_id),
LEAST(COALESCE(a_interface_id,'00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(b_interface_id,'00000000-0000-0000-0000-000000000000'::uuid)),
GREATEST(COALESCE(a_interface_id,'00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(b_interface_id,'00000000-0000-0000-0000-000000000000'::uuid))
);
CREATE INDEX links_a_idx ON topo.links (a_device_id);
CREATE INDEX links_b_idx ON topo.links (b_device_id);
CREATE INDEX links_tenant_status_idx ON topo.links (tenant_id, status);
-- Запуски автовиявлення (для UI "Discovery in progress / знайдено N лінків")
CREATE TYPE topo.discovery_status AS ENUM ('queued','running','completed','failed','cancelled');
CREATE TABLE topo.discovery_runs (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
agent_id uuid REFERENCES core.agents(id) ON DELETE SET NULL,
started_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
scope jsonb NOT NULL DEFAULT '{}'::jsonb, -- {"subnets":["10.0.0.0/24"],"protos":["lldp","cdp"]}
status topo.discovery_status NOT NULL DEFAULT 'queued',
devices_found int NOT NULL DEFAULT 0,
links_found int NOT NULL DEFAULT 0,
error text,
started_at timestamptz,
finished_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX discovery_runs_tenant_idx ON topo.discovery_runs (tenant_id, created_at DESC);
-- =====================================================================
-- ЧАСТИНА B. ВІЗУАЛЬНІ МАПИ (те, що бачить користувач)
-- =====================================================================
CREATE TYPE topo.map_kind AS ENUM ('logical','floor_plan','rack','geo','auto');
CREATE TYPE topo.layout_algo AS ENUM ('manual','grid','tree','circular','force','hierarchical','dagre');
CREATE TABLE topo.maps (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
site_id uuid REFERENCES inv.sites(id) ON DELETE SET NULL,
parent_map_id uuid REFERENCES topo.maps(id) ON DELETE SET NULL, -- drill-down підмапи
name text NOT NULL,
slug core.slug NOT NULL,
kind topo.map_kind NOT NULL DEFAULT 'logical',
description text,
-- Стан полотна
layout_algo topo.layout_algo NOT NULL DEFAULT 'manual',
viewport jsonb NOT NULL DEFAULT '{"x":0,"y":0,"zoom":1}'::jsonb,
grid jsonb NOT NULL DEFAULT '{"enabled":true,"size":16,"snap":true}'::jsonb,
-- Кластеризація при віддаленні: {"enabled":true,"zoom_threshold":0.4,"by":"site"}
clustering jsonb NOT NULL DEFAULT '{"enabled":true,"zoom_threshold":0.4}'::jsonb,
-- Правила автододавання пристроїв (kind='auto'): фільтр як у dynamic-групах
auto_rule jsonb,
theme jsonb NOT NULL DEFAULT '{}'::jsonb,
is_default boolean NOT NULL DEFAULT false,
-- Публічний read-only лінк (status page)
public_token text UNIQUE,
-- Оптимістичне блокування при спільному редагуванні
revision bigint NOT NULL DEFAULT 1,
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
UNIQUE (tenant_id, slug)
);
CREATE INDEX maps_tenant_idx ON topo.maps (tenant_id) WHERE deleted_at IS NULL;
CREATE TRIGGER trg_maps_touch BEFORE UPDATE ON topo.maps
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
-- ---------------------------------------------------------------------
-- Підкладки: план офісу / серверної (PNG/SVG), схема стійки, OSM
-- ---------------------------------------------------------------------
CREATE TYPE topo.background_kind AS ENUM ('image','svg','rack','osm','tile','none');
CREATE TABLE topo.map_backgrounds (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
kind topo.background_kind NOT NULL DEFAULT 'image',
z_index int NOT NULL DEFAULT 0,
-- Для image/svg: об'єкт у S3/MinIO
storage_key text,
mime_type text,
file_size bigint,
natural_width int,
natural_height int,
-- Розміщення підкладки на полотні
x double precision NOT NULL DEFAULT 0,
y double precision NOT NULL DEFAULT 0,
width double precision,
height double precision,
rotation double precision NOT NULL DEFAULT 0,
opacity double precision NOT NULL DEFAULT 1 CHECK (opacity BETWEEN 0 AND 1),
locked boolean NOT NULL DEFAULT true,
-- Для kind='osm'/'tile': географічна прив'язка полотна
-- {"center":[50.45,30.52],"zoom":12,"bounds":[[..],[..]],"tile_url":"..."}
geo jsonb,
-- Для kind='rack': {"units":42,"orientation":"front"}
rack jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX map_backgrounds_map_idx ON topo.map_backgrounds (map_id, z_index);
-- ---------------------------------------------------------------------
-- Вузли мапи
-- ---------------------------------------------------------------------
CREATE TYPE topo.node_kind AS ENUM (
'device', -- прив'язаний до inv.devices
'group', -- згорнутий кластер / контейнер
'cloud', -- Internet / провайдер
'text', -- анотація
'shape', -- прямокутник/еліпс/лінія-роздільник
'image', -- іконка/логотип
'link_map', -- перехід на іншу мапу (drill-down)
'metric' -- міні-віджет з графіком/значенням
);
CREATE TABLE topo.map_nodes (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
kind topo.node_kind NOT NULL DEFAULT 'device',
-- Прив'язки (за kind)
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
group_id uuid REFERENCES inv.device_groups(id) ON DELETE SET NULL,
target_map_id uuid REFERENCES topo.maps(id) ON DELETE SET NULL,
-- Вкладеність у контейнер/кластер на цій же мапі
parent_node_id uuid REFERENCES topo.map_nodes(id) ON DELETE CASCADE,
label text,
-- Геометрія полотна (React Flow / Cytoscape): координати у світових одиницях
x double precision NOT NULL DEFAULT 0,
y double precision NOT NULL DEFAULT 0,
width double precision,
height double precision,
rotation double precision NOT NULL DEFAULT 0,
z_index int NOT NULL DEFAULT 0,
-- Гео-координати для kind='geo' мап (мають пріоритет над x/y при рендері OSM)
lat double precision CHECK (lat BETWEEN -90 AND 90),
lon double precision CHECK (lon BETWEEN -180 AND 180),
-- Позиція в стійці для kind='rack'
rack_unit int,
-- Вигляд: {"icon":"switch","color":"#22c55e","shape":"rounded","showMetrics":["cpu"]}
style jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Довільні дані ноди (текст анотації, конфіг міні-віджета)
data jsonb NOT NULL DEFAULT '{}'::jsonb,
collapsed boolean NOT NULL DEFAULT false,
locked boolean NOT NULL DEFAULT false,
hidden boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Цілісність: device-нода зобов'язана мати device_id, link_map — target_map_id
CONSTRAINT map_nodes_kind_ref_chk CHECK (
(kind = 'device' AND device_id IS NOT NULL) OR
(kind = 'link_map' AND target_map_id IS NOT NULL) OR
(kind NOT IN ('device','link_map'))
)
);
-- Один пристрій — одна нода на мапі
CREATE UNIQUE INDEX map_nodes_device_uniq ON topo.map_nodes (map_id, device_id)
WHERE device_id IS NOT NULL;
CREATE INDEX map_nodes_map_idx ON topo.map_nodes (map_id);
CREATE INDEX map_nodes_device_idx ON topo.map_nodes (device_id);
CREATE INDEX map_nodes_parent_idx ON topo.map_nodes (parent_node_id);
-- Просторовий індекс для viewport-culling при 1000+ вузлах
CREATE INDEX map_nodes_bbox_idx ON topo.map_nodes USING gist (point(x, y));
-- ---------------------------------------------------------------------
-- Ребра мапи (візуальне представлення лінка)
-- ---------------------------------------------------------------------
CREATE TYPE topo.edge_style AS ENUM ('straight','bezier','smoothstep','step','orthogonal','arc');
CREATE TYPE topo.edge_dash AS ENUM ('solid','dashed','dotted','dashdot');
CREATE TABLE topo.map_edges (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
source_node_id uuid NOT NULL REFERENCES topo.map_nodes(id) ON DELETE CASCADE,
target_node_id uuid NOT NULL REFERENCES topo.map_nodes(id) ON DELETE CASCADE,
-- Конкретні порти: Switch1:Port1 -> Router1:eth0
source_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
target_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
-- Прив'язка до фізичного лінка (звідки беруться статус і трафік)
link_id uuid REFERENCES topo.links(id) ON DELETE SET NULL,
label text,
-- Точки прив'язки на ноді: top|right|bottom|left|auto або якір "port:<if_id>"
source_handle text,
target_handle text,
style topo.edge_style NOT NULL DEFAULT 'smoothstep',
dash topo.edge_dash NOT NULL DEFAULT 'solid',
color text,
width_px double precision NOT NULL DEFAULT 2,
-- Проміжні точки для ручного розведення ліній: [{"x":10,"y":20},...]
waypoints jsonb NOT NULL DEFAULT '[]'::jsonb,
-- Анімація трафіку. speed_source: 'utilization' | 'fixed' | 'off'
-- {"enabled":true,"speed_source":"utilization","direction":"a_to_b","max_speed":3}
animation jsonb NOT NULL DEFAULT '{"enabled":true,"speed_source":"utilization"}'::jsonb,
-- Пороги фарбування: {"warn_pct":70,"crit_pct":90,"loss_warn":1,"loss_crit":5}
thresholds jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Показувати підпис зі швидкістю/втратами на лінії
show_metrics boolean NOT NULL DEFAULT true,
z_index int NOT NULL DEFAULT 0,
locked boolean NOT NULL DEFAULT false,
hidden boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (source_node_id <> target_node_id)
);
CREATE INDEX map_edges_map_idx ON topo.map_edges (map_id);
CREATE INDEX map_edges_source_idx ON topo.map_edges (source_node_id);
CREATE INDEX map_edges_target_idx ON topo.map_edges (target_node_id);
CREATE INDEX map_edges_link_idx ON topo.map_edges (link_id);
-- Одне ребро на пару портів у межах мапи
CREATE UNIQUE INDEX map_edges_ports_uniq ON topo.map_edges (
map_id, source_node_id, target_node_id,
COALESCE(source_interface_id,'00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(target_interface_id,'00000000-0000-0000-0000-000000000000'::uuid)
);
-- ---------------------------------------------------------------------
-- Версії мап (undo/redo, відкат після невдалого автолейауту)
-- ---------------------------------------------------------------------
CREATE TABLE topo.map_revisions (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
revision bigint NOT NULL,
author_id uuid REFERENCES core.users(id) ON DELETE SET NULL,
comment text,
-- Повний знімок {nodes:[...], edges:[...], backgrounds:[...]} (стиснутий на боці API)
snapshot jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (map_id, revision)
);
CREATE INDEX map_revisions_map_idx ON topo.map_revisions (map_id, revision DESC);
-- Доступ до мапи окремим користувачам/ролям (понад загальний RBAC)
CREATE TABLE topo.map_shares (
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
user_id uuid REFERENCES core.users(id) ON DELETE CASCADE,
role_id uuid REFERENCES core.roles(id) ON DELETE CASCADE,
can_edit boolean NOT NULL DEFAULT false,
CHECK (user_id IS NOT NULL OR role_id IS NOT NULL)
);
CREATE UNIQUE INDEX map_shares_uniq ON topo.map_shares (
map_id,
COALESCE(user_id,'00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(role_id,'00000000-0000-0000-0000-000000000000'::uuid)
);