feat: host display_name в ingest, поиск хостов, UI и docs
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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:00–14:30` Dashboard: виджеты top hosts/types
|
||||
- [x] `14:30–16:00` Dashboard: `open/resolved 24h` + drill-down
|
||||
- [x] `16:00–17:00` retention job (`events 30–90d`, `problems 180d+`)
|
||||
- [ ] `17:00–18:00` health checks (DB/worker/heartbeat stale)
|
||||
- [x] `17:00–18:00` health checks (DB/worker/heartbeat stale)
|
||||
- [x] `18:00–19:00` runbook + финальный push в kalinamall + freeze dev
|
||||
|
||||
### Неделя после freeze (только тестирование)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user