feat: global notification policy severity to channels (notif-22)

Singleton notification_policy table, NOTIFY_MIN_SEVERITY/CHANNELS env,
central dispatch gate, Settings policy UI, migration 007.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:19:26 +10:00
parent 9b177c145b
commit ebb450be92
19 changed files with 577 additions and 126 deletions
+116 -39
View File
@@ -11,6 +11,48 @@
<p v-else-if="loading">Загрузка</p>
<template v-else>
<section class="card settings-card settings-policy">
<h2>Правило оповещений</h2>
<p class="settings-hint settings-hint-top">
События и problems с severity <strong></strong> порога уходят в выбранные каналы (если канал включён и
настроен).
</p>
<form class="settings-form" @submit.prevent="savePolicy">
<label class="settings-field">
Мин. severity
<select v-model="policyForm.min_severity">
<option value="info">info</option>
<option value="warning">warning</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
</label>
<fieldset class="settings-channels">
<legend>Каналы</legend>
<label class="settings-inline">
<input v-model="policyForm.use_telegram" type="checkbox" />
Telegram
</label>
<label class="settings-inline">
<input v-model="policyForm.use_webhook" type="checkbox" />
Webhook
</label>
<label class="settings-inline">
<input v-model="policyForm.use_email" type="checkbox" />
Email
</label>
</fieldset>
<p v-if="policyLoaded" class="settings-meta">
Источник: <code>{{ policyLoaded.source }}</code>
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingPolicy">
{{ savingPolicy ? "Сохранение…" : "Сохранить правило" }}
</button>
</div>
</form>
</section>
<section class="card settings-card">
<h2>Telegram</h2>
<form class="settings-form" @submit.prevent="saveTelegram">
@@ -18,15 +60,6 @@
<input v-model="telegramForm.enabled" type="checkbox" />
Включить Telegram
</label>
<label class="settings-field">
Мин. severity
<select v-model="telegramForm.min_severity">
<option value="info">info</option>
<option value="warning">warning</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
</label>
<label class="settings-field">
Bot token
<input
@@ -71,15 +104,6 @@
<input v-model="webhookForm.enabled" type="checkbox" />
Включить webhook
</label>
<label class="settings-field">
Мин. severity
<select v-model="webhookForm.min_severity">
<option value="info">info</option>
<option value="warning">warning</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
</label>
<label class="settings-field">
URL
<input
@@ -125,15 +149,6 @@
<input v-model="emailForm.enabled" type="checkbox" />
Включить email
</label>
<label class="settings-field">
Мин. severity
<select v-model="emailForm.min_severity">
<option value="info">info</option>
<option value="warning">warning</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
</label>
<label class="settings-field">
SMTP host
<input v-model="emailForm.smtp_host" type="text" autocomplete="off" :placeholder="smtpHostPlaceholder" />
@@ -227,7 +242,16 @@ interface EmailSettings {
source: string;
}
interface NotificationPolicy {
min_severity: string;
use_telegram: boolean;
use_webhook: boolean;
use_email: boolean;
source: string;
}
const loading = ref(true);
const savingPolicy = ref(false);
const savingTelegram = ref(false);
const savingWebhook = ref(false);
const savingEmail = ref(false);
@@ -239,17 +263,23 @@ const success = ref("");
const telegramLoaded = ref<TelegramSettings | null>(null);
const webhookLoaded = ref<WebhookSettings | null>(null);
const emailLoaded = ref<EmailSettings | null>(null);
const policyLoaded = ref<NotificationPolicy | null>(null);
const policyForm = reactive({
min_severity: "warning",
use_telegram: true,
use_webhook: false,
use_email: false,
});
const telegramForm = reactive({
enabled: false,
min_severity: "warning",
bot_token: "",
chat_id: "",
});
const webhookForm = reactive({
enabled: false,
min_severity: "warning",
url: "",
secret_header: "",
secret: "",
@@ -257,7 +287,6 @@ const webhookForm = reactive({
const emailForm = reactive({
enabled: false,
min_severity: "warning",
smtp_host: "",
smtp_port: 587,
smtp_user: "",
@@ -298,23 +327,28 @@ async function load() {
loading.value = true;
error.value = "";
try {
const data = await apiFetch<{ telegram: TelegramSettings; webhook: WebhookSettings; email: EmailSettings }>(
"/api/v1/settings/notifications",
);
const data = await apiFetch<{
policy: NotificationPolicy;
telegram: TelegramSettings;
webhook: WebhookSettings;
email: EmailSettings;
}>("/api/v1/settings/notifications");
policyLoaded.value = data.policy;
policyForm.min_severity = data.policy.min_severity;
policyForm.use_telegram = data.policy.use_telegram;
policyForm.use_webhook = data.policy.use_webhook;
policyForm.use_email = data.policy.use_email;
telegramLoaded.value = data.telegram;
webhookLoaded.value = data.webhook;
emailLoaded.value = data.email;
telegramForm.enabled = data.telegram.enabled;
telegramForm.min_severity = data.telegram.min_severity;
telegramForm.bot_token = "";
telegramForm.chat_id = "";
webhookForm.enabled = data.webhook.enabled;
webhookForm.min_severity = data.webhook.min_severity;
webhookForm.url = "";
webhookForm.secret_header = data.webhook.secret_header ?? "";
webhookForm.secret = "";
emailForm.enabled = data.email.enabled;
emailForm.min_severity = data.email.min_severity;
emailForm.smtp_host = "";
emailForm.smtp_port = data.email.smtp_port || 587;
emailForm.smtp_user = data.email.smtp_user ?? "";
@@ -330,6 +364,28 @@ async function load() {
}
}
async function savePolicy() {
savingPolicy.value = true;
error.value = "";
success.value = "";
try {
policyLoaded.value = await apiFetch<NotificationPolicy>("/api/v1/settings/notifications/policy", {
method: "PUT",
body: JSON.stringify({
min_severity: policyForm.min_severity,
use_telegram: policyForm.use_telegram,
use_webhook: policyForm.use_webhook,
use_email: policyForm.use_email,
}),
});
success.value = "Правило оповещений сохранено.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
savingPolicy.value = false;
}
}
async function saveTelegram() {
savingTelegram.value = true;
error.value = "";
@@ -337,7 +393,6 @@ async function saveTelegram() {
try {
const body: Record<string, unknown> = {
enabled: telegramForm.enabled,
min_severity: telegramForm.min_severity,
};
if (telegramForm.bot_token.trim()) body.bot_token = telegramForm.bot_token.trim();
if (telegramForm.chat_id.trim()) body.chat_id = telegramForm.chat_id.trim();
@@ -363,7 +418,6 @@ async function saveWebhook() {
try {
const body: Record<string, unknown> = {
enabled: webhookForm.enabled,
min_severity: webhookForm.min_severity,
secret_header: webhookForm.secret_header.trim() || null,
};
if (webhookForm.url.trim()) body.url = webhookForm.url.trim();
@@ -422,7 +476,6 @@ async function saveEmail() {
try {
const body: Record<string, unknown> = {
enabled: emailForm.enabled,
min_severity: emailForm.min_severity,
smtp_port: emailForm.smtp_port,
smtp_starttls: emailForm.smtp_starttls,
smtp_ssl: emailForm.smtp_ssl,
@@ -505,6 +558,30 @@ onMounted(load);
max-width: 28rem;
}
.settings-policy {
border-color: #3d4f66;
}
.settings-hint-top {
margin-top: 0;
margin-bottom: 1rem;
}
.settings-channels {
border: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.settings-channels legend {
font-size: 0.9rem;
color: #9aa4b2;
margin-bottom: 0.35rem;
}
.settings-inline {
flex-direction: row;
align-items: center;