Netpulse_SasS/server/migrations/0005_telemetry_timescale.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

392 lines
17 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 :: 0005_telemetry_timescale.sql
-- Time-series: узагальнені метрики (series/samples), гарячі шляхи
-- (ICMP, лічильники інтерфейсів, статус лінків), CAGG, стиснення, retention.
--
-- УВАГА: цей файл НЕ можна виконувати в одній транзакції —
-- CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous) заборонено
-- всередині транзакційного блоку. migrate.ps1 визначає це автоматично.
-- =====================================================================
-- ---------------------------------------------------------------------
-- A. Узагальнена модель метрик (Prometheus-подібна): series + samples.
-- Будь-який плагін реєструє власний metric_key без зміни DDL.
-- ---------------------------------------------------------------------
CREATE TABLE ts.series (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
interface_id uuid REFERENCES inv.interfaces(id) ON DELETE CASCADE,
plugin_key core.slug,
metric_key text NOT NULL, -- cpu.util, mem.used, ups.battery.pct, sensor.temp
unit text, -- pct, bps, C, V, ms
-- Додаткові виміри: {"core":"0","oid":"...","phase":"L1"}
labels jsonb NOT NULL DEFAULT '{}'::jsonb,
labels_hash uuid GENERATED ALWAYS AS (md5(labels::text)::uuid) STORED,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, device_id, metric_key, labels_hash)
);
CREATE INDEX series_device_metric_idx ON ts.series (device_id, metric_key);
CREATE INDEX series_iface_idx ON ts.series (interface_id) WHERE interface_id IS NOT NULL;
CREATE TABLE ts.samples (
ts timestamptz NOT NULL,
series_id bigint NOT NULL REFERENCES ts.series(id) ON DELETE CASCADE,
value double precision NOT NULL,
PRIMARY KEY (ts, series_id)
);
SELECT create_hypertable('ts.samples', 'ts',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => TRUE);
CREATE INDEX samples_series_ts_idx ON ts.samples (series_id, ts DESC);
-- ---------------------------------------------------------------------
-- B. ICMP — гарячий шлях: колір ноди й лінії на мапі
-- ---------------------------------------------------------------------
CREATE TABLE ts.icmp_samples (
ts timestamptz NOT NULL,
device_id uuid NOT NULL,
tenant_id uuid NOT NULL,
agent_id uuid,
rtt_avg_ms real,
rtt_min_ms real,
rtt_max_ms real,
jitter_ms real,
loss_pct real NOT NULL DEFAULT 0,
packets_sent smallint NOT NULL DEFAULT 0,
packets_recv smallint NOT NULL DEFAULT 0,
reachable boolean NOT NULL,
PRIMARY KEY (ts, device_id)
);
SELECT create_hypertable('ts.icmp_samples', 'ts',
chunk_time_interval => INTERVAL '1 day', if_not_exists => TRUE);
CREATE INDEX icmp_device_ts_idx ON ts.icmp_samples (device_id, ts DESC);
CREATE INDEX icmp_tenant_ts_idx ON ts.icmp_samples (tenant_id, ts DESC);
-- ---------------------------------------------------------------------
-- C. Лічильники інтерфейсів — джерело швидкості анімації трафіку
-- ---------------------------------------------------------------------
CREATE TABLE ts.if_counters (
ts timestamptz NOT NULL,
interface_id uuid NOT NULL,
device_id uuid NOT NULL,
tenant_id uuid NOT NULL,
-- Сирі 64-бітні лічильники (для точного перерахунку після рестарту пристрою)
in_octets bigint,
out_octets bigint,
in_ucast_pkts bigint,
out_ucast_pkts bigint,
in_errors bigint,
out_errors bigint,
in_discards bigint,
out_discards bigint,
-- Похідні швидкості, пораховані агентом (уже з урахуванням wrap/reset)
in_bps double precision,
out_bps double precision,
in_pps double precision,
out_pps double precision,
-- % завантаження від speed_bps — саме це керує анімацією та кольором лінії
util_in_pct real,
util_out_pct real,
oper_up boolean,
PRIMARY KEY (ts, interface_id)
);
SELECT create_hypertable('ts.if_counters', 'ts',
chunk_time_interval => INTERVAL '1 day', if_not_exists => TRUE);
CREATE INDEX ifc_iface_ts_idx ON ts.if_counters (interface_id, ts DESC);
CREATE INDEX ifc_device_ts_idx ON ts.if_counters (device_id, ts DESC);
-- ---------------------------------------------------------------------
-- D. Статус лінків — те, що WebSocket транслює на мапу
-- ---------------------------------------------------------------------
CREATE TABLE ts.link_status (
ts timestamptz NOT NULL,
link_id uuid NOT NULL,
tenant_id uuid NOT NULL,
status inv.device_status NOT NULL,
latency_ms real,
loss_pct real,
util_pct real,
PRIMARY KEY (ts, link_id)
);
SELECT create_hypertable('ts.link_status', 'ts',
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
CREATE INDEX link_status_link_ts_idx ON ts.link_status (link_id, ts DESC);
-- Історія станів пристроїв (SLA / uptime-звіти)
CREATE TABLE ts.device_status_history (
ts timestamptz NOT NULL,
device_id uuid NOT NULL,
tenant_id uuid NOT NULL,
status inv.device_status NOT NULL,
prev_status inv.device_status,
reason text,
PRIMARY KEY (ts, device_id)
);
SELECT create_hypertable('ts.device_status_history', 'ts',
chunk_time_interval => INTERVAL '30 days', if_not_exists => TRUE);
CREATE INDEX dsh_device_ts_idx ON ts.device_status_history (device_id, ts DESC);
-- ---------------------------------------------------------------------
-- E. Syslog / SNMP-трапи (тригер бекапу конфігу в NCM)
-- ---------------------------------------------------------------------
CREATE TABLE ts.syslog (
ts timestamptz NOT NULL,
id uuid NOT NULL DEFAULT core.new_id(),
tenant_id uuid NOT NULL,
device_id uuid,
source_ip inet,
facility smallint,
severity smallint,
hostname text,
tag text,
message text NOT NULL,
parsed jsonb,
PRIMARY KEY (ts, id)
);
SELECT create_hypertable('ts.syslog', 'ts',
chunk_time_interval => INTERVAL '1 day', if_not_exists => TRUE);
CREATE INDEX syslog_device_ts_idx ON ts.syslog (device_id, ts DESC);
CREATE INDEX syslog_msg_trgm_idx ON ts.syslog USING gin (message gin_trgm_ops);
CREATE TABLE ts.snmp_traps (
ts timestamptz NOT NULL,
id uuid NOT NULL DEFAULT core.new_id(),
tenant_id uuid NOT NULL,
device_id uuid,
source_ip inet,
trap_oid text,
varbinds jsonb NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (ts, id)
);
SELECT create_hypertable('ts.snmp_traps', 'ts',
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
CREATE INDEX traps_device_ts_idx ON ts.snmp_traps (device_id, ts DESC);
-- Самометрики агентів (RAM < 30 МБ — контролюємо фактом, не обіцянкою)
CREATE TABLE ts.agent_health (
ts timestamptz NOT NULL,
agent_id uuid NOT NULL,
tenant_id uuid NOT NULL,
cpu_pct real,
rss_bytes bigint,
goroutines int,
queue_depth int,
checks_per_sec real,
errors_per_min real,
PRIMARY KEY (ts, agent_id)
);
SELECT create_hypertable('ts.agent_health', 'ts',
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
-- =====================================================================
-- F. CONTINUOUS AGGREGATES
-- Графіки за 30 днів не читають сирі дані — лише роллапи.
-- =====================================================================
CREATE MATERIALIZED VIEW ts.icmp_5m
WITH (timescaledb.continuous) AS
SELECT time_bucket(INTERVAL '5 minutes', ts) AS bucket,
device_id,
tenant_id,
avg(rtt_avg_ms)::real AS rtt_avg_ms,
min(rtt_min_ms)::real AS rtt_min_ms,
max(rtt_max_ms)::real AS rtt_max_ms,
avg(jitter_ms)::real AS jitter_ms,
avg(loss_pct)::real AS loss_pct,
sum(packets_sent) AS packets_sent,
sum(packets_recv) AS packets_recv,
count(*) FILTER (WHERE NOT reachable) AS down_samples,
count(*) AS samples
FROM ts.icmp_samples
GROUP BY bucket, device_id, tenant_id
WITH NO DATA;
CREATE MATERIALIZED VIEW ts.icmp_1h
WITH (timescaledb.continuous) AS
SELECT time_bucket(INTERVAL '1 hour', bucket) AS bucket,
device_id,
tenant_id,
avg(rtt_avg_ms)::real AS rtt_avg_ms,
min(rtt_min_ms)::real AS rtt_min_ms,
max(rtt_max_ms)::real AS rtt_max_ms,
avg(loss_pct)::real AS loss_pct,
sum(down_samples) AS down_samples,
sum(samples) AS samples
FROM ts.icmp_5m
GROUP BY 1, 2, 3
WITH NO DATA;
CREATE MATERIALIZED VIEW ts.if_counters_5m
WITH (timescaledb.continuous) AS
SELECT time_bucket(INTERVAL '5 minutes', ts) AS bucket,
interface_id,
device_id,
tenant_id,
avg(in_bps) AS in_bps_avg,
max(in_bps) AS in_bps_max,
avg(out_bps) AS out_bps_avg,
max(out_bps) AS out_bps_max,
avg(util_in_pct)::real AS util_in_avg,
max(util_in_pct)::real AS util_in_max,
avg(util_out_pct)::real AS util_out_avg,
max(util_out_pct)::real AS util_out_max,
sum(in_errors) AS in_errors,
sum(out_errors) AS out_errors,
sum(in_discards) AS in_discards,
sum(out_discards) AS out_discards
FROM ts.if_counters
GROUP BY bucket, interface_id, device_id, tenant_id
WITH NO DATA;
CREATE MATERIALIZED VIEW ts.if_counters_1h
WITH (timescaledb.continuous) AS
SELECT time_bucket(INTERVAL '1 hour', bucket) AS bucket,
interface_id, device_id, tenant_id,
avg(in_bps_avg) AS in_bps_avg,
max(in_bps_max) AS in_bps_max,
avg(out_bps_avg) AS out_bps_avg,
max(out_bps_max) AS out_bps_max,
avg(util_in_avg)::real AS util_in_avg,
max(util_in_max)::real AS util_in_max,
avg(util_out_avg)::real AS util_out_avg,
max(util_out_max)::real AS util_out_max
FROM ts.if_counters_5m
GROUP BY 1, 2, 3, 4
WITH NO DATA;
CREATE MATERIALIZED VIEW ts.samples_5m
WITH (timescaledb.continuous) AS
SELECT time_bucket(INTERVAL '5 minutes', ts) AS bucket,
series_id,
avg(value) AS avg_value,
min(value) AS min_value,
max(value) AS max_value,
last(value, ts) AS last_value,
count(*) AS samples
FROM ts.samples
GROUP BY bucket, series_id
WITH NO DATA;
CREATE MATERIALIZED VIEW ts.samples_1h
WITH (timescaledb.continuous) AS
SELECT time_bucket(INTERVAL '1 hour', bucket) AS bucket,
series_id,
avg(avg_value) AS avg_value,
min(min_value) AS min_value,
max(max_value) AS max_value,
sum(samples) AS samples
FROM ts.samples_5m
GROUP BY 1, 2
WITH NO DATA;
-- Політики оновлення роллапів
SELECT add_continuous_aggregate_policy('ts.icmp_5m',
start_offset => INTERVAL '3 hours', end_offset => INTERVAL '5 minutes',
schedule_interval => INTERVAL '5 minutes', if_not_exists => TRUE);
SELECT add_continuous_aggregate_policy('ts.icmp_1h',
start_offset => INTERVAL '2 days', end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour', if_not_exists => TRUE);
SELECT add_continuous_aggregate_policy('ts.if_counters_5m',
start_offset => INTERVAL '3 hours', end_offset => INTERVAL '5 minutes',
schedule_interval => INTERVAL '5 minutes', if_not_exists => TRUE);
SELECT add_continuous_aggregate_policy('ts.if_counters_1h',
start_offset => INTERVAL '2 days', end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour', if_not_exists => TRUE);
SELECT add_continuous_aggregate_policy('ts.samples_5m',
start_offset => INTERVAL '3 hours', end_offset => INTERVAL '5 minutes',
schedule_interval => INTERVAL '5 minutes', if_not_exists => TRUE);
SELECT add_continuous_aggregate_policy('ts.samples_1h',
start_offset => INTERVAL '2 days', end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour', if_not_exists => TRUE);
-- =====================================================================
-- G. СТИСНЕННЯ ТА RETENTION
-- =====================================================================
ALTER TABLE ts.samples SET (timescaledb.compress,
timescaledb.compress_segmentby = 'series_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE ts.icmp_samples SET (timescaledb.compress,
timescaledb.compress_segmentby = 'device_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE ts.if_counters SET (timescaledb.compress,
timescaledb.compress_segmentby = 'interface_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE ts.link_status SET (timescaledb.compress,
timescaledb.compress_segmentby = 'link_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE ts.syslog SET (timescaledb.compress,
timescaledb.compress_segmentby = 'tenant_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE ts.snmp_traps SET (timescaledb.compress,
timescaledb.compress_segmentby = 'tenant_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE ts.agent_health SET (timescaledb.compress,
timescaledb.compress_segmentby = 'agent_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE core.audit_log SET (timescaledb.compress,
timescaledb.compress_segmentby = 'tenant_id',
timescaledb.compress_orderby = 'ts DESC');
SELECT add_compression_policy('ts.samples', INTERVAL '2 days', if_not_exists => TRUE);
SELECT add_compression_policy('ts.icmp_samples', INTERVAL '2 days', if_not_exists => TRUE);
SELECT add_compression_policy('ts.if_counters', INTERVAL '2 days', if_not_exists => TRUE);
SELECT add_compression_policy('ts.link_status', INTERVAL '7 days', if_not_exists => TRUE);
SELECT add_compression_policy('ts.syslog', INTERVAL '3 days', if_not_exists => TRUE);
SELECT add_compression_policy('ts.snmp_traps', INTERVAL '7 days', if_not_exists => TRUE);
SELECT add_compression_policy('ts.agent_health', INTERVAL '3 days', if_not_exists => TRUE);
SELECT add_compression_policy('core.audit_log', INTERVAL '30 days', if_not_exists => TRUE);
-- Retention: значення за замовчуванням для Pro. Для Enterprise
-- застосунок перевизначає політику через remove_retention_policy/add_retention_policy.
SELECT add_retention_policy('ts.samples', INTERVAL '35 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.icmp_samples', INTERVAL '35 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.if_counters', INTERVAL '35 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.syslog', INTERVAL '90 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.snmp_traps', INTERVAL '90 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.agent_health', INTERVAL '14 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.samples_5m', INTERVAL '400 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.if_counters_5m', INTERVAL '400 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.icmp_5m', INTERVAL '400 days', if_not_exists => TRUE);
-- 1h-роллапи не видаляємо: це база для SLA-звітів і білінгових графіків.
-- =====================================================================
-- H. ХЕЛПЕРИ ДЛЯ РЕНДЕРУ МАПИ
-- Один запит на весь стан полотна замість N+1.
-- =====================================================================
-- Останній ICMP-семпл кожного пристрою (SkipScan по (device_id, ts DESC))
CREATE OR REPLACE VIEW ts.device_last_icmp AS
SELECT DISTINCT ON (device_id)
device_id, ts, rtt_avg_ms, loss_pct, jitter_ms, reachable
FROM ts.icmp_samples
WHERE ts > now() - INTERVAL '15 minutes'
ORDER BY device_id, ts DESC;
-- Поточне завантаження лінка: беремо гіршу з двох сторін
CREATE OR REPLACE VIEW topo.link_live AS
SELECT l.id AS link_id,
l.tenant_id,
l.status,
l.capacity_bps,
GREATEST(COALESCE(a.util_out_pct, 0), COALESCE(b.util_out_pct, 0)) AS util_pct,
COALESCE(a.out_bps, 0) AS a_out_bps,
COALESCE(b.out_bps, 0) AS b_out_bps,
GREATEST(a.ts, b.ts) AS ts
FROM topo.links l
LEFT JOIN LATERAL (
SELECT * FROM ts.if_counters c
WHERE c.interface_id = l.a_interface_id AND c.ts > now() - INTERVAL '15 minutes'
ORDER BY c.ts DESC LIMIT 1
) a ON true
LEFT JOIN LATERAL (
SELECT * FROM ts.if_counters c
WHERE c.interface_id = l.b_interface_id AND c.ts > now() - INTERVAL '15 minutes'
ORDER BY c.ts DESC LIMIT 1
) b ON true;