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)
|
count_stmt = count_stmt.where(Host.product == product)
|
||||||
if hostname:
|
if hostname:
|
||||||
like = f"%{hostname}%"
|
like = f"%{hostname}%"
|
||||||
stmt = stmt.where(Host.hostname.ilike(like))
|
host_match = Host.hostname.ilike(like) | Host.display_name.ilike(like)
|
||||||
count_stmt = count_stmt.where(Host.hostname.ilike(like))
|
stmt = stmt.where(host_match)
|
||||||
|
count_stmt = count_stmt.where(host_match)
|
||||||
|
|
||||||
total = db.scalar(count_stmt) or 0
|
total = db.scalar(count_stmt) or 0
|
||||||
rows = db.scalars(
|
rows = db.scalars(
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ def upsert_host(db: Session, payload: dict) -> Host:
|
|||||||
db.add(host)
|
db.add(host)
|
||||||
else:
|
else:
|
||||||
host.hostname = hostname
|
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.os_version = host_data.get("os_version") or host.os_version
|
||||||
host.product_version = source.get("product_version") or host.product_version
|
host.product_version = source.get("product_version") or host.product_version
|
||||||
host.ipv4 = host_data.get("ipv4") or host.ipv4
|
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
|
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):
|
def test_ingest_invalid_payload_422(client, auth_headers):
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/v1/events",
|
"/api/v1/events",
|
||||||
|
|||||||
@@ -170,6 +170,29 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 3.3. Человекочитаемое имя хоста (`host.display_name`)
|
## 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 |
|
| Тип | Формат dedup_key |
|
||||||
|-----|------------------|
|
|-----|------------------|
|
||||||
|
|||||||
+5
-5
@@ -90,15 +90,15 @@
|
|||||||
- [x] `d2-1` UI Problems: список, фильтры, `Ack/Resolve`, карточка с таймлайном
|
- [x] `d2-1` UI Problems: список, фильтры, `Ack/Resolve`, карточка с таймлайном
|
||||||
- [x] `d2-2` Dashboard: top hosts/types, `open vs resolved 24h`, drill-down
|
- [x] `d2-2` Dashboard: top hosts/types, `open vs resolved 24h`, drill-down
|
||||||
- [x] `d2-3` Ops: retention, health checks, runbook backup/restore/deploy
|
- [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)
|
### Отображение хостов (`display_name`, как `SERVER_DISPLAY_NAME` у ssh)
|
||||||
|
|
||||||
Сейчас: **ssh** — `SERVER_DISPLAY_NAME` только в Telegram; в SAC уходит `socket.gethostname()`. **RDP** — параметра нет, в SAC только `$env:COMPUTERNAME`. В SAC UI «Хосты» уже показывает `display_name || hostname`, если поле пришло в ingest.
|
Сейчас: **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`)
|
- [x] `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
|
- [x] `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] `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**, а не только короткое имя ОС.
|
**Приёмка:** хост с `SERVER_DISPLAY_NAME="UNMS Kalina"` (или RDP `$ServerDisplayName`) в SAC → **Хосты** отображается как **UNMS Kalina**, а не только короткое имя ОС.
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
- [x] `13:00–14:30` Dashboard: виджеты top hosts/types
|
- [x] `13:00–14:30` Dashboard: виджеты top hosts/types
|
||||||
- [x] `14:30–16:00` Dashboard: `open/resolved 24h` + drill-down
|
- [x] `14:30–16:00` Dashboard: `open/resolved 24h` + drill-down
|
||||||
- [x] `16:00–17:00` retention job (`events 30–90d`, `problems 180d+`)
|
- [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
|
- [x] `18:00–19:00` runbook + финальный push в kalinamall + freeze dev
|
||||||
|
|
||||||
### Неделя после freeze (только тестирование)
|
### Неделя после freeze (только тестирование)
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<h1>Хосты</h1>
|
<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-if="error" class="error">{{ error }}</p>
|
||||||
<p v-else-if="loading">Загрузка…</p>
|
<p v-else-if="loading">Загрузка…</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -8,6 +15,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
|
<th>Имя</th>
|
||||||
<th>Hostname</th>
|
<th>Hostname</th>
|
||||||
<th>Product</th>
|
<th>Product</th>
|
||||||
<th>OS</th>
|
<th>OS</th>
|
||||||
@@ -22,7 +30,8 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="h in data?.items ?? []" :key="h.id">
|
<tr v-for="h in data?.items ?? []" :key="h.id">
|
||||||
<td>{{ h.id }}</td>
|
<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.product }} {{ h.product_version || "" }}</td>
|
||||||
<td>{{ h.os_family }}</td>
|
<td>{{ h.os_family }}</td>
|
||||||
<td>{{ h.ipv4 || "—" }}</td>
|
<td>{{ h.ipv4 || "—" }}</td>
|
||||||
@@ -52,6 +61,7 @@ import { apiFetch, type HostListResponse } from "../api";
|
|||||||
const data = ref<HostListResponse | null>(null);
|
const data = ref<HostListResponse | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
|
const search = ref("");
|
||||||
|
|
||||||
function formatDt(iso: string) {
|
function formatDt(iso: string) {
|
||||||
return new Date(iso).toLocaleString("ru-RU");
|
return new Date(iso).toLocaleString("ru-RU");
|
||||||
@@ -63,14 +73,22 @@ function agentLabel(status: string) {
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
async function loadHosts() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Ошибка";
|
error.value = e instanceof Error ? e.message : "Ошибка";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadHosts();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user