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>
354 lines
18 KiB
SQL
354 lines
18 KiB
SQL
-- =====================================================================
|
||
-- 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)
|
||
);
|