cd2f27792d
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.
1513 lines
51 KiB
Vue
1513 lines
51 KiB
Vue
<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>
|