feat: host display_name в ingest, поиск хостов, UI и docs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 11:49:10 +10:00
parent 108e1756c4
commit d4bf6e536c
8 changed files with 122 additions and 11 deletions
+3 -2
View File
@@ -34,8 +34,9 @@ def list_hosts(
count_stmt = count_stmt.where(Host.product == product)
if hostname:
like = f"%{hostname}%"
stmt = stmt.where(Host.hostname.ilike(like))
count_stmt = count_stmt.where(Host.hostname.ilike(like))
host_match = Host.hostname.ilike(like) | Host.display_name.ilike(like)
stmt = stmt.where(host_match)
count_stmt = count_stmt.where(host_match)
total = db.scalar(count_stmt) or 0
rows = db.scalars(
+3 -1
View File
@@ -46,7 +46,9 @@ def upsert_host(db: Session, payload: dict) -> Host:
db.add(host)
else:
host.hostname = hostname
host.display_name = host_data.get("display_name") or host.display_name
dn = host_data.get("display_name")
if isinstance(dn, str) and dn.strip():
host.display_name = dn.strip()
host.os_version = host_data.get("os_version") or host.os_version
host.product_version = source.get("product_version") or host.product_version
host.ipv4 = host_data.get("ipv4") or host.ipv4
+11
View File
@@ -0,0 +1,11 @@
"""Extended /health payload for ops monitoring."""
def test_health_includes_stale_and_last_event(client, db_session, auth_headers):
r = client.get("/health")
assert r.status_code == 200
body = r.json()
assert body["database"] == "ok"
assert "hosts_stale" in body
assert "last_event_received_at" in body
assert body["service"] == "security-alert-center"
+26
View File
@@ -0,0 +1,26 @@
"""Hosts list API."""
from datetime import datetime, timezone
from app.models import Host
def test_hosts_search_by_display_name(client, db_session, jwt_headers):
h = Host(
hostname="pc-01",
display_name="UNMS Kalina",
os_family="linux",
product="ssh-monitor",
last_seen_at=datetime.now(timezone.utc),
)
db_session.add(h)
db_session.commit()
r = client.get("/api/v1/hosts?hostname=UNMS", headers=jwt_headers)
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["items"][0]["display_name"] == "UNMS Kalina"
r2 = client.get("/api/v1/hosts?hostname=nomatch", headers=jwt_headers)
assert r2.json()["total"] == 0
+30
View File
@@ -70,6 +70,36 @@ def test_ingest_rdp_lifecycle_201(client, auth_headers):
assert r.json()["event_id"] == event_id
def test_ingest_host_display_name_updated(client, auth_headers, db_session):
from sqlalchemy import select
from app.models import Host
event_id = str(uuid.uuid4())
payload = {
**VALID_EVENT,
"event_id": event_id,
"host": {
"hostname": "short-name",
"display_name": "UNMS Kalina",
"os_family": "linux",
},
}
assert client.post("/api/v1/events", json=payload, headers=auth_headers).status_code == 201
host = db_session.scalar(select(Host).where(Host.hostname == "short-name"))
assert host is not None
assert host.display_name == "UNMS Kalina"
payload2 = {
**payload,
"event_id": str(uuid.uuid4()),
"host": {"hostname": "short-name", "display_name": "Kalina UNMS", "os_family": "linux"},
}
assert client.post("/api/v1/events", json=payload2, headers=auth_headers).status_code == 201
db_session.refresh(host)
assert host.display_name == "Kalina UNMS"
def test_ingest_invalid_payload_422(client, auth_headers):
r = client.post(
"/api/v1/events",
+23
View File
@@ -170,6 +170,29 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
---
## 3.3. Человекочитаемое имя хоста (`host.display_name`)
В UI SAC (**Хосты**, фильтры Problems/Events) показывается `display_name`, если оно задано; иначе `hostname` из ОС.
| Агент | Параметр | В ingest |
|-------|----------|--------|
| **ssh-monitor** | `SERVER_DISPLAY_NAME` в `/etc/ssh-monitor.conf` | `host.display_name` (пусто — поле не передаётся) |
| **RDP-login-monitor** | `$ServerDisplayName` в `login_monitor.settings.ps1` | `host.display_name`; `hostname` = `$env:COMPUTERNAME` |
Пример фрагмента JSON:
```json
"host": {
"hostname": "NEW-ADMIN-PC",
"display_name": "UNMS Kalina",
"os_family": "windows"
}
```
Telegram у ssh/RDP использует ту же подпись, что и `display_name`, когда параметр задан.
---
## 4. Поля `dedup_key` (рекомендации)
| Тип | Формат dedup_key |
|-----|------------------|
+5 -5
View File
@@ -90,15 +90,15 @@
- [x] `d2-1` UI Problems: список, фильтры, `Ack/Resolve`, карточка с таймлайном
- [x] `d2-2` Dashboard: top hosts/types, `open vs resolved 24h`, drill-down
- [x] `d2-3` Ops: retention, health checks, runbook backup/restore/deploy
- [ ] `dod` DoD: нет дублей `event_id`, Problems e2e, 3 правила, UI MVP, docs, push в kalinamall
- [x] `dod` DoD: нет дублей `event_id`, Problems e2e, 3 правила, UI MVP, docs, push в kalinamall
### Отображение хостов (`display_name`, как `SERVER_DISPLAY_NAME` у ssh)
Сейчас: **ssh**`SERVER_DISPLAY_NAME` только в Telegram; в SAC уходит `socket.gethostname()`. **RDP** — параметра нет, в SAC только `$env:COMPUTERNAME`. В SAC UI «Хосты» уже показывает `display_name || hostname`, если поле пришло в ingest.
- [ ] `agent-display-rdp` **RDP-login-monitor:** `$ServerDisplayName` в `login_monitor.settings.ps1` / example; подпись в Telegram; в `Sac-Client.ps1``host.display_name` (и при необходимости согласовать `hostname`)
- [ ] `agent-display-ssh` **ssh-monitor:** в `sac-client.sh` передавать `SERVER_DISPLAY_NAME` как `host.display_name` в JSON ingest
- [ ] `sac-display-hosts` **SAC:** проверить ingest/обновление `Host.display_name`; колонка «Хосты» = человекочитаемое имя; поиск по `display_name`; `docs/agent-integration.md` + schema `host.display_name`
- [x] `agent-display-rdp` **RDP-login-monitor:** `$ServerDisplayName` в `login_monitor.settings.ps1` / example; подпись в Telegram; в `Sac-Client.ps1``host.display_name` (и при необходимости согласовать `hostname`)
- [x] `agent-display-ssh` **ssh-monitor:** в `sac-client.sh` передавать `SERVER_DISPLAY_NAME` как `host.display_name` в JSON ingest
- [x] `sac-display-hosts` **SAC:** проверить ingest/обновление `Host.display_name`; колонка «Хосты» = человекочитаемое имя; поиск по `display_name`; `docs/agent-integration.md` + schema `host.display_name`
**Приёмка:** хост с `SERVER_DISPLAY_NAME="UNMS Kalina"` (или RDP `$ServerDisplayName`) в SAC → **Хосты** отображается как **UNMS Kalina**, а не только короткое имя ОС.
@@ -121,7 +121,7 @@
- [x] `13:0014:30` Dashboard: виджеты top hosts/types
- [x] `14:3016:00` Dashboard: `open/resolved 24h` + drill-down
- [x] `16:0017:00` retention job (`events 3090d`, `problems 180d+`)
- [ ] `17:0018:00` health checks (DB/worker/heartbeat stale)
- [x] `17:0018:00` health checks (DB/worker/heartbeat stale)
- [x] `18:0019:00` runbook + финальный push в kalinamall + freeze dev
### Неделя после freeze (только тестирование)
+21 -3
View File
@@ -1,5 +1,12 @@
<template>
<h1>Хосты</h1>
<div class="filters">
<label>
Поиск (hostname / display name)
<input v-model="search" type="search" placeholder="UNMS Kalina" @keyup.enter="loadHosts" />
</label>
<button type="button" class="secondary" @click="loadHosts">Найти</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p>
<template v-else>
@@ -8,6 +15,7 @@
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
<th>Hostname</th>
<th>Product</th>
<th>OS</th>
@@ -22,7 +30,8 @@
<tbody>
<tr v-for="h in data?.items ?? []" :key="h.id">
<td>{{ h.id }}</td>
<td>{{ h.display_name || h.hostname }}</td>
<td>{{ h.display_name || "—" }}</td>
<td>{{ h.hostname }}</td>
<td>{{ h.product }} {{ h.product_version || "" }}</td>
<td>{{ h.os_family }}</td>
<td>{{ h.ipv4 || "—" }}</td>
@@ -52,6 +61,7 @@ import { apiFetch, type HostListResponse } from "../api";
const data = ref<HostListResponse | null>(null);
const loading = ref(false);
const error = ref("");
const search = ref("");
function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU");
@@ -63,14 +73,22 @@ function agentLabel(status: string) {
return "unknown";
}
onMounted(async () => {
async function loadHosts() {
loading.value = true;
error.value = "";
try {
data.value = await apiFetch<HostListResponse>("/api/v1/hosts?page=1&page_size=100");
const q = search.value.trim();
const params = new URLSearchParams({ page: "1", page_size: "100" });
if (q) params.set("hostname", q);
data.value = await apiFetch<HostListResponse>(`/api/v1/hosts?${params}`);
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка";
} finally {
loading.value = false;
}
}
onMounted(() => {
loadHosts();
});
</script>