Netpulse_SasS/server/migrations/0007_alerting.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

233 lines
11 KiB
SQL
Raw Permalink 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 :: 0007_alerting.sql
-- Правила тригерів, алерти, ескалації, канали доставки (Telegram/PWA Push),
-- вікна обслуговування, придушення шуму.
-- =====================================================================
CREATE TYPE alr.severity AS ENUM ('info','warning','average','high','disaster');
CREATE TYPE alr.alert_state AS ENUM ('firing','acknowledged','suppressed','resolved','expired');
-- ---------------------------------------------------------------------
-- Правила
-- ---------------------------------------------------------------------
CREATE TYPE alr.rule_source AS ENUM ('metric','icmp','interface','link','syslog','trap','ncm','agent','compliance');
CREATE TABLE alr.rules (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
source alr.rule_source NOT NULL,
severity alr.severity NOT NULL DEFAULT 'warning',
-- До чого застосовується: пристрої/групи/теги (той самий формат фільтра)
selector jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Умова. Приклади:
-- metric: {"metric_key":"cpu.util","agg":"avg","window":"5m","op":">","value":85}
-- icmp: {"op":"loss_pct >","value":20,"for":"3m"}
-- interface: {"metric":"util_out_pct","op":">","value":90,"for":"10m"}
-- syslog: {"regex":"%LINK-3-UPDOWN.*down"}
condition jsonb NOT NULL,
-- Гістерезис: умова зняття алерту (якщо NULL — інверсія condition)
recovery jsonb,
-- Скільки триматися умові перед firing (антифлап)
for_seconds int NOT NULL DEFAULT 60,
-- Не піднімати алерт, якщо батьківський вузол на мапі вже down
depends_on_topology boolean NOT NULL DEFAULT true,
enabled boolean NOT NULL DEFAULT true,
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(),
UNIQUE (tenant_id, name)
);
CREATE INDEX alr_rules_tenant_idx ON alr.rules (tenant_id) WHERE enabled;
-- ---------------------------------------------------------------------
-- Алерти
-- ---------------------------------------------------------------------
CREATE TABLE alr.alerts (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
rule_id uuid REFERENCES alr.rules(id) ON DELETE SET NULL,
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
interface_id uuid REFERENCES inv.interfaces(id) ON DELETE CASCADE,
link_id uuid REFERENCES topo.links(id) ON DELETE CASCADE,
severity alr.severity NOT NULL,
state alr.alert_state NOT NULL DEFAULT 'firing',
title text NOT NULL,
message text,
-- Ключ дедуплікації: rule_id + object → один активний алерт
dedup_key text NOT NULL,
value double precision,
threshold double precision,
context jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Кореляція: кореневий алерт (маршрутизатор впав → 40 пристроїв за ним)
root_alert_id uuid REFERENCES alr.alerts(id) ON DELETE SET NULL,
suppressed_by text, -- 'maintenance' | 'topology' | 'dependency'
started_at timestamptz NOT NULL DEFAULT now(),
acked_at timestamptz,
acked_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
ack_comment text,
resolved_at timestamptz,
last_seen_at timestamptz NOT NULL DEFAULT now(),
notify_count int NOT NULL DEFAULT 0
);
-- Один активний алерт на dedup_key
CREATE UNIQUE INDEX alerts_active_dedup_uniq ON alr.alerts (tenant_id, dedup_key)
WHERE state IN ('firing','acknowledged','suppressed');
CREATE INDEX alerts_tenant_state_idx ON alr.alerts (tenant_id, state, severity, started_at DESC);
CREATE INDEX alerts_device_idx ON alr.alerts (device_id, started_at DESC);
CREATE INDEX alerts_link_idx ON alr.alerts (link_id) WHERE state = 'firing';
CREATE INDEX alerts_root_idx ON alr.alerts (root_alert_id);
-- Архів (після resolve алерти переносяться сюди фоном; hypertable)
CREATE TABLE alr.alerts_history (
ts timestamptz NOT NULL,
id uuid NOT NULL,
tenant_id uuid NOT NULL,
rule_id uuid,
device_id uuid,
severity alr.severity NOT NULL,
title text NOT NULL,
started_at timestamptz NOT NULL,
resolved_at timestamptz,
duration_sec int,
acked_by uuid,
context jsonb NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (ts, id)
);
SELECT create_hypertable('alr.alerts_history', 'ts',
chunk_time_interval => INTERVAL '30 days', if_not_exists => TRUE);
CREATE INDEX alerts_hist_tenant_idx ON alr.alerts_history (tenant_id, ts DESC);
-- ---------------------------------------------------------------------
-- Канали доставки
-- ---------------------------------------------------------------------
CREATE TYPE alr.channel_kind AS ENUM (
'email','telegram','webhook','slack','discord','sms','web_push','pagerduty','mattermost','viber'
);
CREATE TABLE alr.channels (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
kind alr.channel_kind NOT NULL,
name text NOT NULL,
-- Несекретна частина: {"chat_id":-100123,"thread_id":5} / {"url":"https://..."}
config jsonb NOT NULL DEFAULT '{}'::jsonb,
secret_id uuid REFERENCES core.secrets(id) ON DELETE SET NULL,
-- Кастомний шаблон повідомлення (Go template / Handlebars)
template text,
min_severity alr.severity NOT NULL DEFAULT 'warning',
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
-- Підписки PWA Web Push (на користувача, не на тенант)
CREATE TABLE alr.push_subscriptions (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
endpoint text NOT NULL,
p256dh text NOT NULL,
auth text NOT NULL,
user_agent text,
last_success_at timestamptz,
failure_count int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, endpoint)
);
-- ---------------------------------------------------------------------
-- Маршрутизація та ескалація
-- ---------------------------------------------------------------------
CREATE TABLE alr.escalation_policies (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
-- Кому і коли: [{"after_min":0,"channels":[...]},{"after_min":15,"channels":[...]}]
steps jsonb NOT NULL DEFAULT '[]'::jsonb,
repeat_after_min int,
max_repeats int NOT NULL DEFAULT 3,
UNIQUE (tenant_id, name)
);
CREATE TABLE alr.routes (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
priority int NOT NULL DEFAULT 100,
-- Фільтр: {"severity_gte":"high","tags":{"env":"prod"},"site_ids":[...]}
matcher jsonb NOT NULL DEFAULT '{}'::jsonb,
policy_id uuid REFERENCES alr.escalation_policies(id) ON DELETE SET NULL,
channel_ids uuid[] NOT NULL DEFAULT '{}',
-- Тихі години: {"tz":"Europe/Kyiv","quiet":[{"days":[6,0],"from":"22:00","to":"08:00"}]}
schedule jsonb,
enabled boolean NOT NULL DEFAULT true,
UNIQUE (tenant_id, name)
);
-- Журнал доставки
CREATE TYPE alr.delivery_status AS ENUM ('queued','sent','failed','throttled','skipped');
CREATE TABLE alr.notifications (
ts timestamptz NOT NULL DEFAULT now(),
id uuid NOT NULL DEFAULT core.new_id(),
tenant_id uuid NOT NULL,
alert_id uuid,
channel_id uuid,
user_id uuid,
status alr.delivery_status NOT NULL DEFAULT 'queued',
attempt smallint NOT NULL DEFAULT 1,
error text,
-- Для Telegram: message_id, щоб потім редагувати кнопки Ack/Mute
external_id text,
payload jsonb,
PRIMARY KEY (ts, id)
);
SELECT create_hypertable('alr.notifications', 'ts',
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
CREATE INDEX notif_alert_idx ON alr.notifications (alert_id, ts DESC);
SELECT add_retention_policy('alr.notifications', INTERVAL '90 days', if_not_exists => TRUE);
-- ---------------------------------------------------------------------
-- Вікна обслуговування (алерти придушуються, SLA не псується)
-- ---------------------------------------------------------------------
CREATE TABLE alr.maintenance_windows (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
period tstzrange NOT NULL,
-- Повторюваність (RRULE, RFC 5545) для регулярних вікон
rrule text,
selector jsonb NOT NULL DEFAULT '{}'::jsonb,
suppress_notifications boolean NOT NULL DEFAULT true,
exclude_from_sla boolean NOT NULL DEFAULT true,
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX mw_active_idx ON alr.maintenance_windows USING gist (tenant_id, period);
-- Ручне придушення конкретного об'єкта ("Mute" з Telegram Mini App)
CREATE TABLE alr.mutes (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
link_id uuid REFERENCES topo.links(id) ON DELETE CASCADE,
rule_id uuid REFERENCES alr.rules(id) ON DELETE CASCADE,
until timestamptz NOT NULL,
reason text,
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (device_id IS NOT NULL OR link_id IS NOT NULL OR rule_id IS NOT NULL)
);
CREATE INDEX mutes_active_idx ON alr.mutes (tenant_id, until);