feat: webhook notification channel with UI and ingest dispatch

Add webhook config (DB/env), JSON POST on events/problems, settings API,
Settings UI section, and notify_dispatch for multi-channel ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:09:40 +10:00
parent f7b5fff04e
commit 20fa7e2c27
13 changed files with 826 additions and 189 deletions
+220 -78
View File
@@ -2,66 +2,122 @@
<div class="settings-page">
<h1>Настройки</h1>
<p class="settings-intro">
Каналы оповещений SAC. Настройки Telegram сохраняются в БД (источник <code>db</code>) или берутся из
<code>sac-api.env</code> (<code>env</code>), пока запись не создана.
Каналы оповещений SAC. Значения в БД (<code>source=db</code>) или из <code>sac-api.env</code> (<code>env</code>),
пока запись не создана через UI.
</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="success" class="success">{{ success }}</p>
<p v-else-if="loading">Загрузка</p>
<section v-else class="card settings-card">
<h2>Telegram</h2>
<form class="settings-form" @submit.prevent="save">
<label class="settings-field">
<input v-model="form.enabled" type="checkbox" />
Включить Telegram
</label>
<label class="settings-field">
Мин. severity
<select v-model="form.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
v-model="form.bot_token"
type="password"
autocomplete="off"
:placeholder="tokenPlaceholder"
/>
</label>
<label class="settings-field">
Chat ID
<input v-model="form.chat_id" type="text" autocomplete="off" :placeholder="chatPlaceholder" />
</label>
<p v-if="loaded" class="settings-meta">
Источник: <code>{{ loaded.source }}</code>
· настроен: {{ loaded.configured ? "да" : "нет" }}
<template v-else>
<section class="card settings-card">
<h2>Telegram</h2>
<form class="settings-form" @submit.prevent="saveTelegram">
<label class="settings-field">
<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
v-model="telegramForm.bot_token"
type="password"
autocomplete="off"
:placeholder="tokenPlaceholder"
/>
</label>
<label class="settings-field">
Chat ID
<input
v-model="telegramForm.chat_id"
type="text"
autocomplete="off"
:placeholder="chatPlaceholder"
/>
</label>
<p v-if="telegramLoaded" class="settings-meta">
Источник: <code>{{ telegramLoaded.source }}</code>
· настроен: {{ telegramLoaded.configured ? "да" : "нет" }}
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingTelegram">
{{ savingTelegram ? "Сохранение…" : "Сохранить" }}
</button>
<button type="button" class="secondary" :disabled="testingTelegram" @click="testTelegram">
{{ testingTelegram ? "Отправка" : "Проверить Telegram" }}
</button>
</div>
</form>
<p class="settings-hint">
Для <strong>UseSAC=exclusive</strong> рекомендуется <code>warning</code> failed login и problems, без
успешных RDP (<code>info</code>).
</p>
</section>
<div class="settings-actions">
<button type="submit" :disabled="saving">{{ saving ? "Сохранение…" : "Сохранить" }}</button>
<button type="button" class="secondary" :disabled="testing" @click="testTelegram">
{{ testing ? "Отправка" : "Проверить Telegram" }}
</button>
</div>
</form>
<p class="settings-hint">
Для <strong>UseSAC=exclusive</strong> рекомендуется <code>warning</code> failed login и problems, без
успешных RDP (<code>info</code>).
</p>
</section>
<section class="card settings-card">
<h2>Webhook</h2>
<form class="settings-form" @submit.prevent="saveWebhook">
<label class="settings-field">
<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
v-model="webhookForm.url"
type="url"
autocomplete="off"
:placeholder="urlPlaceholder"
/>
</label>
<label class="settings-field">
Secret header (необязательно)
<input v-model="webhookForm.secret_header" type="text" autocomplete="off" placeholder="X-SAC-Token" />
</label>
<label class="settings-field">
Secret value
<input
v-model="webhookForm.secret"
type="password"
autocomplete="off"
:placeholder="webhookSecretPlaceholder"
/>
</label>
<p v-if="webhookLoaded" class="settings-meta">
Источник: <code>{{ webhookLoaded.source }}</code>
· настроен: {{ webhookLoaded.configured ? "да" : "нет" }}
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingWebhook">
{{ savingWebhook ? "Сохранение…" : "Сохранить" }}
</button>
<button type="button" class="secondary" :disabled="testingWebhook" @click="testWebhook">
{{ testingWebhook ? "Отправка" : "Проверить webhook" }}
</button>
</div>
</form>
<p class="settings-hint">JSON POST: <code>kind</code> = event | problem | test; те же пороги severity, что у Telegram.</p>
</section>
</template>
</div>
</template>
@@ -78,37 +134,76 @@ interface TelegramSettings {
source: string;
}
interface WebhookSettings {
enabled: boolean;
configured: boolean;
url_hint: string | null;
secret_header: string | null;
secret_hint: string | null;
min_severity: string;
source: string;
}
const loading = ref(true);
const saving = ref(false);
const testing = ref(false);
const savingTelegram = ref(false);
const savingWebhook = ref(false);
const testingTelegram = ref(false);
const testingWebhook = ref(false);
const error = ref("");
const success = ref("");
const loaded = ref<TelegramSettings | null>(null);
const telegramLoaded = ref<TelegramSettings | null>(null);
const webhookLoaded = ref<WebhookSettings | null>(null);
const form = reactive({
const telegramForm = reactive({
enabled: false,
min_severity: "warning",
bot_token: "",
chat_id: "",
});
const webhookForm = reactive({
enabled: false,
min_severity: "warning",
url: "",
secret_header: "",
secret: "",
});
const tokenPlaceholder = computed(() =>
loaded.value?.bot_token_hint ? `оставить ${loaded.value.bot_token_hint}` : "обязателен при первой настройке",
telegramLoaded.value?.bot_token_hint
? `оставить ${telegramLoaded.value.bot_token_hint}`
: "обязателен при первой настройке",
);
const chatPlaceholder = computed(() =>
loaded.value?.chat_id_hint ? `оставить ${loaded.value.chat_id_hint}` : "обязателен при первой настройке",
telegramLoaded.value?.chat_id_hint
? `оставить ${telegramLoaded.value.chat_id_hint}`
: "обязателен при первой настройке",
);
const urlPlaceholder = computed(() =>
webhookLoaded.value?.url_hint ? `оставить ${webhookLoaded.value.url_hint}` : "https://…",
);
const webhookSecretPlaceholder = computed(() =>
webhookLoaded.value?.secret_hint ? `оставить ${webhookLoaded.value.secret_hint}` : "необязательно",
);
async function load() {
loading.value = true;
error.value = "";
try {
const data = await apiFetch<{ telegram: TelegramSettings }>("/api/v1/settings/notifications");
loaded.value = data.telegram;
form.enabled = data.telegram.enabled;
form.min_severity = data.telegram.min_severity;
form.bot_token = "";
form.chat_id = "";
const data = await apiFetch<{ telegram: TelegramSettings; webhook: WebhookSettings }>(
"/api/v1/settings/notifications",
);
telegramLoaded.value = data.telegram;
webhookLoaded.value = data.webhook;
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 = "";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally {
@@ -116,35 +211,61 @@ async function load() {
}
}
async function save() {
saving.value = true;
async function saveTelegram() {
savingTelegram.value = true;
error.value = "";
success.value = "";
try {
const body: Record<string, unknown> = {
enabled: form.enabled,
min_severity: form.min_severity,
enabled: telegramForm.enabled,
min_severity: telegramForm.min_severity,
};
if (form.bot_token.trim()) body.bot_token = form.bot_token.trim();
if (form.chat_id.trim()) body.chat_id = form.chat_id.trim();
if (telegramForm.bot_token.trim()) body.bot_token = telegramForm.bot_token.trim();
if (telegramForm.chat_id.trim()) body.chat_id = telegramForm.chat_id.trim();
const updated = await apiFetch<TelegramSettings>("/api/v1/settings/notifications/telegram", {
telegramLoaded.value = await apiFetch<TelegramSettings>("/api/v1/settings/notifications/telegram", {
method: "PUT",
body: JSON.stringify(body),
});
loaded.value = updated;
form.bot_token = "";
form.chat_id = "";
telegramForm.bot_token = "";
telegramForm.chat_id = "";
success.value = "Настройки Telegram сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
saving.value = false;
savingTelegram.value = false;
}
}
async function saveWebhook() {
savingWebhook.value = true;
error.value = "";
success.value = "";
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();
if (webhookForm.secret.trim()) body.secret = webhookForm.secret.trim();
webhookLoaded.value = await apiFetch<WebhookSettings>("/api/v1/settings/notifications/webhook", {
method: "PUT",
body: JSON.stringify(body),
});
webhookForm.url = "";
webhookForm.secret = "";
success.value = "Настройки webhook сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
savingWebhook.value = false;
}
}
async function testTelegram() {
testing.value = true;
testingTelegram.value = true;
error.value = "";
success.value = "";
try {
@@ -155,7 +276,23 @@ async function testTelegram() {
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка отправки";
} finally {
testing.value = false;
testingTelegram.value = false;
}
}
async function testWebhook() {
testingWebhook.value = true;
error.value = "";
success.value = "";
try {
const res = await apiFetch<{ message: string }>("/api/v1/settings/notifications/webhook/test", {
method: "POST",
});
success.value = res.message;
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка отправки";
} finally {
testingWebhook.value = false;
}
}
@@ -172,6 +309,10 @@ onMounted(load);
margin-bottom: 1.25rem;
}
.settings-card {
margin-bottom: 1.25rem;
}
.settings-card h2 {
margin-top: 0;
}
@@ -191,8 +332,9 @@ onMounted(load);
.settings-field input[type="text"],
.settings-field input[type="password"],
.settings-field input[type="url"],
.settings-field select {
max-width: 24rem;
max-width: 28rem;
}
.settings-meta {