Files
security-alert-center/frontend/src/views/SettingsView.vue
T
PapaTramp cd2f27792d feat: agent updates from SAC, poll config, SSH/WinRM fallback (0.12.0)
Add agent update settings, pending self-update via poll, desired config per host, and automatic or manual SSH/WinRM fallback when agents do not report agent.update events.
2026-06-20 15:52:10 +10:00

1513 lines
51 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="settings-page">
<h1>Настройки</h1>
<p class="settings-intro">
Каналы оповещений 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="loading">Загрузка</p>
<template v-if="!loading">
<p v-if="success" class="success">{{ success }}</p>
<div v-if="lastCreatedCode" class="settings-enrollment-banner" role="status">
<p class="settings-enrollment-banner-title">Код регистрации Seaca</p>
<p class="settings-enrollment-banner-hint">
Скопируйте и передайте оператору вместе с URL сервера. Полный код показывается только сейчас.
</p>
<div class="settings-enrollment-code-row">
<code class="settings-enrollment-code-value">{{ lastCreatedCode }}</code>
<button type="button" @click="copyEnrollmentCode">Копировать</button>
<button type="button" class="secondary" @click="dismissEnrollmentCode">Скрыть</button>
</div>
</div>
<section class="card settings-card settings-policy">
<h2>Интерфейс</h2>
<p class="settings-hint settings-hint-top">
Блок «Сервер» в левой панели: CPU, RAM, диск, задержка БД и счётчики SAC. Обновляется каждые 30 секунд.
</p>
<form class="settings-form" @submit.prevent="saveUiSettings">
<label class="settings-field settings-inline">
<input v-model="uiForm.show_sidebar_system_stats" type="checkbox" />
Показывать блок системной информации в боковой панели
</label>
<p v-if="uiLoaded" class="settings-meta">
Источник: <code>{{ uiLoaded.source }}</code>
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingUi">
{{ savingUi ? "Сохранение…" : "Сохранить интерфейс" }}
</button>
</div>
</form>
</section>
<section class="card settings-card settings-policy">
<h2>Windows (qwinsta / logoff)</h2>
<p class="settings-hint settings-hint-top">
Доменный admin для удалённых команд на Windows-хостах (qwinsta, logoff через агент).
Формат логина: <code>ДОМЕН\пользователь</code> (например <code>B26\papatramp</code>).
Пока запись не создана в UI используются <code>SAC_WIN_ADMIN_*</code> из <code>sac-api.env</code>.
</p>
<form class="settings-form" @submit.prevent="saveWinAdminSettings">
<label class="settings-field">
Доменный admin
<input
v-model="winAdminForm.user"
type="text"
autocomplete="off"
placeholder="B26\papatramp"
/>
</label>
<label class="settings-field">
Пароль
<input
v-model="winAdminForm.password"
type="password"
autocomplete="new-password"
:placeholder="winAdminPasswordPlaceholder"
/>
</label>
<p v-if="winAdminLoaded" class="settings-meta">
Настроен: {{ winAdminLoaded.configured ? "да" : "нет" }}
· источник: <code>{{ winAdminLoaded.source }}</code>
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingWinAdmin">
{{ savingWinAdmin ? "Сохранение…" : "Сохранить Windows admin" }}
</button>
</div>
</form>
</section>
<section class="card settings-card settings-policy">
<h2>Linux (SSH / обновление ssh-monitor)</h2>
<p class="settings-hint settings-hint-top">
SSH-учётка для удалённого запуска <code>/opt/scripts/update_ssh_monitor.sh</code> с карточки Linux-хоста.
Рекомендуется <code>root</code> или пользователь с <code>sudo NOPASSWD</code>.
Пока запись не создана в UI используются <code>SAC_LINUX_ADMIN_*</code> из <code>sac-api.env</code>.
</p>
<form class="settings-form" @submit.prevent="saveLinuxAdminSettings">
<label class="settings-field">
SSH login
<input
v-model="linuxAdminForm.user"
type="text"
autocomplete="off"
placeholder="root"
/>
</label>
<label class="settings-field">
SSH password
<input
v-model="linuxAdminForm.password"
type="password"
autocomplete="new-password"
:placeholder="linuxAdminPasswordPlaceholder"
/>
</label>
<p v-if="linuxAdminLoaded" class="settings-meta">
Настроен: {{ linuxAdminLoaded.configured ? "да" : "нет" }}
· источник: <code>{{ linuxAdminLoaded.source }}</code>
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingLinuxAdmin">
{{ savingLinuxAdmin ? "Сохранение…" : "Сохранить Linux admin" }}
</button>
</div>
</form>
</section>
<section class="card settings-card settings-policy">
<h2>Обновления агентов</h2>
<p class="settings-hint settings-hint-top">
Режим <code>gpo</code> обновления вручную/GPO; SAC только показывает устаревшие версии.
Режим <code>sac</code> «Запросить обновление» ставит флаг в poll; агент self-update;
через {{ agentUpdateForm.fallback_after_minutes }} мин без ответа fallback SSH/WinRM с SAC.
</p>
<form class="settings-form" @submit.prevent="saveAgentUpdateSettingsForm">
<label class="settings-field">
Режим
<select v-model="agentUpdateForm.mode">
<option value="gpo">GPO / ручной</option>
<option value="sac">SAC (poll + fallback)</option>
</select>
</label>
<label class="settings-field settings-inline">
<input v-model="agentUpdateForm.fallback_enabled" type="checkbox" />
Авто-fallback SSH/WinRM после таймаута
</label>
<label class="settings-field">
Fallback через (мин)
<input v-model.number="agentUpdateForm.fallback_after_minutes" type="number" min="1" max="1440" />
</label>
<label class="settings-field">
Recommended RDP-login-monitor
<input v-model="agentUpdateForm.recommended_rdp_version" type="text" placeholder="2.1.0-SAC" />
</label>
<label class="settings-field">
Recommended ssh-monitor
<input v-model="agentUpdateForm.recommended_ssh_version" type="text" placeholder="2.1.0-SAC" />
</label>
<label class="settings-field">
WinRM: путь к Deploy-LoginMonitor.ps1 (fallback)
<input
v-model="agentUpdateForm.win_agent_update_script"
type="text"
placeholder="C:\ProgramData\LoginMonitor\Deploy-LoginMonitor.ps1"
/>
</label>
<p v-if="agentUpdateLoaded" class="settings-meta">
Источник: <code>{{ agentUpdateLoaded.source }}</code>
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingAgentUpdate">
{{ savingAgentUpdate ? "Сохранение…" : "Сохранить обновления агентов" }}
</button>
</div>
</form>
</section>
<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>
<label class="settings-inline">
<input v-model="policyForm.use_mobile" type="checkbox" />
Seaca (push)
</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 settings-policy">
<h2>Мобильные устройства (Seaca)</h2>
<p class="settings-hint settings-hint-top">
Подключение Android-клиента по коду регистрации. Управление пользователями SAC в разделе «Пользователи».
</p>
<form class="settings-form" @submit.prevent="saveMobileSettings">
<label class="settings-field settings-inline">
<input v-model="mobileForm.devices_allowed" type="checkbox" />
Разрешать мобильным устройствам подключаться
</label>
<label class="settings-field">
Макс. устройств на пользователя
<input v-model.number="mobileForm.max_devices_per_user" type="number" min="1" max="50" />
</label>
<label class="settings-field">
Мин. версия приложения (необязательно)
<input v-model="mobileForm.min_app_version" type="text" placeholder="0.1.0" />
</label>
<p v-if="mobileLoaded" class="settings-meta">
FCM: {{ mobileLoaded.fcm_configured ? "настроен" : "не настроен" }}
· источник: <code>{{ mobileLoaded.source }}</code>
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingMobile">
{{ savingMobile ? "Сохранение…" : "Сохранить мобильные" }}
</button>
</div>
</form>
<h3>Коды регистрации</h3>
<form class="settings-form settings-mobile-code-form" @submit.prevent="createEnrollmentCode">
<label class="settings-field">
Метка
<input v-model="codeForm.label" type="text" placeholder="Телефон Иванова" />
</label>
<label class="settings-field">
Пользователь (необязательно)
<select v-model="codeForm.target_user_id">
<option :value="null"> любой с паролем </option>
<option v-for="u in sacUsers" :key="u.id" :value="u.id">{{ u.username }} ({{ u.role }})</option>
</select>
</label>
<label class="settings-field">
Способ входа
<select v-model="codeForm.login_mode">
<option value="password">Логин и пароль SAC + код</option>
<option value="code_only">Только код (пользователь привязан)</option>
</select>
</label>
<label class="settings-field">
Срок (часов)
<input v-model.number="codeForm.expires_in_hours" type="number" min="1" max="720" />
</label>
<div class="settings-actions">
<button type="submit" :disabled="creatingCode">
{{ creatingCode ? "Создание…" : "Выдать код" }}
</button>
</div>
</form>
<div class="settings-mobile-table-wrap">
<table class="settings-severity-table">
<thead>
<tr>
<th>Метка</th>
<th>Префикс</th>
<th>Пользователь</th>
<th>Вход</th>
<th>Использований</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="code in enrollmentCodes" :key="code.id">
<td>{{ code.label || "—" }}</td>
<td><code>{{ code.code_prefix }}</code></td>
<td>{{ code.target_username || "—" }}</td>
<td>{{ code.login_mode }}</td>
<td>{{ code.use_count }}/{{ code.max_uses }}</td>
<td>
<button
v-if="code.is_active"
type="button"
class="secondary"
@click="revokeCode(code.id)"
>
Отозвать
</button>
</td>
</tr>
</tbody>
</table>
</div>
<h3>Подключённые устройства</h3>
<div class="settings-mobile-table-wrap">
<table class="settings-severity-table">
<thead>
<tr>
<th>Имя</th>
<th>Пользователь</th>
<th>Статус</th>
<th>FCM</th>
<th>Последняя активность</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="device in mobileDevices" :key="device.id">
<td>{{ device.display_name }}</td>
<td>{{ device.username }}</td>
<td>{{ device.is_active ? "активно" : "отключено" }}</td>
<td>{{ device.has_fcm_token ? "да" : "нет" }}</td>
<td>{{ device.last_seen_at || device.enrolled_at }}</td>
<td class="settings-mobile-actions">
<button
v-if="device.is_active && device.has_fcm_token"
type="button"
class="secondary"
:disabled="testingPushDeviceId === device.id"
@click="testDevicePush(device.id)"
>
{{ testingPushDeviceId === device.id ? "Отправка…" : "Тест push" }}
</button>
<button
v-if="device.is_active"
type="button"
class="secondary"
@click="revokeDevice(device.id)"
>
Отключить
</button>
<button
type="button"
class="danger"
@click="deleteDevice(device.id)"
>
Удалить
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="card settings-card">
<h2>Severity по типу события</h2>
<p class="settings-hint settings-hint-top">
Переопределение значимости для новых событий после ingest. Исходное severity агента сохраняется в
<code>details.severity_agent</code>. Пустое значение как прислал агент (см. «По умолчанию»).
</p>
<div class="settings-severity-toolbar">
<input v-model="severityFilter" type="search" placeholder="Фильтр по type…" />
<button type="button" :disabled="savingSeverity" @click="saveSeverityOverrides">
{{ savingSeverity ? "Сохранение" : "Сохранить severity" }}
</button>
</div>
<div class="settings-severity-table-wrap">
<table class="settings-severity-table">
<thead>
<tr>
<th>Тип события</th>
<th>По умолчанию</th>
<th>Effective</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredSeverityRows" :key="row.event_type">
<td><code>{{ row.event_type }}</code></td>
<td>{{ row.default_severity }}</td>
<td>
<select v-model="row.selected">
<option value="">по умолчанию агента</option>
<option value="info">info</option>
<option value="warning">warning</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
<p class="settings-meta">Записей: {{ filteredSeverityRows.length }} / {{ severityRows.length }}</p>
</section>
<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">
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>
<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">
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>
<section class="card settings-card">
<h2>Email (SMTP)</h2>
<form class="settings-form" @submit.prevent="saveEmail">
<label class="settings-field">
<input v-model="emailForm.enabled" type="checkbox" />
Включить email
</label>
<label class="settings-field">
SMTP host
<input v-model="emailForm.smtp_host" type="text" autocomplete="off" :placeholder="smtpHostPlaceholder" />
</label>
<label class="settings-field">
SMTP port
<input v-model.number="emailForm.smtp_port" type="number" min="1" max="65535" />
</label>
<label class="settings-field">
SMTP user
<input v-model="emailForm.smtp_user" type="text" autocomplete="off" />
</label>
<label class="settings-field">
SMTP password
<input
v-model="emailForm.smtp_password"
type="password"
autocomplete="off"
:placeholder="smtpPasswordPlaceholder"
/>
</label>
<label class="settings-field">
From
<input v-model="emailForm.mail_from" type="email" autocomplete="off" />
</label>
<label class="settings-field">
To (через запятую или ;)
<input v-model="emailForm.mail_to" type="text" autocomplete="off" :placeholder="mailToPlaceholder" />
</label>
<label class="settings-field settings-inline">
<input v-model="emailForm.smtp_starttls" type="checkbox" />
STARTTLS
</label>
<label class="settings-field settings-inline">
<input v-model="emailForm.smtp_ssl" type="checkbox" />
SSL (порт 465)
</label>
<p v-if="emailLoaded" class="settings-meta">
Источник: <code>{{ emailLoaded.source }}</code>
· настроен: {{ emailLoaded.configured ? "да" : "нет" }}
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingEmail">
{{ savingEmail ? "Сохранение…" : "Сохранить" }}
</button>
<button type="button" class="secondary" :disabled="testingEmail" @click="testEmail">
{{ testingEmail ? "Отправка" : "Проверить email" }}
</button>
</div>
</form>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import {
apiFetch,
createMobileEnrollmentCode,
fetchMobileDevices,
fetchMobileEnrollmentCodes,
fetchMobileSettings,
fetchUsers,
fetchWinAdminSettings,
fetchLinuxAdminSettings,
fetchAgentUpdateSettings,
saveAgentUpdateSettings,
revokeMobileDevice,
deleteMobileDevice,
revokeMobileEnrollmentCode,
testMobileDevicePush,
updateMobileSettings,
updateWinAdminSettings,
updateLinuxAdminSettings,
type MobileDevice,
type MobileEnrollmentCode,
type MobileSettings,
type AgentUpdateSettings,
type UserSummary,
} from "../api";
interface TelegramSettings {
enabled: boolean;
configured: boolean;
chat_id_hint: string | null;
bot_token_hint: string | null;
min_severity: string;
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;
}
interface EmailSettings {
enabled: boolean;
configured: boolean;
smtp_host_hint: string | null;
smtp_port: number;
smtp_user: string | null;
smtp_password_hint: string | null;
mail_from: string | null;
mail_to_hint: string | null;
smtp_starttls: boolean;
smtp_ssl: boolean;
min_severity: string;
source: string;
}
interface NotificationPolicy {
min_severity: string;
use_telegram: boolean;
use_webhook: boolean;
use_email: boolean;
use_mobile: boolean;
source: string;
}
interface UiSettings {
show_sidebar_system_stats: boolean;
source: string;
}
interface WinAdminSettings {
configured: boolean;
user: string | null;
password_hint: string | null;
source: string;
}
interface LinuxAdminSettings {
configured: boolean;
user: string | null;
password_hint: string | null;
source: string;
}
interface SeverityOverrideRowApi {
event_type: string;
default_severity: string;
override_severity: string | null;
}
interface SeverityOverrideRowUi extends SeverityOverrideRowApi {
selected: string;
}
const loading = ref(true);
const savingPolicy = ref(false);
const savingUi = ref(false);
const savingWinAdmin = ref(false);
const savingLinuxAdmin = ref(false);
const savingAgentUpdate = ref(false);
const savingTelegram = ref(false);
const savingWebhook = ref(false);
const savingEmail = ref(false);
const savingSeverity = ref(false);
const savingMobile = ref(false);
const creatingCode = ref(false);
const testingTelegram = ref(false);
const testingWebhook = ref(false);
const testingEmail = ref(false);
const testingPushDeviceId = ref<number | null>(null);
const error = ref("");
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 uiLoaded = ref<UiSettings | null>(null);
const winAdminLoaded = ref<WinAdminSettings | null>(null);
const linuxAdminLoaded = ref<LinuxAdminSettings | null>(null);
const agentUpdateLoaded = ref<AgentUpdateSettings | null>(null);
const severityRows = ref<SeverityOverrideRowUi[]>([]);
const severityFilter = ref("");
const mobileLoaded = ref<MobileSettings | null>(null);
const enrollmentCodes = ref<MobileEnrollmentCode[]>([]);
const mobileDevices = ref<MobileDevice[]>([]);
const sacUsers = ref<UserSummary[]>([]);
const lastCreatedCode = ref("");
const ENROLLMENT_CODE_STORAGE_KEY = "sac_mobile_last_enrollment_code";
function restoreLastCreatedCode() {
const stored = sessionStorage.getItem(ENROLLMENT_CODE_STORAGE_KEY);
if (stored) {
lastCreatedCode.value = stored;
}
}
function persistLastCreatedCode(code: string) {
sessionStorage.setItem(ENROLLMENT_CODE_STORAGE_KEY, code);
}
function dismissEnrollmentCode() {
lastCreatedCode.value = "";
sessionStorage.removeItem(ENROLLMENT_CODE_STORAGE_KEY);
}
async function copyEnrollmentCode() {
if (!lastCreatedCode.value) return;
error.value = "";
try {
await navigator.clipboard.writeText(lastCreatedCode.value);
success.value = "Код скопирован в буфер обмена.";
} catch {
error.value = "Не удалось скопировать автоматически — выделите код вручную.";
}
}
const policyForm = reactive({
min_severity: "warning",
use_telegram: true,
use_webhook: false,
use_email: false,
use_mobile: false,
});
const mobileForm = reactive({
devices_allowed: false,
max_devices_per_user: 3,
min_app_version: "",
});
const codeForm = reactive({
label: "",
target_user_id: null as number | null,
login_mode: "password",
expires_in_hours: 24,
max_uses: 1,
});
const uiForm = reactive({
show_sidebar_system_stats: true,
});
const winAdminForm = reactive({
user: "",
password: "",
});
const linuxAdminForm = reactive({
user: "",
password: "",
});
const agentUpdateForm = reactive({
mode: "gpo" as "gpo" | "sac",
fallback_enabled: true,
fallback_after_minutes: 15,
recommended_rdp_version: "",
recommended_ssh_version: "",
win_agent_update_script: "",
});
const telegramForm = reactive({
enabled: false,
bot_token: "",
chat_id: "",
});
const webhookForm = reactive({
enabled: false,
url: "",
secret_header: "",
secret: "",
});
const emailForm = reactive({
enabled: false,
smtp_host: "",
smtp_port: 587,
smtp_user: "",
smtp_password: "",
mail_from: "",
mail_to: "",
smtp_starttls: true,
smtp_ssl: false,
});
const tokenPlaceholder = computed(() =>
telegramLoaded.value?.bot_token_hint
? `оставить ${telegramLoaded.value.bot_token_hint}`
: "обязателен при первой настройке",
);
const chatPlaceholder = computed(() =>
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}` : "необязательно",
);
const smtpHostPlaceholder = computed(() =>
emailLoaded.value?.smtp_host_hint ? `оставить ${emailLoaded.value.smtp_host_hint}` : "smtp.example.com",
);
const smtpPasswordPlaceholder = computed(() =>
emailLoaded.value?.smtp_password_hint ? `оставить ${emailLoaded.value.smtp_password_hint}` : "необязательно",
);
const mailToPlaceholder = computed(() =>
emailLoaded.value?.mail_to_hint ? `оставить ${emailLoaded.value.mail_to_hint}` : "admin@example.com",
);
const winAdminPasswordPlaceholder = computed(() =>
winAdminLoaded.value?.password_hint
? `оставить ${winAdminLoaded.value.password_hint}`
: "обязателен при первой настройке",
);
const linuxAdminPasswordPlaceholder = computed(() =>
linuxAdminLoaded.value?.password_hint
? `оставить ${linuxAdminLoaded.value.password_hint}`
: "обязателен при первой настройке",
);
const filteredSeverityRows = computed(() => {
const q = severityFilter.value.trim().toLowerCase();
if (!q) return severityRows.value;
return severityRows.value.filter((row) => row.event_type.toLowerCase().includes(q));
});
function mapSeverityRows(items: SeverityOverrideRowApi[]): SeverityOverrideRowUi[] {
return items.map((row) => ({
...row,
selected: row.override_severity ?? "",
}));
}
async function loadSeverityOverrides() {
const data = await apiFetch<{ items: SeverityOverrideRowApi[] }>(
"/api/v1/settings/notifications/severity-overrides",
);
severityRows.value = mapSeverityRows(data.items);
}
async function saveSeverityOverrides() {
savingSeverity.value = true;
error.value = "";
success.value = "";
try {
const overrides: Record<string, string | null> = {};
for (const row of severityRows.value) {
const prev = row.override_severity ?? "";
const next = row.selected.trim();
if (next === prev) continue;
overrides[row.event_type] = next || null;
}
if (Object.keys(overrides).length === 0) {
error.value = "Нет изменений severity для сохранения";
return;
}
const data = await apiFetch<{ items: SeverityOverrideRowApi[] }>(
"/api/v1/settings/notifications/severity-overrides",
{
method: "PUT",
body: JSON.stringify({ overrides }),
},
);
severityRows.value = mapSeverityRows(data.items);
success.value = "Severity по типам событий сохранён.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения severity";
} finally {
savingSeverity.value = false;
}
}
async function loadUiSettings() {
const data = await apiFetch<UiSettings>("/api/v1/settings/ui");
uiLoaded.value = data;
uiForm.show_sidebar_system_stats = data.show_sidebar_system_stats;
}
async function saveUiSettings() {
savingUi.value = true;
error.value = "";
success.value = "";
try {
uiLoaded.value = await apiFetch<UiSettings>("/api/v1/settings/ui", {
method: "PUT",
body: JSON.stringify({
show_sidebar_system_stats: uiForm.show_sidebar_system_stats,
}),
});
success.value = "Настройки интерфейса сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
savingUi.value = false;
}
}
async function loadWinAdminSettings() {
const data = await fetchWinAdminSettings();
winAdminLoaded.value = data;
winAdminForm.user = data.user ?? "";
winAdminForm.password = "";
}
async function saveWinAdminSettings() {
savingWinAdmin.value = true;
error.value = "";
success.value = "";
try {
const payload: { user?: string; password?: string } = {};
if (winAdminForm.user.trim()) {
payload.user = winAdminForm.user.trim();
}
if (winAdminForm.password.trim()) {
payload.password = winAdminForm.password;
}
if (!payload.user && !payload.password) {
error.value = "Укажите логин и/или новый пароль Windows admin";
return;
}
if (!winAdminLoaded.value?.configured && (!payload.user || !payload.password)) {
error.value = "Для первой настройки укажите логин и пароль";
return;
}
winAdminLoaded.value = await updateWinAdminSettings(payload);
winAdminForm.user = winAdminLoaded.value.user ?? winAdminForm.user;
winAdminForm.password = "";
success.value = "Учётные данные Windows admin сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения Windows admin";
} finally {
savingWinAdmin.value = false;
}
}
async function loadLinuxAdminSettings() {
const data = await fetchLinuxAdminSettings();
linuxAdminLoaded.value = data;
linuxAdminForm.user = data.user ?? "";
linuxAdminForm.password = "";
}
async function saveLinuxAdminSettings() {
savingLinuxAdmin.value = true;
error.value = "";
success.value = "";
try {
const payload: { user?: string; password?: string } = {};
if (linuxAdminForm.user.trim()) {
payload.user = linuxAdminForm.user.trim();
}
if (linuxAdminForm.password.trim()) {
payload.password = linuxAdminForm.password;
}
if (!payload.user && !payload.password) {
error.value = "Укажите логин и/или новый пароль Linux admin";
return;
}
if (!linuxAdminLoaded.value?.configured && (!payload.user || !payload.password)) {
error.value = "Для первой настройки укажите логин и пароль";
return;
}
linuxAdminLoaded.value = await updateLinuxAdminSettings(payload);
linuxAdminForm.user = linuxAdminLoaded.value.user ?? linuxAdminForm.user;
linuxAdminForm.password = "";
success.value = "Учётные данные Linux admin сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения Linux admin";
} finally {
savingLinuxAdmin.value = false;
}
}
async function loadAgentUpdateSettings() {
const data = await fetchAgentUpdateSettings();
agentUpdateLoaded.value = data;
agentUpdateForm.mode = data.mode;
agentUpdateForm.fallback_enabled = data.fallback_enabled;
agentUpdateForm.fallback_after_minutes = data.fallback_after_minutes;
agentUpdateForm.recommended_rdp_version = data.recommended_rdp_version ?? "";
agentUpdateForm.recommended_ssh_version = data.recommended_ssh_version ?? "";
agentUpdateForm.win_agent_update_script = data.win_agent_update_script ?? "";
}
async function saveAgentUpdateSettingsForm() {
savingAgentUpdate.value = true;
error.value = "";
success.value = "";
try {
agentUpdateLoaded.value = await saveAgentUpdateSettings({
mode: agentUpdateForm.mode,
fallback_enabled: agentUpdateForm.fallback_enabled,
fallback_after_minutes: agentUpdateForm.fallback_after_minutes,
recommended_rdp_version: agentUpdateForm.recommended_rdp_version.trim() || null,
recommended_ssh_version: agentUpdateForm.recommended_ssh_version.trim() || null,
win_agent_update_script: agentUpdateForm.win_agent_update_script.trim() || null,
});
success.value = "Настройки обновления агентов сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения agent-updates";
} finally {
savingAgentUpdate.value = false;
}
}
async function loadMobileSection() {
mobileLoaded.value = await fetchMobileSettings();
mobileForm.devices_allowed = mobileLoaded.value.devices_allowed;
mobileForm.max_devices_per_user = mobileLoaded.value.max_devices_per_user;
mobileForm.min_app_version = mobileLoaded.value.min_app_version ?? "";
enrollmentCodes.value = await fetchMobileEnrollmentCodes();
mobileDevices.value = await fetchMobileDevices();
const users = await fetchUsers();
sacUsers.value = users.items;
}
async function saveMobileSettings() {
savingMobile.value = true;
error.value = "";
success.value = "";
try {
mobileLoaded.value = await updateMobileSettings({
devices_allowed: mobileForm.devices_allowed,
max_devices_per_user: mobileForm.max_devices_per_user,
min_app_version: mobileForm.min_app_version.trim() || null,
});
success.value = "Настройки мобильных устройств сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
savingMobile.value = false;
}
}
async function createEnrollmentCode() {
creatingCode.value = true;
error.value = "";
success.value = "";
try {
const created = await createMobileEnrollmentCode({
label: codeForm.label,
target_user_id: codeForm.target_user_id,
login_mode: codeForm.login_mode,
max_uses: codeForm.max_uses,
expires_in_hours: codeForm.expires_in_hours,
});
lastCreatedCode.value = created.enrollment_code;
persistLastCreatedCode(created.enrollment_code);
enrollmentCodes.value = await fetchMobileEnrollmentCodes();
codeForm.label = "";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка создания кода";
} finally {
creatingCode.value = false;
}
}
async function revokeCode(codeId: number) {
error.value = "";
success.value = "";
try {
await revokeMobileEnrollmentCode(codeId);
enrollmentCodes.value = await fetchMobileEnrollmentCodes();
success.value = "Код отозван.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка";
}
}
async function revokeDevice(deviceId: number) {
error.value = "";
success.value = "";
try {
await revokeMobileDevice(deviceId);
mobileDevices.value = await fetchMobileDevices();
success.value = "Устройство отключено.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка";
}
}
async function deleteDevice(deviceId: number) {
const device = mobileDevices.value.find((item) => item.id === deviceId);
const label = device?.display_name || `#${deviceId}`;
if (!window.confirm(`Удалить запись устройства «${label}» из базы? Повторная привязка потребует новый код enroll.`)) {
return;
}
error.value = "";
success.value = "";
try {
await deleteMobileDevice(deviceId);
mobileDevices.value = await fetchMobileDevices();
success.value = "Запись устройства удалена.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка";
}
}
async function testDevicePush(deviceId: number) {
error.value = "";
success.value = "";
testingPushDeviceId.value = deviceId;
try {
const res = await testMobileDevicePush(deviceId);
success.value = res.message;
window.scrollTo({ top: 0, behavior: "smooth" });
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка push";
window.scrollTo({ top: 0, behavior: "smooth" });
} finally {
testingPushDeviceId.value = null;
}
}
async function load() {
loading.value = true;
error.value = "";
try {
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;
policyForm.use_mobile = data.policy.use_mobile ?? false;
telegramLoaded.value = data.telegram;
webhookLoaded.value = data.webhook;
emailLoaded.value = data.email;
telegramForm.enabled = data.telegram.enabled;
telegramForm.bot_token = "";
telegramForm.chat_id = "";
webhookForm.enabled = data.webhook.enabled;
webhookForm.url = "";
webhookForm.secret_header = data.webhook.secret_header ?? "";
webhookForm.secret = "";
emailForm.enabled = data.email.enabled;
emailForm.smtp_host = "";
emailForm.smtp_port = data.email.smtp_port || 587;
emailForm.smtp_user = data.email.smtp_user ?? "";
emailForm.smtp_password = "";
emailForm.mail_from = data.email.mail_from ?? "";
emailForm.mail_to = "";
emailForm.smtp_starttls = data.email.smtp_starttls;
emailForm.smtp_ssl = data.email.smtp_ssl;
await loadUiSettings();
await loadWinAdminSettings();
await loadLinuxAdminSettings();
await loadAgentUpdateSettings();
await loadSeverityOverrides();
await loadMobileSection();
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally {
loading.value = false;
}
}
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,
use_mobile: policyForm.use_mobile,
}),
});
success.value = "Правило оповещений сохранено.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
savingPolicy.value = false;
}
}
async function saveTelegram() {
savingTelegram.value = true;
error.value = "";
success.value = "";
try {
const body: Record<string, unknown> = {
enabled: telegramForm.enabled,
};
if (telegramForm.bot_token.trim()) body.bot_token = telegramForm.bot_token.trim();
if (telegramForm.chat_id.trim()) body.chat_id = telegramForm.chat_id.trim();
telegramLoaded.value = await apiFetch<TelegramSettings>("/api/v1/settings/notifications/telegram", {
method: "PUT",
body: JSON.stringify(body),
});
telegramForm.bot_token = "";
telegramForm.chat_id = "";
success.value = "Настройки Telegram сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
savingTelegram.value = false;
}
}
async function saveWebhook() {
savingWebhook.value = true;
error.value = "";
success.value = "";
try {
const body: Record<string, unknown> = {
enabled: webhookForm.enabled,
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() {
testingTelegram.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 {
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;
}
}
async function saveEmail() {
savingEmail.value = true;
error.value = "";
success.value = "";
try {
const body: Record<string, unknown> = {
enabled: emailForm.enabled,
smtp_port: emailForm.smtp_port,
smtp_starttls: emailForm.smtp_starttls,
smtp_ssl: emailForm.smtp_ssl,
};
if (emailForm.smtp_host.trim()) body.smtp_host = emailForm.smtp_host.trim();
if (emailForm.smtp_user.trim()) body.smtp_user = emailForm.smtp_user.trim();
if (emailForm.smtp_password.trim()) body.smtp_password = emailForm.smtp_password.trim();
if (emailForm.mail_from.trim()) body.mail_from = emailForm.mail_from.trim();
if (emailForm.mail_to.trim()) body.mail_to = emailForm.mail_to.trim();
emailLoaded.value = await apiFetch<EmailSettings>("/api/v1/settings/notifications/email", {
method: "PUT",
body: JSON.stringify(body),
});
emailForm.smtp_host = "";
emailForm.smtp_password = "";
emailForm.mail_to = "";
success.value = "Настройки email сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
savingEmail.value = false;
}
}
async function testEmail() {
testingEmail.value = true;
error.value = "";
success.value = "";
try {
const res = await apiFetch<{ message: string }>("/api/v1/settings/notifications/email/test", {
method: "POST",
});
success.value = res.message;
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка отправки";
} finally {
testingEmail.value = false;
}
}
onMounted(() => {
restoreLastCreatedCode();
load();
});
</script>
<style scoped>
.settings-page {
max-width: 52rem;
}
.settings-intro {
color: #9aa4b2;
margin-bottom: 1.25rem;
}
.settings-card {
margin-bottom: 1.25rem;
}
.settings-card h2 {
margin-top: 0;
}
.settings-form {
display: flex;
flex-direction: column;
gap: 0.85rem;
margin-bottom: 1rem;
}
.settings-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.settings-field input[type="text"],
.settings-field input[type="password"],
.settings-field input[type="url"],
.settings-field select {
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;
gap: 0.5rem;
}
.settings-meta {
font-size: 0.85rem;
color: #9aa4b2;
margin: 0;
}
.settings-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.settings-actions button.secondary {
background: #2a3441;
color: #c5d0dc;
}
.settings-hint {
font-size: 0.9rem;
color: #9aa4b2;
margin: 0;
}
.success {
color: #6dd196;
}
.settings-severity-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
align-items: center;
}
.settings-severity-toolbar input[type="search"] {
flex: 1;
min-width: 12rem;
}
.settings-severity-table-wrap {
max-height: 24rem;
overflow: auto;
border: 1px solid #2a3441;
border-radius: 6px;
}
.settings-severity-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.settings-severity-table th,
.settings-severity-table td {
text-align: left;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid #2a3441;
}
.settings-severity-table select {
min-width: 11rem;
}
.settings-mobile-table-wrap {
max-height: 16rem;
overflow: auto;
border: 1px solid #2a3441;
border-radius: 6px;
margin-bottom: 1rem;
}
.settings-mobile-code-form {
margin-top: 0.5rem;
}
.settings-enrollment-banner {
margin: 0 0 1.25rem;
padding: 1rem 1.1rem;
border: 1px solid #3d8b5f;
border-radius: 8px;
background: #15251c;
}
.settings-enrollment-banner-title {
margin: 0 0 0.35rem;
font-size: 1.05rem;
font-weight: 600;
color: #8ce8b0;
}
.settings-enrollment-banner-hint {
margin: 0 0 0.75rem;
color: #a8b8c8;
font-size: 0.92rem;
}
.settings-enrollment-code-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.settings-enrollment-code-value {
flex: 1 1 16rem;
padding: 0.55rem 0.65rem;
border-radius: 6px;
background: #0f1419;
color: #e8f0f8;
word-break: break-all;
user-select: all;
}
.settings-mobile-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
</style>