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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user