From 54337a5917fe9e69763b8102e630561dbd6f45c1 Mon Sep 17 00:00:00 2001 From: PTah Date: Fri, 5 Jun 2026 09:53:00 +1000 Subject: [PATCH] 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 --- .../alembic/versions/013_host_inventory.py | 26 ++ backend/app/api/v1/hosts.py | 54 +++- backend/app/constants/event_types.py | 1 + backend/app/models/host.py | 2 + backend/app/schemas/list_models.py | 13 + backend/app/services/event_summary.py | 1 + backend/app/services/host_inventory.py | 162 +++++++++++ backend/app/services/ingest.py | 25 +- backend/app/services/telegram_templates.py | 46 +++ backend/app/version.py | 2 +- backend/tests/test_host_inventory.py | 132 +++++++++ backend/tests/test_telegram_templates.py | 23 ++ docs/agent-integration.md | 5 +- docs/event-schema-v1.json | 1 + frontend/src/api.ts | 17 ++ frontend/src/components/AppSidebar.vue | 2 +- frontend/src/router.ts | 2 + frontend/src/version.ts | 2 +- frontend/src/views/DashboardView.vue | 4 + frontend/src/views/EventsView.vue | 2 + frontend/src/views/HostDetailView.vue | 272 ++++++++++++++++++ frontend/src/views/HostsView.vue | 48 +++- schemas/event-schema-v1.json | 1 + 23 files changed, 826 insertions(+), 17 deletions(-) create mode 100644 backend/alembic/versions/013_host_inventory.py create mode 100644 backend/app/services/host_inventory.py create mode 100644 backend/tests/test_host_inventory.py create mode 100644 frontend/src/views/HostDetailView.vue diff --git a/backend/alembic/versions/013_host_inventory.py b/backend/alembic/versions/013_host_inventory.py new file mode 100644 index 0000000..1b0069a --- /dev/null +++ b/backend/alembic/versions/013_host_inventory.py @@ -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") diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index f0fe294..16ac09c 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -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 diff --git a/backend/app/constants/event_types.py b/backend/app/constants/event_types.py index 9764793..33abcf0 100644 --- a/backend/app/constants/event_types.py +++ b/backend/app/constants/event_types.py @@ -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 diff --git a/backend/app/models/host.py b/backend/app/models/host.py index 39318a5..19097d8 100644 --- a/backend/app/models/host.py +++ b/backend/app/models/host.py @@ -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()) diff --git a/backend/app/schemas/list_models.py b/backend/app/schemas/list_models.py index 280027b..bfb3589 100644 --- a/backend/app/schemas/list_models.py +++ b/backend/app/schemas/list_models.py @@ -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 diff --git a/backend/app/services/event_summary.py b/backend/app/services/event_summary.py index 7d1c768..8a9bd67 100644 --- a/backend/app/services/event_summary.py +++ b/backend/app/services/event_summary.py @@ -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, diff --git a/backend/app/services/host_inventory.py b/backend/app/services/host_inventory.py new file mode 100644 index 0000000..2b0ec6b --- /dev/null +++ b/backend/app/services/host_inventory.py @@ -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 diff --git a/backend/app/services/ingest.py b/backend/app/services/ingest.py index 087416e..be50f76 100644 --- a/backend/app/services/ingest.py +++ b/backend/app/services/ingest.py @@ -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"), diff --git a/backend/app/services/telegram_templates.py b/backend/app/services/telegram_templates.py index 8a3e2ff..e628aa0 100644 --- a/backend/app/services/telegram_templates.py +++ b/backend/app/services/telegram_templates.py @@ -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"• {field}: {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 = "⚠️ Изменилось железо хоста\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 = "🖥️ Инвентаризация хоста\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"🚨 SAC: {html_escape(event.title)}\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) diff --git a/backend/app/version.py b/backend/app/version.py index a6bdddc..1b795ba 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -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}" diff --git a/backend/tests/test_host_inventory.py b/backend/tests/test_host_inventory.py new file mode 100644 index 0000000..e8f0d8a --- /dev/null +++ b/backend/tests/test_host_inventory.py @@ -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 diff --git a/backend/tests/test_telegram_templates.py b/backend/tests/test_telegram_templates.py index f160cee..e17845a 100644 --- a/backend/tests/test_telegram_templates.py +++ b/backend/tests/test_telegram_templates.py @@ -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 diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 4a80d08..faf4081 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -193,6 +193,9 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 | RD Gateway 303 | `rdg.connection.failed` | warning | | Старт/стоп | `agent.lifecycle` | info | | Отчёт | `report.daily.rdp` | info | +| Инвентаризация железа/ПО | `agent.inventory` | info (SAC: **warning**, если изменилось железо) | + +**Инвентаризация (`agent.inventory`, RDP ≥ 2.0.21-SAC):** агент шлёт снимок в `details.inventory` (CPU, RAM, диски, GPU, Windows, IPv4). Интервал по умолчанию **12 ч**; отключение: `$GetInventory = $false` в `login_monitor.settings.ps1`. SAC хранит последний снимок в `hosts.inventory`; при изменении железа ingest повышает severity до **warning** и шлёт оповещение. **Дополнительные поля:** @@ -203,7 +206,7 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 - `gateway_target`, `gateway_error_code` - `filtered_out`, `filter_reason` -**Переключатели в `login_monitor.settings.ps1` (≥ 1.2.23-SAC):** `$EnableRcmShadowControlMonitoring`, `$EnableWinRmInboundMonitoring`, `$EnableAdminShareMonitoring` (по умолчанию `1`; Security **5140**, audit File Share). Подавление: `ignore.lst` с префиксами `shadow:`, `winrm:`, `smb:` / `5140:`. +**Переключатели в `login_monitor.settings.ps1` (≥ 1.2.23-SAC):** `$EnableRcmShadowControlMonitoring`, `$EnableWinRmInboundMonitoring`, `$EnableAdminShareMonitoring` (по умолчанию `1`; Security **5140**, audit File Share). **`$GetInventory`** (по умолчанию `$true`) — опрос железа/ПО для SAC. Подавление: `ignore.lst` с префиксами `shadow:`, `winrm:`, `smb:` / `5140:`. --- diff --git a/docs/event-schema-v1.json b/docs/event-schema-v1.json index 5d9b725..c867a07 100644 --- a/docs/event-schema-v1.json +++ b/docs/event-schema-v1.json @@ -102,6 +102,7 @@ "rdg.connection.success", "report.daily.ssh", "agent.heartbeat", + "agent.inventory", "agent.test" ] }, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c9f497b..a58357c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -160,6 +160,7 @@ export interface EventSummary { host_id: number; hostname: string; display_name?: string | null; + product_version?: string | null; occurred_at: string; received_at: string; category: string; @@ -187,6 +188,7 @@ export interface HostSummary { hostname: string; display_name: string | null; os_family: string; + os_version?: string | null; product: string; product_version: string | null; ipv4: string | null; @@ -194,9 +196,24 @@ export interface HostSummary { event_count: number | null; last_heartbeat_at: string | null; last_daily_report_at: string | null; + last_inventory_at?: string | null; agent_status: "online" | "stale" | "unknown"; } +export interface HostDetail extends HostSummary { + agent_instance_id: string | null; + ipv6: string | null; + tags: string[] | null; + use_sac_mode: string | null; + inventory: Record | null; + inventory_updated_at: string | null; + created_at: string | null; +} + +export function fetchHost(id: number): Promise { + return apiFetch(`/api/v1/hosts/${id}`); +} + export interface HostListResponse { items: HostSummary[]; total: number; diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index a77cf1c..9bbf8d1 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -47,7 +47,7 @@ const navItems = computed(() => { { to: "/events", label: "События", icon: "☰", match: "prefix" as const }, { to: "/reports", label: "Отчёты", icon: "▤", match: "prefix" as const }, { to: "/problems", label: "Проблемы", icon: "!", match: "prefix" as const }, - { to: "/hosts", label: "Хосты", icon: "⬢", match: "exact" as const }, + { to: "/hosts", label: "Хосты", icon: "⬢", match: "prefix" as const }, ]; if (isAdmin()) { items.push( diff --git a/frontend/src/router.ts b/frontend/src/router.ts index f64dc1a..d8ac9bc 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -3,6 +3,7 @@ import { getToken, isAdmin, setRole, fetchMe } from "./api"; import EventsView from "./views/EventsView.vue"; import EventDetailView from "./views/EventDetailView.vue"; import HostsView from "./views/HostsView.vue"; +import HostDetailView from "./views/HostDetailView.vue"; import LoginView from "./views/LoginView.vue"; import ErrorPageView from "./views/ErrorPageView.vue"; import ProblemsView from "./views/ProblemsView.vue"; @@ -35,6 +36,7 @@ const router = createRouter({ { path: "/events/:id", component: EventDetailView, props: true }, { path: "/reports", component: ReportsView }, { path: "/hosts", component: HostsView }, + { path: "/hosts/:id", component: HostDetailView, props: true }, { path: "/problems", component: ProblemsView }, { path: "/problems/:id", component: ProblemDetailView, props: true }, { path: "/settings", component: SettingsView, meta: { adminOnly: true } }, diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 3b0831d..5a0af84 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,3 +1,3 @@ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.7.4"; +export const APP_VERSION = "0.7.5"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index ce8e50c..cdb73d9 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -236,6 +236,8 @@ Имя сервера + Версия агента + Severity Title @@ -268,6 +270,8 @@ {{ formatServerName(e.display_name) }} + {{ e.product_version || "—" }} + {{ e.severity }} {{ e.title }} diff --git a/frontend/src/views/EventsView.vue b/frontend/src/views/EventsView.vue index 1015d76..1ea8539 100644 --- a/frontend/src/views/EventsView.vue +++ b/frontend/src/views/EventsView.vue @@ -33,6 +33,7 @@ Время Хост Имя сервера + Версия агента Severity Type Title @@ -46,6 +47,7 @@ {{ formatDt(e.occurred_at) }} {{ e.hostname }} {{ formatServerName(e.display_name) }} + {{ e.product_version || "—" }} {{ e.severity }} {{ e.type }} {{ e.title }} diff --git a/frontend/src/views/HostDetailView.vue b/frontend/src/views/HostDetailView.vue new file mode 100644 index 0000000..5cc3404 --- /dev/null +++ b/frontend/src/views/HostDetailView.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/frontend/src/views/HostsView.vue b/frontend/src/views/HostsView.vue index cd23968..bd8df5f 100644 --- a/frontend/src/views/HostsView.vue +++ b/frontend/src/views/HostsView.vue @@ -17,10 +17,10 @@ - + - Агент + Heartbeat @@ -31,9 +31,18 @@ {{ h.id }} - {{ h.display_name || "—" }} - {{ h.hostname }} - {{ h.product }} {{ h.product_version || "" }} + + + {{ h.display_name || h.hostname }} + + + + + {{ h.hostname }} + + {{ h.hostname }} + + {{ h.product_version || "—" }} {{ h.os_family }} {{ h.ipv4 || "—" }} {{ agentLabel(h.agent_status) }} @@ -78,9 +87,10 @@ type SortKey = | "id" | "display_name" | "hostname" - | "product" + | "product_version" | "os_family" | "ipv4" + | "agent_status" | "last_seen_at" | "event_count" | "last_daily_report_at"; @@ -89,9 +99,10 @@ const HOSTS_SORT_KEYS: SortKey[] = [ "id", "display_name", "hostname", - "product", + "product_version", "os_family", "ipv4", + "agent_status", "last_seen_at", "event_count", "last_daily_report_at", @@ -137,6 +148,12 @@ function agentLabel(status: string) { return "unknown"; } +function agentStatusOrder(status: string) { + if (status === "online") return 0; + if (status === "stale") return 1; + return 2; +} + function setSort(key: SortKey) { if (sortBy.value === key) { sortDir.value = sortDir.value === "asc" ? "desc" : "asc"; @@ -180,8 +197,8 @@ const sortedItems = computed(() => { case "hostname": cmp = compareNullableStrings(a.hostname, b.hostname); break; - case "product": - cmp = compareNullableStrings(a.product, b.product); + case "product_version": + cmp = compareNullableStrings(a.product_version, b.product_version); break; case "os_family": cmp = compareNullableStrings(a.os_family, b.os_family); @@ -189,6 +206,9 @@ const sortedItems = computed(() => { case "ipv4": cmp = compareNullableStrings(a.ipv4, b.ipv4); break; + case "agent_status": + cmp = agentStatusOrder(a.agent_status) - agentStatusOrder(b.agent_status); + break; case "event_count": cmp = (a.event_count ?? 0) - (b.event_count ?? 0); break; @@ -256,3 +276,13 @@ onMounted(() => { loadHosts(); }); + + diff --git a/schemas/event-schema-v1.json b/schemas/event-schema-v1.json index 99aff47..d2b779d 100644 --- a/schemas/event-schema-v1.json +++ b/schemas/event-schema-v1.json @@ -104,6 +104,7 @@ "rdg.connection.failed", "report.daily.ssh", "agent.heartbeat", + "agent.inventory", "agent.test" ] },