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>
233 lines
11 KiB
SQL
233 lines
11 KiB
SQL
-- =====================================================================
|
||
-- 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);
|