feat: improve SAC UI layout, reports state, and hosts sorting
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -191,6 +191,15 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
|
||||
Telegram у ssh/RDP использует ту же подпись, что и `display_name`, когда параметр задан.
|
||||
|
||||
### Версии агентов (единый номер)
|
||||
|
||||
| Репозиторий | Источник версии | SAC `product_version` |
|
||||
|-------------|-----------------|------------------------|
|
||||
| **ssh-monitor** | `SSH_MONITOR_VERSION` в `ssh-monitor` + `version.txt` | из `SSH_MONITOR_VERSION` |
|
||||
| **RDP-login-monitor** | `$ScriptVersion` в `Login_Monitor.ps1` + `version.txt` | из `$ScriptVersion` |
|
||||
|
||||
Не задавайте разные номера для «скрипта» и «SAC-модуля» — в UI **Хосты** отображается одна версия с ingest.
|
||||
|
||||
---
|
||||
|
||||
## 4. Поля `dedup_key` (рекомендации)
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# E2E и нагрузочное тестирование SAC + агенты
|
||||
|
||||
Чеклист после freeze MVP (`work-plan.md`). Отмечайте дату и хост.
|
||||
|
||||
## Дни 1–2: функциональный E2E
|
||||
|
||||
### Linux (ssh-monitor, `UseSAC=dual` или `exclusive`)
|
||||
|
||||
- [ ] `ssh-monitor --check-sac` → HTTP 201/202, событие `agent.test` в UI
|
||||
- [ ] Успешный SSH login → `ssh.login.success` в SAC + Telegram (dual)
|
||||
- [ ] Неудачный SSH → `ssh.login.failed`
|
||||
- [ ] `SERVER_DISPLAY_NAME` задан → в SAC **Хосты** отображается display name
|
||||
- [ ] Heartbeat → `agent.heartbeat`, хост не `stale` в пределах `SAC_HEARTBEAT_STALE_MINUTES`
|
||||
- [ ] Daily report → `report.daily.ssh` (если включён)
|
||||
|
||||
### Windows (RDP-login-monitor, `UseSAC=dual`)
|
||||
|
||||
- [ ] `-CheckSac` → ingest OK
|
||||
- [ ] Старт монитора → `agent.lifecycle` в SAC (HTTP 201), Telegram «ЗАПУЩЕН»
|
||||
- [ ] Версия в UI **Хосты** = `$ScriptVersion` после деплоя
|
||||
- [ ] `$ServerDisplayName` → display name в SAC
|
||||
- [ ] 4624/4625 (тестовый вход) → `rdp.login.*` при наличии событий
|
||||
- [ ] Dashboard: новое событие появляется в «Последние события» без F5 (~5 с)
|
||||
|
||||
### SAC UI
|
||||
|
||||
- [ ] Problems: open → ack → resolve
|
||||
- [ ] Дубликат `event_id` → HTTP 409, одна строка в БД
|
||||
- [ ] Правило brute-force (порог событий) → Problem
|
||||
|
||||
## Дни 3–4: нагрузка и устойчивость
|
||||
|
||||
- [ ] Пакет 50+ `ssh.login.failed` с одного IP → агрегация/Problem, без зависания UI
|
||||
- [ ] Повтор ingest с тем же `event_id` → 409
|
||||
- [ ] Остановка SAC API → spool на агенте, восстановление после `/health`
|
||||
- [ ] Flapping heartbeat: stale → warning в health / хостах
|
||||
|
||||
## День 5: регресс
|
||||
|
||||
- [ ] Обновление RDP через Deploy: одна версия в `deployed_version.txt`, ingest после 1.2.14+ JSON
|
||||
- [ ] `sac-retention.timer` active; ручной `systemctl start sac-retention.service` — exit 0
|
||||
- [ ] Известные issues занесены в issue tracker / `known-issues.md`
|
||||
|
||||
## Дни 6–7: soak
|
||||
|
||||
- [ ] 48–72 ч dual mode: нет роста spool без причины, диск БД в норме
|
||||
- [ ] Только bugfix по результатам выше
|
||||
|
||||
## Версии на момент чеклиста
|
||||
|
||||
| Компонент | Версия |
|
||||
|-----------|--------|
|
||||
| ssh-monitor | 1.2.6-SAC |
|
||||
| RDP-login-monitor | 1.2.15-SAC |
|
||||
| security-alert-center | см. `backend/app/version.py` |
|
||||
@@ -126,6 +126,8 @@
|
||||
|
||||
### Неделя после freeze (только тестирование)
|
||||
|
||||
Чеклист: [testing-e2e-checklist.md](testing-e2e-checklist.md)
|
||||
|
||||
- [ ] `дни 1–2` функциональные E2E (`RDP + SSH + mixed load`)
|
||||
- [ ] `дни 3–4` нагрузка, дубликаты, флаппинг heartbeat
|
||||
- [ ] `день 5` регресс + known issues
|
||||
|
||||
+13
-3
@@ -18,9 +18,10 @@ a {
|
||||
}
|
||||
|
||||
.layout {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.25rem 2rem;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 1rem 1rem 2rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
@@ -59,6 +60,15 @@ th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.th-sort {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sev-critical,
|
||||
.sev-high {
|
||||
color: #ff6b6b;
|
||||
|
||||
@@ -1,86 +1,177 @@
|
||||
<template>
|
||||
|
||||
<p>
|
||||
<p>
|
||||
|
||||
<RouterLink v-if="fromReports" :to="{ path: '/reports', query: backToReportsQuery }">← Отчёты</RouterLink>
|
||||
|
||||
<RouterLink v-else to="/events">← События</RouterLink>
|
||||
|
||||
</p>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<p v-else-if="loading">Загрузка…</p>
|
||||
|
||||
<template v-else-if="event">
|
||||
|
||||
<h1>{{ event.title }}</h1>
|
||||
|
||||
<div class="card report-meta">
|
||||
|
||||
<p>
|
||||
|
||||
<strong>ID:</strong> {{ event.id }} · <strong>event_id:</strong> {{ event.event_id }}
|
||||
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<template v-else-if="event">
|
||||
|
||||
<strong>Хост:</strong>
|
||||
<RouterLink :to="{ path: '/reports', query: { hostname: event.hostname } }">
|
||||
|
||||
{{ event.hostname }}
|
||||
|
||||
</RouterLink>
|
||||
<div class="card report-meta">
|
||||
|
||||
· <strong>Severity:</strong>
|
||||
|
||||
<span :class="'sev-' + event.severity">{{ event.severity }}</span>
|
||||
|
||||
</p>
|
||||
|
||||
<p><strong>Type:</strong> <code>{{ event.type }}</code></p>
|
||||
|
||||
<p><strong>Occurred:</strong> {{ formatDt(event.occurred_at) }}</p>
|
||||
|
||||
<p v-if="!isReport"><strong>Summary:</strong> {{ event.summary }}</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<template v-if="isReport">
|
||||
|
||||
<h2>Отчёт</h2>
|
||||
|
||||
<p class="report-summary-line">{{ event.summary }}</p>
|
||||
|
||||
<ReportBodyCard :type="event.type" :summary="event.summary" :details="event.details" />
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<details v-if="event.details && !isReport" class="raw-details">
|
||||
|
||||
<summary>details (JSON)</summary>
|
||||
|
||||
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
<details v-if="isReport && event.details" class="raw-details">
|
||||
|
||||
<summary>details (JSON)</summary>
|
||||
|
||||
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
<details class="raw-details">
|
||||
|
||||
<summary>payload (JSON)</summary>
|
||||
|
||||
<pre>{{ JSON.stringify(event.payload, null, 2) }}</pre>
|
||||
|
||||
</details>
|
||||
|
||||
</template>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { apiFetch, type EventDetail } from "../api";
|
||||
|
||||
import ReportBodyCard from "../components/ReportBodyCard.vue";
|
||||
|
||||
import { isDailyReportType } from "../utils/reportDisplay";
|
||||
|
||||
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
|
||||
|
||||
const event = ref<EventDetail | null>(null);
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const error = ref("");
|
||||
|
||||
|
||||
|
||||
const fromReports = computed(() => route.query.from === "reports");
|
||||
|
||||
const isReport = computed(() => (event.value ? isDailyReportType(event.value.type) : false));
|
||||
|
||||
const backToReportsQuery = computed(() => {
|
||||
const q: Record<string, string> = {};
|
||||
if (typeof route.query.type === "string" && route.query.type) q.type = route.query.type;
|
||||
if (typeof route.query.hostname === "string" && route.query.hostname) q.hostname = route.query.hostname;
|
||||
return q;
|
||||
});
|
||||
|
||||
|
||||
|
||||
function formatDt(iso: string) {
|
||||
|
||||
return new Date(iso).toLocaleString("ru-RU");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function load() {
|
||||
|
||||
loading.value = true;
|
||||
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
|
||||
event.value = await apiFetch<EventDetail>(`/api/v1/events/${props.id}`);
|
||||
|
||||
} catch (e) {
|
||||
|
||||
error.value = e instanceof Error ? e.message : "Не найдено";
|
||||
|
||||
} finally {
|
||||
|
||||
loading.value = false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
onMounted(load);
|
||||
|
||||
watch(() => props.id, load);
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
@@ -14,21 +14,21 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Имя</th>
|
||||
<th>Hostname</th>
|
||||
<th>Product</th>
|
||||
<th>OS</th>
|
||||
<th>IPv4</th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('id')">ID {{ sortMark('id') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('display_name')">Имя {{ sortMark('display_name') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('hostname')">Hostname {{ sortMark('hostname') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('product')">Product {{ sortMark('product') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('os_family')">OS {{ sortMark('os_family') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('ipv4')">IPv4 {{ sortMark('ipv4') }}</button></th>
|
||||
<th>Агент</th>
|
||||
<th>Heartbeat</th>
|
||||
<th>Отчёт</th>
|
||||
<th>Last seen</th>
|
||||
<th>Events</th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('last_daily_report_at')">Отчёт {{ sortMark('last_daily_report_at') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('last_seen_at')">Last seen {{ sortMark('last_seen_at') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('event_count')">Events {{ sortMark('event_count') }}</button></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="h in data?.items ?? []" :key="h.id">
|
||||
<tr v-for="h in sortedItems" :key="h.id">
|
||||
<td>{{ h.id }}</td>
|
||||
<td>{{ h.display_name || "—" }}</td>
|
||||
<td>{{ h.hostname }}</td>
|
||||
@@ -55,13 +55,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { apiFetch, type HostListResponse } from "../api";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { apiFetch, type HostListResponse, type HostSummary } from "../api";
|
||||
|
||||
const data = ref<HostListResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const search = ref("");
|
||||
type SortKey =
|
||||
| "id"
|
||||
| "display_name"
|
||||
| "hostname"
|
||||
| "product"
|
||||
| "os_family"
|
||||
| "ipv4"
|
||||
| "last_seen_at"
|
||||
| "event_count"
|
||||
| "last_daily_report_at";
|
||||
const sortBy = ref<SortKey>("last_seen_at");
|
||||
const sortDir = ref<"asc" | "desc">("desc");
|
||||
|
||||
function formatDt(iso: string) {
|
||||
return new Date(iso).toLocaleString("ru-RU");
|
||||
@@ -73,6 +85,75 @@ function agentLabel(status: string) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function setSort(key: SortKey) {
|
||||
if (sortBy.value === key) {
|
||||
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
||||
return;
|
||||
}
|
||||
sortBy.value = key;
|
||||
sortDir.value = key === "id" || key === "last_seen_at" || key === "event_count" ? "desc" : "asc";
|
||||
}
|
||||
|
||||
function sortMark(key: SortKey) {
|
||||
if (sortBy.value !== key) return "";
|
||||
return sortDir.value === "asc" ? "↑" : "↓";
|
||||
}
|
||||
|
||||
function compareNullableStrings(a: string | null, b: string | null) {
|
||||
const av = (a ?? "").trim().toLowerCase();
|
||||
const bv = (b ?? "").trim().toLowerCase();
|
||||
return av.localeCompare(bv, "ru");
|
||||
}
|
||||
|
||||
function compareNullableDates(a: string | null, b: string | null) {
|
||||
const av = a ? new Date(a).getTime() : 0;
|
||||
const bv = b ? new Date(b).getTime() : 0;
|
||||
return av - bv;
|
||||
}
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
const items = [...(data.value?.items ?? [])];
|
||||
const key = sortBy.value;
|
||||
const dir = sortDir.value === "asc" ? 1 : -1;
|
||||
items.sort((a: HostSummary, b: HostSummary) => {
|
||||
let cmp = 0;
|
||||
switch (key) {
|
||||
case "id":
|
||||
cmp = a.id - b.id;
|
||||
break;
|
||||
case "display_name":
|
||||
cmp = compareNullableStrings(a.display_name, b.display_name);
|
||||
break;
|
||||
case "hostname":
|
||||
cmp = compareNullableStrings(a.hostname, b.hostname);
|
||||
break;
|
||||
case "product":
|
||||
cmp = compareNullableStrings(a.product, b.product);
|
||||
break;
|
||||
case "os_family":
|
||||
cmp = compareNullableStrings(a.os_family, b.os_family);
|
||||
break;
|
||||
case "ipv4":
|
||||
cmp = compareNullableStrings(a.ipv4, b.ipv4);
|
||||
break;
|
||||
case "event_count":
|
||||
cmp = (a.event_count ?? 0) - (b.event_count ?? 0);
|
||||
break;
|
||||
case "last_daily_report_at":
|
||||
cmp = compareNullableDates(a.last_daily_report_at, b.last_daily_report_at);
|
||||
break;
|
||||
case "last_seen_at":
|
||||
cmp = compareNullableDates(a.last_seen_at, b.last_seen_at);
|
||||
break;
|
||||
}
|
||||
if (cmp === 0) {
|
||||
cmp = a.hostname.localeCompare(b.hostname, "ru");
|
||||
}
|
||||
return cmp * dir;
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
||||
async function loadHosts() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
@@ -75,13 +75,16 @@ const page = ref(1);
|
||||
const pageSize = 30;
|
||||
const hostname = ref("");
|
||||
const reportType = ref("report.daily.ssh");
|
||||
const REPORT_TYPE_KEY = "sac_reports_type";
|
||||
|
||||
function formatDt(iso: string) {
|
||||
return new Date(iso).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
function openReport(id: number) {
|
||||
router.push({ path: `/events/${id}`, query: { from: "reports" } });
|
||||
const query: Record<string, string> = { from: "reports", type: reportType.value };
|
||||
if (hostname.value.trim()) query.hostname = hostname.value.trim();
|
||||
router.push({ path: `/events/${id}`, query });
|
||||
}
|
||||
|
||||
async function load(p: number) {
|
||||
@@ -110,7 +113,14 @@ onMounted(() => {
|
||||
const h = route.query.hostname;
|
||||
if (typeof h === "string" && h) hostname.value = h;
|
||||
const t = route.query.type;
|
||||
if (typeof t === "string" && t) reportType.value = t;
|
||||
if (typeof t === "string" && t) {
|
||||
reportType.value = t;
|
||||
} else {
|
||||
const savedType = localStorage.getItem(REPORT_TYPE_KEY);
|
||||
if (savedType === "report.daily.ssh" || savedType === "report.daily.rdp") {
|
||||
reportType.value = savedType;
|
||||
}
|
||||
}
|
||||
load(1);
|
||||
});
|
||||
|
||||
@@ -123,4 +133,8 @@ watch(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(reportType, (value) => {
|
||||
localStorage.setItem(REPORT_TYPE_KEY, value);
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user