feat: SAC 0.7.0 display_name columns and event severity overrides
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "sac-ui",
|
||||
"private": true,
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -132,6 +132,7 @@ export interface EventSummary {
|
||||
event_id: string;
|
||||
host_id: number;
|
||||
hostname: string;
|
||||
display_name?: string | null;
|
||||
occurred_at: string;
|
||||
received_at: string;
|
||||
category: string;
|
||||
@@ -180,6 +181,7 @@ export interface ProblemSummary {
|
||||
id: number;
|
||||
host_id: number | null;
|
||||
hostname: string | null;
|
||||
display_name?: string | null;
|
||||
title: string;
|
||||
summary: string;
|
||||
severity: string;
|
||||
@@ -228,6 +230,7 @@ export async function resolveProblem(problemId: number): Promise<{ id: number; s
|
||||
export interface TopHostItem {
|
||||
host_id: number;
|
||||
hostname: string;
|
||||
display_name?: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Display name from host card (SAC «Имя сервера»). */
|
||||
export function formatServerName(displayName?: string | null): string {
|
||||
const name = displayName?.trim();
|
||||
return name || "—";
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export const APP_NAME = "Security Alert Center";
|
||||
export const APP_VERSION = "0.6.0";
|
||||
export const APP_VERSION = "0.7.0";
|
||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||
|
||||
@@ -234,6 +234,8 @@
|
||||
|
||||
<th>Хост</th>
|
||||
|
||||
<th>Имя сервера</th>
|
||||
|
||||
<th>Severity</th>
|
||||
|
||||
<th>Title</th>
|
||||
@@ -264,6 +266,8 @@
|
||||
|
||||
</td>
|
||||
|
||||
<td>{{ formatServerName(e.display_name) }}</td>
|
||||
|
||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||
|
||||
<td>{{ e.title }}</td>
|
||||
@@ -293,6 +297,7 @@
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
|
||||
import { apiFetch, getToken, type DashboardSummary, type EventSummary } from "../api";
|
||||
import { formatServerName } from "../utils/hostDisplay";
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<option>critical</option>
|
||||
</select>
|
||||
<input v-model="typeFilter" placeholder="type" @keyup.enter="load(1)" />
|
||||
<input v-model="hostnameFilter" placeholder="hostname" @keyup.enter="load(1)" />
|
||||
<input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
|
||||
<button type="button" @click="load(1)">Применить</button>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
@@ -23,6 +23,7 @@
|
||||
<th>ID</th>
|
||||
<th>Время</th>
|
||||
<th>Хост</th>
|
||||
<th>Имя сервера</th>
|
||||
<th>Severity</th>
|
||||
<th>Type</th>
|
||||
<th>Title</th>
|
||||
@@ -35,6 +36,7 @@
|
||||
</td>
|
||||
<td>{{ formatDt(e.occurred_at) }}</td>
|
||||
<td>{{ e.hostname }}</td>
|
||||
<td>{{ formatServerName(e.display_name) }}</td>
|
||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||
<td><code>{{ e.type }}</code></td>
|
||||
<td>{{ e.title }}</td>
|
||||
@@ -60,6 +62,7 @@
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { apiFetch, type EventListResponse } from "../api";
|
||||
import { formatServerName } from "../utils/hostDisplay";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<option>high</option>
|
||||
<option>critical</option>
|
||||
</select>
|
||||
<input v-model="hostnameFilter" placeholder="hostname" @keyup.enter="load(1)" />
|
||||
<input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
|
||||
<button type="button" @click="load(1)">Применить</button>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
@@ -27,6 +27,7 @@
|
||||
<th>ID</th>
|
||||
<th>Обновлено</th>
|
||||
<th>Хост</th>
|
||||
<th>Имя сервера</th>
|
||||
<th>Severity</th>
|
||||
<th>Status</th>
|
||||
<th>Rule</th>
|
||||
@@ -41,6 +42,7 @@
|
||||
</td>
|
||||
<td>{{ formatDt(p.updated_at) }}</td>
|
||||
<td>{{ p.hostname ?? "—" }}</td>
|
||||
<td>{{ formatServerName(p.display_name) }}</td>
|
||||
<td :class="'sev-' + p.severity">{{ p.severity }}</td>
|
||||
<td :class="'status-' + p.status">{{ p.status }}</td>
|
||||
<td><code>{{ p.rule_id ?? "—" }}</code></td>
|
||||
@@ -88,6 +90,7 @@
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ackProblem, apiFetch, resolveProblem, type ProblemListResponse } from "../api";
|
||||
import { formatServerName } from "../utils/hostDisplay";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</p>
|
||||
|
||||
<div class="filters">
|
||||
<input v-model="hostname" placeholder="hostname" @keyup.enter="load(1)" />
|
||||
<input v-model="hostname" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
|
||||
<select v-model="reportType" @change="load(1)">
|
||||
<option value="report.daily.ssh">SSH (report.daily.ssh)</option>
|
||||
<option value="report.daily.rdp">RDP (report.daily.rdp)</option>
|
||||
@@ -23,6 +23,7 @@
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Хост</th>
|
||||
<th>Имя сервера</th>
|
||||
<th>Тип</th>
|
||||
<th>Сводка</th>
|
||||
</tr>
|
||||
@@ -40,6 +41,7 @@
|
||||
<td>
|
||||
<strong>{{ e.hostname }}</strong>
|
||||
</td>
|
||||
<td>{{ formatServerName(e.display_name) }}</td>
|
||||
<td><code>{{ e.type }}</code></td>
|
||||
<td class="report-summary-cell">{{ e.summary }}</td>
|
||||
</tr>
|
||||
@@ -64,6 +66,7 @@
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { apiFetch, type EventListResponse } from "../api";
|
||||
import { formatServerName } from "../utils/hostDisplay";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -53,6 +53,47 @@
|
||||
</form>
|
||||
</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">
|
||||
@@ -250,11 +291,22 @@ interface NotificationPolicy {
|
||||
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 savingTelegram = ref(false);
|
||||
const savingWebhook = ref(false);
|
||||
const savingEmail = ref(false);
|
||||
const savingSeverity = ref(false);
|
||||
const testingTelegram = ref(false);
|
||||
const testingWebhook = ref(false);
|
||||
const testingEmail = ref(false);
|
||||
@@ -264,6 +316,8 @@ 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 severityRows = ref<SeverityOverrideRowUi[]>([]);
|
||||
const severityFilter = ref("");
|
||||
|
||||
const policyForm = reactive({
|
||||
min_severity: "warning",
|
||||
@@ -323,6 +377,58 @@ const mailToPlaceholder = computed(() =>
|
||||
emailLoaded.value?.mail_to_hint ? `оставить ${emailLoaded.value.mail_to_hint}` : "admin@example.com",
|
||||
);
|
||||
|
||||
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 load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
@@ -357,6 +463,7 @@ async function load() {
|
||||
emailForm.mail_to = "";
|
||||
emailForm.smtp_starttls = data.email.smtp_starttls;
|
||||
emailForm.smtp_ssl = data.email.smtp_ssl;
|
||||
await loadSeverityOverrides();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
||||
} finally {
|
||||
@@ -522,7 +629,7 @@ onMounted(load);
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
max-width: 42rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
.settings-intro {
|
||||
@@ -614,4 +721,41 @@ onMounted(load);
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user