feat: host inventory (agent.inventory), host detail page, UI columns

Store hardware/software snapshots on hosts; warn on hardware changes. Host card from Hosts list. Add agent version column to Events/Overview; rename Hosts table columns and sort by status.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-05 09:53:00 +10:00
parent 5d3cda2ab6
commit 54337a5917
23 changed files with 826 additions and 17 deletions
@@ -0,0 +1,26 @@
"""host inventory JSONB
Revision ID: 013
Revises: 012
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "013"
down_revision: Union[str, None] = "012"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("hosts", sa.Column("inventory", JSONB, nullable=True))
op.add_column("hosts", sa.Column("inventory_updated_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("hosts", "inventory_updated_at")
op.drop_column("hosts", "inventory")
+53 -1
View File
@@ -7,7 +7,7 @@ from app.auth.jwt_auth import get_current_user, require_admin
from app.config import get_settings
from app.database import get_db
from app.models import Event, Host
from app.schemas.list_models import HostListResponse, HostSummary
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
from app.services.host_delete import delete_host_and_related
from app.services.host_health import (
HEARTBEAT_TYPE,
@@ -63,6 +63,7 @@ def list_hosts(
hostname=host.hostname,
display_name=host.display_name,
os_family=host.os_family,
os_version=host.os_version,
product=host.product,
product_version=host.product_version,
ipv4=host.ipv4,
@@ -70,6 +71,7 @@ def list_hosts(
event_count=int(event_count or 0),
last_heartbeat_at=last_hb,
last_daily_report_at=report_map.get(host.id),
last_inventory_at=host.inventory_updated_at,
agent_status=agent_status(
last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes
),
@@ -79,6 +81,56 @@ def list_hosts(
return HostListResponse(items=items, total=total, page=page, page_size=page_size)
def _host_detail_from_model(
host: Host,
*,
db: Session,
settings,
) -> HostDetail:
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
report_map = max_daily_report_time_by_host(db)
event_count = db.scalar(
select(func.count()).select_from(Event).where(Event.host_id == host.id)
)
last_hb = hb_map.get(host.id)
return HostDetail(
id=host.id,
hostname=host.hostname,
display_name=host.display_name,
os_family=host.os_family,
os_version=host.os_version,
product=host.product,
product_version=host.product_version,
ipv4=host.ipv4,
ipv6=host.ipv6,
last_seen_at=host.last_seen_at,
event_count=int(event_count or 0),
last_heartbeat_at=last_hb,
last_daily_report_at=report_map.get(host.id),
last_inventory_at=host.inventory_updated_at,
agent_status=agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes),
agent_instance_id=host.agent_instance_id,
tags=host.tags if isinstance(host.tags, list) else [],
use_sac_mode=host.use_sac_mode,
inventory=host.inventory if isinstance(host.inventory, dict) else None,
inventory_updated_at=host.inventory_updated_at,
created_at=host.created_at,
)
@router.get("/{host_id}", response_model=HostDetail)
def get_host(
host_id: int,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> HostDetail:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
settings = get_settings()
return _host_detail_from_model(host, db=db, settings=settings)
class HostDeleteResponse(BaseModel):
status: str
host_id: int
+1
View File
@@ -6,6 +6,7 @@ DEFAULT_EVENT_SEVERITIES: dict[str, str] = {
# Agent / lifecycle
"agent.heartbeat": "info",
"agent.lifecycle": "info",
"agent.inventory": "info",
"agent.test": "info",
"agent.recovered": "info",
# SSH
+2
View File
@@ -21,6 +21,8 @@ class Host(Base):
ipv4: Mapped[str | None] = mapped_column(String(45))
ipv6: Mapped[str | None] = mapped_column(String(45))
tags: Mapped[list | None] = mapped_column(JSONB, default=list)
inventory: Mapped[dict | None] = mapped_column(JSONB)
inventory_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+13
View File
@@ -8,6 +8,7 @@ class HostSummary(BaseModel):
hostname: str
display_name: str | None
os_family: str
os_version: str | None = None
product: str
product_version: str | None
ipv4: str | None
@@ -15,11 +16,22 @@ class HostSummary(BaseModel):
event_count: int | None = None
last_heartbeat_at: datetime | None = None
last_daily_report_at: datetime | None = None
last_inventory_at: datetime | None = None
agent_status: str = "unknown"
model_config = {"from_attributes": True}
class HostDetail(HostSummary):
agent_instance_id: str | None = None
ipv6: str | None = None
tags: list | None = None
use_sac_mode: str | None = None
inventory: dict | None = None
inventory_updated_at: datetime | None = None
created_at: datetime | None = None
class HostListResponse(BaseModel):
items: list[HostSummary]
total: int
@@ -33,6 +45,7 @@ class EventSummary(BaseModel):
host_id: int
hostname: str
display_name: str | None = None
product_version: str | None = None
occurred_at: datetime
received_at: datetime
category: str
+1
View File
@@ -10,6 +10,7 @@ def event_to_summary(event: Event) -> EventSummary:
host_id=event.host_id,
hostname=host.hostname,
display_name=host.display_name,
product_version=host.product_version,
occurred_at=event.occurred_at,
received_at=event.received_at,
category=event.category,
+162
View File
@@ -0,0 +1,162 @@
"""Host hardware/software inventory from agent.inventory ingest."""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any
from app.models import Host
INVENTORY_EVENT_TYPE = "agent.inventory"
def _as_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _as_list(value: Any) -> list[Any]:
return value if isinstance(value, list) else []
def _round_gb(value: Any) -> float | None:
try:
return round(float(value), 2)
except (TypeError, ValueError):
return None
def extract_inventory_payload(details: dict | None) -> dict[str, Any] | None:
if not isinstance(details, dict):
return None
inv = details.get("inventory")
return inv if isinstance(inv, dict) else None
def hardware_snapshot(inventory: dict[str, Any]) -> dict[str, Any]:
processors: list[tuple[str, int | None, int | None]] = []
for item in _as_list(inventory.get("processor")):
row = _as_dict(item)
name = str(row.get("name") or "").strip()
if not name:
continue
cores = row.get("cores")
logical = row.get("logical_processors")
try:
cores_i = int(cores) if cores is not None else None
except (TypeError, ValueError):
cores_i = None
try:
logical_i = int(logical) if logical is not None else None
except (TypeError, ValueError):
logical_i = None
processors.append((name, cores_i, logical_i))
processors.sort()
motherboard = _as_dict(inventory.get("motherboard"))
mobo_product = str(motherboard.get("product") or "").strip()
memory_gb = _round_gb(inventory.get("memory_gb"))
disks: list[tuple[str, float | None, str]] = []
for item in _as_list(inventory.get("disks")):
row = _as_dict(item)
friendly = str(row.get("friendly_name") or "").strip()
media = str(row.get("media_type") or "").strip()
size_gb = _round_gb(row.get("size_gb"))
if friendly:
disks.append((friendly, size_gb, media))
disks.sort()
video: list[str] = sorted(
{
str(_as_dict(item).get("name") or "").strip()
for item in _as_list(inventory.get("video"))
if str(_as_dict(item).get("name") or "").strip()
}
)
return {
"processor": processors,
"motherboard_product": mobo_product,
"memory_gb": memory_gb,
"disks": disks,
"video": video,
}
def _format_change(field: str, old: Any, new: Any) -> dict[str, Any]:
return {"field": field, "old": old, "new": new}
def diff_hardware(old: dict[str, Any] | None, new: dict[str, Any]) -> list[dict[str, Any]]:
if not old:
return []
prev = hardware_snapshot(old)
curr = hardware_snapshot(new)
changes: list[dict[str, Any]] = []
if prev["processor"] != curr["processor"]:
changes.append(_format_change("processor", prev["processor"], curr["processor"]))
if prev["motherboard_product"] != curr["motherboard_product"]:
changes.append(
_format_change("motherboard", prev["motherboard_product"], curr["motherboard_product"])
)
if prev["memory_gb"] != curr["memory_gb"]:
changes.append(_format_change("memory_gb", prev["memory_gb"], curr["memory_gb"]))
if prev["disks"] != curr["disks"]:
changes.append(_format_change("disks", prev["disks"], curr["disks"]))
if prev["video"] != curr["video"]:
changes.append(_format_change("video", prev["video"], curr["video"]))
return changes
def inventory_fingerprint(inventory: dict[str, Any]) -> str:
snap = hardware_snapshot(inventory)
raw = json.dumps(snap, sort_keys=True, ensure_ascii=False, default=str)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def apply_os_version_from_inventory(host: Host, inventory: dict[str, Any]) -> None:
windows = _as_dict(inventory.get("windows"))
product = str(windows.get("product_name") or "").strip()
version = str(windows.get("version") or "").strip()
if product and version:
host.os_version = f"{product} ({version})"
elif product:
host.os_version = product
elif version:
host.os_version = version
def process_inventory_ingest(
host: Host,
details: dict | None,
) -> tuple[str, list[dict[str, Any]], dict[str, Any] | None]:
"""
Update host.inventory from agent.inventory details.
Returns (severity, hardware_changes, merged_details_patch).
severity is warning when hardware changed, else info.
"""
inventory = extract_inventory_payload(details)
if inventory is None:
return "info", [], None
previous = host.inventory if isinstance(host.inventory, dict) else None
changes = diff_hardware(previous, inventory)
now = datetime.now(timezone.utc)
host.inventory = inventory
host.inventory_updated_at = now
apply_os_version_from_inventory(host, inventory)
patch: dict[str, Any] = {
"inventory_fingerprint": inventory_fingerprint(inventory),
"first_inventory": previous is None,
}
if changes:
patch["hardware_changes"] = changes
severity = "warning" if changes else "info"
return severity, changes, patch
+22 -3
View File
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from app.models import Event, Host
from app.services.daily_report_format import normalize_daily_report_details
from app.services.event_severity_overrides import apply_severity_override
from app.services.host_inventory import INVENTORY_EVENT_TYPE, process_inventory_ingest
DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
@@ -80,15 +81,33 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
details = payload.get("details")
if payload.get("type") in DAILY_REPORT_TYPES:
details = normalize_daily_report_details(details, host, payload["type"])
severity = payload["severity"]
title = payload["title"]
summary = payload["summary"]
if payload.get("type") == INVENTORY_EVENT_TYPE:
inv_severity, hw_changes, inv_patch = process_inventory_ingest(host, details)
severity = inv_severity
if inv_patch:
merged = dict(details) if isinstance(details, dict) else {}
merged.update(inv_patch)
details = merged
if hw_changes:
change_fields = ", ".join(c["field"] for c in hw_changes)
title = f"Hardware inventory changed on {host.hostname}"
summary = (
f"Изменилось железо на {host.display_name or host.hostname}: {change_fields}"
)
event = Event(
event_id=event_id,
host_id=host.id,
occurred_at=_parse_dt(payload["occurred_at"]),
category=payload["category"],
type=payload["type"],
severity=payload["severity"],
title=payload["title"],
summary=payload["summary"],
severity=severity,
title=title,
summary=summary,
details=details,
raw=payload.get("raw"),
dedup_key=payload.get("dedup_key"),
@@ -333,6 +333,50 @@ def format_smb_admin_share_html(event: Event) -> str:
return msg.rstrip()
def _format_inventory_change_line(change: dict[str, Any]) -> str:
field = html_escape(str(change.get("field") or "?"))
old = change.get("old")
new = change.get("new")
return f"• <b>{field}</b>: {html_escape(old)}{html_escape(new)}"
def format_agent_inventory_html(event: Event) -> str:
details = _details_dict(event)
changes = details.get("hardware_changes")
if isinstance(changes, list) and changes:
msg = "<b>⚠️ Изменилось железо хоста</b>\n"
msg += _line("🏢", "Хост", host_label(event.host))
msg += _line("🕐", "Время", format_time(event.occurred_at))
msg += "\n"
for item in changes:
if isinstance(item, dict):
msg += _format_inventory_change_line(item) + "\n"
if event.summary:
msg += f"\n{html_escape(event.summary)}"
return msg.rstrip()
inv = details.get("inventory")
if not isinstance(inv, dict):
inv = {}
msg = "<b>🖥️ Инвентаризация хоста</b>\n"
msg += _line("🏢", "Хост", host_label(event.host))
windows = inv.get("windows") if isinstance(inv.get("windows"), dict) else {}
os_name = windows.get("product_name") or event.host.os_version if event.host else None
if os_name:
msg += _line("💿", "ОС", html_escape(os_name))
mem = inv.get("memory_gb")
if mem is not None:
msg += _line("🧠", "RAM", f"{html_escape(mem)} GB")
procs = inv.get("processor")
if isinstance(procs, list) and procs:
names = [str(p.get("name") or "").strip() for p in procs if isinstance(p, dict)]
names = [n for n in names if n]
if names:
msg += _line("⚙️", "CPU", html_escape("; ".join(names)))
msg += _line("🕐", "Время", format_time(event.occurred_at))
return msg.rstrip()
def format_generic_event_html(event: Event) -> str:
sev = event.severity.upper()
msg = f"<b>🚨 SAC: {html_escape(event.title)}</b>\n"
@@ -453,6 +497,8 @@ def _format_event_body_html(event: Event) -> str:
return format_winrm_session_html(event)
if event.type.startswith("smb."):
return format_smb_admin_share_html(event)
if event.type == "agent.inventory":
return format_agent_inventory_html(event)
return format_generic_event_html(event)
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
APP_VERSION = "0.7.4"
APP_VERSION = "0.7.5"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+132
View File
@@ -0,0 +1,132 @@
"""Host inventory ingest and API."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from app.models import Event, Host
from app.services.host_inventory import diff_hardware, hardware_snapshot, inventory_fingerprint
def test_hardware_diff_detects_memory_change():
old = {"memory_gb": 16.0, "processor": [{"name": "CPU A", "cores": 4, "logical_processors": 8}]}
new = {"memory_gb": 32.0, "processor": [{"name": "CPU A", "cores": 4, "logical_processors": 8}]}
changes = diff_hardware(old, new)
assert len(changes) == 1
assert changes[0]["field"] == "memory_gb"
def test_hardware_diff_ignores_first_snapshot():
new = {"memory_gb": 16.0}
assert diff_hardware(None, new) == []
def test_ingest_inventory_stores_host_and_info(client, auth_headers, db_session):
event_id = str(uuid.uuid4())
payload = {
"schema_version": "1.0",
"event_id": event_id,
"occurred_at": "2026-06-04T10:00:00+03:00",
"source": {"product": "rdp-login-monitor", "product_version": "2.0.21-SAC"},
"host": {"hostname": "inv-pc", "os_family": "windows"},
"category": "agent",
"type": "agent.inventory",
"severity": "info",
"title": "Host inventory",
"summary": "Inventory snapshot",
"details": {
"inventory": {
"schema_version": 1,
"computer_name": "INV-PC",
"memory_gb": 32.0,
"processor": [{"name": "Intel Xeon", "cores": 8, "logical_processors": 16}],
"windows": {"product_name": "Windows Server 2019", "version": "1809"},
}
},
}
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
host = db_session.scalar(select(Host).where(Host.hostname == "inv-pc"))
assert host is not None
assert host.inventory is not None
assert host.inventory["memory_gb"] == 32.0
assert host.inventory_updated_at is not None
assert host.os_version == "Windows Server 2019 (1809)"
def test_ingest_inventory_hardware_change_warning(client, auth_headers, db_session):
base = {
"schema_version": "1.0",
"occurred_at": "2026-06-04T10:00:00+03:00",
"source": {"product": "rdp-login-monitor", "product_version": "2.0.21-SAC"},
"host": {"hostname": "inv-change", "os_family": "windows"},
"category": "agent",
"type": "agent.inventory",
"severity": "info",
"title": "Host inventory",
"summary": "Inventory snapshot",
}
inv_v1 = {
"inventory": {
"memory_gb": 16.0,
"processor": [{"name": "CPU A", "cores": 4, "logical_processors": 8}],
"disks": [{"friendly_name": "Disk0", "media_type": "SSD", "size_gb": 500.0}],
}
}
assert (
client.post(
"/api/v1/events",
json={**base, "event_id": str(uuid.uuid4()), "details": inv_v1},
headers=auth_headers,
).status_code
== 201
)
inv_v2 = {
"inventory": {
"memory_gb": 32.0,
"processor": [{"name": "CPU A", "cores": 4, "logical_processors": 8}],
"disks": [{"friendly_name": "Disk0", "media_type": "SSD", "size_gb": 500.0}],
}
}
r = client.post(
"/api/v1/events",
json={**base, "event_id": str(uuid.uuid4()), "details": inv_v2},
headers=auth_headers,
)
assert r.status_code == 201
ev = db_session.scalar(select(Event).order_by(Event.id.desc()))
assert ev is not None
assert ev.severity == "warning"
assert ev.details.get("hardware_changes")
def test_get_host_detail(client, db_session, jwt_headers):
h = Host(
hostname="detail-host",
display_name="Detail Host",
os_family="windows",
product="rdp-login-monitor",
product_version="2.0.21-SAC",
inventory={"memory_gb": 64.0},
inventory_updated_at=datetime.now(timezone.utc),
last_seen_at=datetime.now(timezone.utc),
)
db_session.add(h)
db_session.commit()
r = client.get(f"/api/v1/hosts/{h.id}", headers=jwt_headers)
assert r.status_code == 200
body = r.json()
assert body["hostname"] == "detail-host"
assert body["inventory"]["memory_gb"] == 64.0
assert body["last_inventory_at"] is not None
def test_inventory_fingerprint_stable():
inv = {"memory_gb": 16.0, "processor": [{"name": "X", "cores": 2, "logical_processors": 4}]}
assert inventory_fingerprint(inv) == inventory_fingerprint(inv)
assert hardware_snapshot(inv)["memory_gb"] == 16.0
+23
View File
@@ -303,3 +303,26 @@ def test_ssh_failed_template():
assert "root" in text
assert "10.10.36.9" in text
assert "3 / 5" in text
def test_agent_inventory_hardware_change_template():
event = Event(
event_id="00000000-0000-4000-8000-000000000504",
host_id=1,
occurred_at=datetime(2026, 6, 4, 12, 0, tzinfo=timezone.utc),
category="agent",
type="agent.inventory",
severity="warning",
title="Hardware changed",
summary="memory_gb changed",
details={
"hardware_changes": [
{"field": "memory_gb", "old": 16.0, "new": 32.0},
]
},
payload={},
)
text = format_event_telegram_html(event)
assert "железо" in text.lower() or "Изменилось" in text
assert "memory_gb" in text
assert "32" in text