feat: Telegram settings in DB with UI edit and test send

Store notification_channels (migration 004), effective config DB over env,
PUT/test API endpoints, Settings form, and pass db session into ingest notify.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:05:32 +10:00
parent e0ba384705
commit 79a78dc7c9
12 changed files with 564 additions and 116 deletions
+158 -37
View File
@@ -2,40 +2,71 @@
<div class="settings-page">
<h1>Настройки</h1>
<p class="settings-intro">
Каналы оповещений SAC. Сейчас конфигурация читается из <code>sac-api.env</code> на сервере;
редактирование через UI в разработке (<code>notif-12</code>).
Каналы оповещений SAC. Настройки Telegram сохраняются в БД (источник <code>db</code>) или берутся из
<code>sac-api.env</code> (<code>env</code>), пока запись не создана.
</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-if="data" class="card settings-card">
<section v-else class="card settings-card">
<h2>Telegram</h2>
<dl class="settings-dl">
<dt>Включён</dt>
<dd>{{ data.telegram.enabled ? "да" : "нет" }}</dd>
<dt>Настроен (token + chat)</dt>
<dd>{{ data.telegram.configured ? "да" : "нет" }}</dd>
<dt>Мин. severity</dt>
<dd><code>{{ data.telegram.min_severity }}</code></dd>
<dt>Bot token</dt>
<dd>{{ data.telegram.bot_token_hint ?? "—" }}</dd>
<dt>Chat ID</dt>
<dd>{{ data.telegram.chat_id_hint ?? "—" }}</dd>
<dt>Источник</dt>
<dd><code>{{ data.telegram.source }}</code></dd>
</dl>
<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 ? "да" : "нет" }}
</p>
<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>TELEGRAM_ENABLED=true</code> и <code>TELEGRAM_MIN_SEVERITY=warning</code> в
<code>sac-api.env</code>, затем перезапустите <code>sac-api</code>.
Для <strong>UseSAC=exclusive</strong> рекомендуется <code>warning</code> failed login и problems, без
успешных RDP (<code>info</code>).
</p>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { apiFetch } from "../api";
interface TelegramSettings {
@@ -47,23 +78,88 @@ interface TelegramSettings {
source: string;
}
interface NotificationSettings {
telegram: TelegramSettings;
}
const loading = ref(true);
const saving = ref(false);
const testing = ref(false);
const error = ref("");
const data = ref<NotificationSettings | null>(null);
const success = ref("");
const loaded = ref<TelegramSettings | null>(null);
onMounted(async () => {
const form = reactive({
enabled: false,
min_severity: "warning",
bot_token: "",
chat_id: "",
});
const tokenPlaceholder = computed(() =>
loaded.value?.bot_token_hint ? `оставить ${loaded.value.bot_token_hint}` : "обязателен при первой настройке",
);
const chatPlaceholder = computed(() =>
loaded.value?.chat_id_hint ? `оставить ${loaded.value.chat_id_hint}` : "обязателен при первой настройке",
);
async function load() {
loading.value = true;
error.value = "";
try {
data.value = await apiFetch<NotificationSettings>("/api/v1/settings/notifications");
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 = "";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally {
loading.value = false;
}
});
}
async function save() {
saving.value = true;
error.value = "";
success.value = "";
try {
const body: Record<string, unknown> = {
enabled: form.enabled,
min_severity: form.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();
const updated = await apiFetch<TelegramSettings>("/api/v1/settings/notifications/telegram", {
method: "PUT",
body: JSON.stringify(body),
});
loaded.value = updated;
form.bot_token = "";
form.chat_id = "";
success.value = "Настройки Telegram сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
saving.value = false;
}
}
async function testTelegram() {
testing.value = true;
error.value = "";
success.value = "";
try {
const res = await apiFetch<{ message: string }>("/api/v1/settings/notifications/telegram/test", {
method: "POST",
});
success.value = res.message;
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка отправки";
} finally {
testing.value = false;
}
}
onMounted(load);
</script>
<style scoped>
@@ -80,19 +176,40 @@ onMounted(async () => {
margin-top: 0;
}
.settings-dl {
display: grid;
grid-template-columns: 11rem 1fr;
gap: 0.5rem 1rem;
margin: 0 0 1rem;
.settings-form {
display: flex;
flex-direction: column;
gap: 0.85rem;
margin-bottom: 1rem;
}
.settings-dl dt {
.settings-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.settings-field input[type="text"],
.settings-field input[type="password"],
.settings-field select {
max-width: 24rem;
}
.settings-meta {
font-size: 0.85rem;
color: #9aa4b2;
margin: 0;
}
.settings-dl dd {
margin: 0;
.settings-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.settings-actions button.secondary {
background: #2a3441;
color: #c5d0dc;
}
.settings-hint {
@@ -100,4 +217,8 @@ onMounted(async () => {
color: #9aa4b2;
margin: 0;
}
.success {
color: #6dd196;
}
</style>