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>
392 lines
17 KiB
SQL
392 lines
17 KiB
SQL
-- =====================================================================
|
||
-- 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;
|