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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user