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:
@@ -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")
|
||||||
@@ -7,7 +7,7 @@ from app.auth.jwt_auth import get_current_user, require_admin
|
|||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Event, Host
|
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_delete import delete_host_and_related
|
||||||
from app.services.host_health import (
|
from app.services.host_health import (
|
||||||
HEARTBEAT_TYPE,
|
HEARTBEAT_TYPE,
|
||||||
@@ -63,6 +63,7 @@ def list_hosts(
|
|||||||
hostname=host.hostname,
|
hostname=host.hostname,
|
||||||
display_name=host.display_name,
|
display_name=host.display_name,
|
||||||
os_family=host.os_family,
|
os_family=host.os_family,
|
||||||
|
os_version=host.os_version,
|
||||||
product=host.product,
|
product=host.product,
|
||||||
product_version=host.product_version,
|
product_version=host.product_version,
|
||||||
ipv4=host.ipv4,
|
ipv4=host.ipv4,
|
||||||
@@ -70,6 +71,7 @@ def list_hosts(
|
|||||||
event_count=int(event_count or 0),
|
event_count=int(event_count or 0),
|
||||||
last_heartbeat_at=last_hb,
|
last_heartbeat_at=last_hb,
|
||||||
last_daily_report_at=report_map.get(host.id),
|
last_daily_report_at=report_map.get(host.id),
|
||||||
|
last_inventory_at=host.inventory_updated_at,
|
||||||
agent_status=agent_status(
|
agent_status=agent_status(
|
||||||
last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes
|
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)
|
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):
|
class HostDeleteResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
host_id: int
|
host_id: int
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ DEFAULT_EVENT_SEVERITIES: dict[str, str] = {
|
|||||||
# Agent / lifecycle
|
# Agent / lifecycle
|
||||||
"agent.heartbeat": "info",
|
"agent.heartbeat": "info",
|
||||||
"agent.lifecycle": "info",
|
"agent.lifecycle": "info",
|
||||||
|
"agent.inventory": "info",
|
||||||
"agent.test": "info",
|
"agent.test": "info",
|
||||||
"agent.recovered": "info",
|
"agent.recovered": "info",
|
||||||
# SSH
|
# SSH
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class Host(Base):
|
|||||||
ipv4: Mapped[str | None] = mapped_column(String(45))
|
ipv4: Mapped[str | None] = mapped_column(String(45))
|
||||||
ipv6: Mapped[str | None] = mapped_column(String(45))
|
ipv6: Mapped[str | None] = mapped_column(String(45))
|
||||||
tags: Mapped[list | None] = mapped_column(JSONB, default=list)
|
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))
|
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
|
||||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
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())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class HostSummary(BaseModel):
|
|||||||
hostname: str
|
hostname: str
|
||||||
display_name: str | None
|
display_name: str | None
|
||||||
os_family: str
|
os_family: str
|
||||||
|
os_version: str | None = None
|
||||||
product: str
|
product: str
|
||||||
product_version: str | None
|
product_version: str | None
|
||||||
ipv4: str | None
|
ipv4: str | None
|
||||||
@@ -15,11 +16,22 @@ class HostSummary(BaseModel):
|
|||||||
event_count: int | None = None
|
event_count: int | None = None
|
||||||
last_heartbeat_at: datetime | None = None
|
last_heartbeat_at: datetime | None = None
|
||||||
last_daily_report_at: datetime | None = None
|
last_daily_report_at: datetime | None = None
|
||||||
|
last_inventory_at: datetime | None = None
|
||||||
agent_status: str = "unknown"
|
agent_status: str = "unknown"
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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):
|
class HostListResponse(BaseModel):
|
||||||
items: list[HostSummary]
|
items: list[HostSummary]
|
||||||
total: int
|
total: int
|
||||||
@@ -33,6 +45,7 @@ class EventSummary(BaseModel):
|
|||||||
host_id: int
|
host_id: int
|
||||||
hostname: str
|
hostname: str
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
|
product_version: str | None = None
|
||||||
occurred_at: datetime
|
occurred_at: datetime
|
||||||
received_at: datetime
|
received_at: datetime
|
||||||
category: str
|
category: str
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ def event_to_summary(event: Event) -> EventSummary:
|
|||||||
host_id=event.host_id,
|
host_id=event.host_id,
|
||||||
hostname=host.hostname,
|
hostname=host.hostname,
|
||||||
display_name=host.display_name,
|
display_name=host.display_name,
|
||||||
|
product_version=host.product_version,
|
||||||
occurred_at=event.occurred_at,
|
occurred_at=event.occurred_at,
|
||||||
received_at=event.received_at,
|
received_at=event.received_at,
|
||||||
category=event.category,
|
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.models import Event, Host
|
||||||
from app.services.daily_report_format import normalize_daily_report_details
|
from app.services.daily_report_format import normalize_daily_report_details
|
||||||
from app.services.event_severity_overrides import apply_severity_override
|
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"})
|
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")
|
details = payload.get("details")
|
||||||
if payload.get("type") in DAILY_REPORT_TYPES:
|
if payload.get("type") in DAILY_REPORT_TYPES:
|
||||||
details = normalize_daily_report_details(details, host, payload["type"])
|
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 = Event(
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
host_id=host.id,
|
host_id=host.id,
|
||||||
occurred_at=_parse_dt(payload["occurred_at"]),
|
occurred_at=_parse_dt(payload["occurred_at"]),
|
||||||
category=payload["category"],
|
category=payload["category"],
|
||||||
type=payload["type"],
|
type=payload["type"],
|
||||||
severity=payload["severity"],
|
severity=severity,
|
||||||
title=payload["title"],
|
title=title,
|
||||||
summary=payload["summary"],
|
summary=summary,
|
||||||
details=details,
|
details=details,
|
||||||
raw=payload.get("raw"),
|
raw=payload.get("raw"),
|
||||||
dedup_key=payload.get("dedup_key"),
|
dedup_key=payload.get("dedup_key"),
|
||||||
|
|||||||
@@ -333,6 +333,50 @@ def format_smb_admin_share_html(event: Event) -> str:
|
|||||||
return msg.rstrip()
|
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:
|
def format_generic_event_html(event: Event) -> str:
|
||||||
sev = event.severity.upper()
|
sev = event.severity.upper()
|
||||||
msg = f"<b>🚨 SAC: {html_escape(event.title)}</b>\n"
|
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)
|
return format_winrm_session_html(event)
|
||||||
if event.type.startswith("smb."):
|
if event.type.startswith("smb."):
|
||||||
return format_smb_admin_share_html(event)
|
return format_smb_admin_share_html(event)
|
||||||
|
if event.type == "agent.inventory":
|
||||||
|
return format_agent_inventory_html(event)
|
||||||
return format_generic_event_html(event)
|
return format_generic_event_html(event)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.7.4"
|
APP_VERSION = "0.7.5"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -303,3 +303,26 @@ def test_ssh_failed_template():
|
|||||||
assert "root" in text
|
assert "root" in text
|
||||||
assert "10.10.36.9" in text
|
assert "10.10.36.9" in text
|
||||||
assert "3 / 5" 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
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
|
|||||||
| Старт/стоп | `agent.lifecycle` | info |
|
| Старт/стоп | `agent.lifecycle` | info |
|
||||||
| Отчёт | `report.daily.rdp` | info |
|
| Отчёт | `report.daily.rdp` | info |
|
||||||
| Инвентаризация железа/ПО | `agent.inventory` | info (SAC: **warning**, если изменилось железо) |
|
| Инвентаризация железа/ПО | `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** и шлёт оповещение.
|
||||||
|
|
||||||
**Дополнительные поля:**
|
**Дополнительные поля:**
|
||||||
|
|
||||||
- `event_id_windows`, `logon_type`, `ip_address`, `workstation_name`
|
- `event_id_windows`, `logon_type`, `ip_address`, `workstation_name`
|
||||||
@@ -203,7 +206,7 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
|
|||||||
- `filtered_out`, `filter_reason`
|
- `filtered_out`, `filter_reason`
|
||||||
|
|
||||||
**Переключатели в `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:`.
|
**Переключатели в `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:`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3.3. Человекочитаемое имя хоста (`host.display_name`)
|
## 3.3. Человекочитаемое имя хоста (`host.display_name`)
|
||||||
|
|||||||
@@ -102,6 +102,7 @@
|
|||||||
"rdg.connection.success",
|
"rdg.connection.success",
|
||||||
"report.daily.ssh",
|
"report.daily.ssh",
|
||||||
"agent.heartbeat",
|
"agent.heartbeat",
|
||||||
|
"agent.inventory",
|
||||||
"agent.test"
|
"agent.test"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export interface EventSummary {
|
|||||||
host_id: number;
|
host_id: number;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
display_name?: string | null;
|
display_name?: string | null;
|
||||||
|
product_version?: string | null;
|
||||||
occurred_at: string;
|
occurred_at: string;
|
||||||
received_at: string;
|
received_at: string;
|
||||||
category: string;
|
category: string;
|
||||||
@@ -187,6 +188,7 @@ export interface HostSummary {
|
|||||||
hostname: string;
|
hostname: string;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
os_family: string;
|
os_family: string;
|
||||||
|
os_version?: string | null;
|
||||||
product: string;
|
product: string;
|
||||||
product_version: string | null;
|
product_version: string | null;
|
||||||
ipv4: string | null;
|
ipv4: string | null;
|
||||||
@@ -194,9 +196,24 @@ export interface HostSummary {
|
|||||||
event_count: number | null;
|
event_count: number | null;
|
||||||
last_heartbeat_at: string | null;
|
last_heartbeat_at: string | null;
|
||||||
last_daily_report_at: string | null;
|
last_daily_report_at: string | null;
|
||||||
|
last_inventory_at?: string | null;
|
||||||
agent_status: "online" | "stale" | "unknown";
|
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<string, unknown> | null;
|
||||||
|
inventory_updated_at: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchHost(id: number): Promise<HostDetail> {
|
||||||
|
return apiFetch<HostDetail>(`/api/v1/hosts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostListResponse {
|
export interface HostListResponse {
|
||||||
items: HostSummary[];
|
items: HostSummary[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const navItems = computed(() => {
|
|||||||
{ to: "/events", label: "События", icon: "☰", match: "prefix" as const },
|
{ to: "/events", label: "События", icon: "☰", match: "prefix" as const },
|
||||||
{ to: "/reports", label: "Отчёты", icon: "▤", match: "prefix" as const },
|
{ to: "/reports", label: "Отчёты", icon: "▤", match: "prefix" as const },
|
||||||
{ to: "/problems", 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()) {
|
if (isAdmin()) {
|
||||||
items.push(
|
items.push(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getToken, isAdmin, setRole, fetchMe } from "./api";
|
|||||||
import EventsView from "./views/EventsView.vue";
|
import EventsView from "./views/EventsView.vue";
|
||||||
import EventDetailView from "./views/EventDetailView.vue";
|
import EventDetailView from "./views/EventDetailView.vue";
|
||||||
import HostsView from "./views/HostsView.vue";
|
import HostsView from "./views/HostsView.vue";
|
||||||
|
import HostDetailView from "./views/HostDetailView.vue";
|
||||||
import LoginView from "./views/LoginView.vue";
|
import LoginView from "./views/LoginView.vue";
|
||||||
import ErrorPageView from "./views/ErrorPageView.vue";
|
import ErrorPageView from "./views/ErrorPageView.vue";
|
||||||
import ProblemsView from "./views/ProblemsView.vue";
|
import ProblemsView from "./views/ProblemsView.vue";
|
||||||
@@ -35,6 +36,7 @@ const router = createRouter({
|
|||||||
{ path: "/events/:id", component: EventDetailView, props: true },
|
{ path: "/events/:id", component: EventDetailView, props: true },
|
||||||
{ path: "/reports", component: ReportsView },
|
{ path: "/reports", component: ReportsView },
|
||||||
{ path: "/hosts", component: HostsView },
|
{ path: "/hosts", component: HostsView },
|
||||||
|
{ path: "/hosts/:id", component: HostDetailView, props: true },
|
||||||
{ path: "/problems", component: ProblemsView },
|
{ path: "/problems", component: ProblemsView },
|
||||||
{ path: "/problems/:id", component: ProblemDetailView, props: true },
|
{ path: "/problems/:id", component: ProblemDetailView, props: true },
|
||||||
{ path: "/settings", component: SettingsView, meta: { adminOnly: true } },
|
{ path: "/settings", component: SettingsView, meta: { adminOnly: true } },
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export const APP_NAME = "Security Alert Center";
|
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}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -236,6 +236,8 @@
|
|||||||
|
|
||||||
<th>Имя сервера</th>
|
<th>Имя сервера</th>
|
||||||
|
|
||||||
|
<th>Версия агента</th>
|
||||||
|
|
||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
|
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
@@ -268,6 +270,8 @@
|
|||||||
|
|
||||||
<td>{{ formatServerName(e.display_name) }}</td>
|
<td>{{ formatServerName(e.display_name) }}</td>
|
||||||
|
|
||||||
|
<td>{{ e.product_version || "—" }}</td>
|
||||||
|
|
||||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||||
|
|
||||||
<td>{{ e.title }}</td>
|
<td>{{ e.title }}</td>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
<th>Время</th>
|
<th>Время</th>
|
||||||
<th>Хост</th>
|
<th>Хост</th>
|
||||||
<th>Имя сервера</th>
|
<th>Имя сервера</th>
|
||||||
|
<th>Версия агента</th>
|
||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
<td>{{ formatDt(e.occurred_at) }}</td>
|
<td>{{ formatDt(e.occurred_at) }}</td>
|
||||||
<td>{{ e.hostname }}</td>
|
<td>{{ e.hostname }}</td>
|
||||||
<td>{{ formatServerName(e.display_name) }}</td>
|
<td>{{ formatServerName(e.display_name) }}</td>
|
||||||
|
<td>{{ e.product_version || "—" }}</td>
|
||||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||||
<td><code>{{ e.type }}</code></td>
|
<td><code>{{ e.type }}</code></td>
|
||||||
<td>{{ e.title }}</td>
|
<td>{{ e.title }}</td>
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<template>
|
||||||
|
<p><RouterLink to="/hosts">← Хосты</RouterLink></p>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-else-if="loading">Загрузка…</p>
|
||||||
|
<template v-else-if="host">
|
||||||
|
<h1>{{ host.display_name || host.hostname }}</h1>
|
||||||
|
<div class="card report-meta">
|
||||||
|
<p>
|
||||||
|
<strong>Hostname:</strong> {{ host.hostname }}
|
||||||
|
· <strong>ID:</strong> {{ host.id }}
|
||||||
|
· <strong>Агент:</strong>
|
||||||
|
<span :class="'agent-' + host.agent_status">{{ agentLabel(host.agent_status) }}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Product:</strong> {{ host.product }} {{ host.product_version || "" }}
|
||||||
|
· <strong>OS:</strong> {{ host.os_family }} {{ host.os_version || "" }}
|
||||||
|
· <strong>IPv4:</strong> {{ host.ipv4 || "—" }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Heartbeat:</strong> {{ host.last_heartbeat_at ? formatDt(host.last_heartbeat_at) : "—" }}
|
||||||
|
· <strong>Last seen:</strong> {{ formatDt(host.last_seen_at) }}
|
||||||
|
· <strong>Инвентаризация:</strong>
|
||||||
|
{{ host.last_inventory_at ? formatDt(host.last_inventory_at) : "—" }}
|
||||||
|
· <strong>Событий:</strong> {{ host.event_count ?? 0 }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="inventory" class="host-inventory card">
|
||||||
|
<h2>Железо и ПО</h2>
|
||||||
|
<div v-if="windowsInfo" class="inv-block">
|
||||||
|
<h3>Windows</h3>
|
||||||
|
<p>{{ windowsInfo }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="computerName" class="inv-block">
|
||||||
|
<p><strong>Имя компьютера:</strong> {{ computerName }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="processors.length" class="inv-block">
|
||||||
|
<h3>Процессор</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(p, i) in processors" :key="'cpu-' + i">
|
||||||
|
{{ p.name }}
|
||||||
|
<span v-if="p.cores != null"> — {{ p.cores }} ядер / {{ p.logical_processors }} потоков</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-if="motherboard" class="inv-block">
|
||||||
|
<h3>Материнская плата</h3>
|
||||||
|
<p>{{ motherboard }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="memoryGb != null" class="inv-block">
|
||||||
|
<h3>Память</h3>
|
||||||
|
<p>{{ memoryGb }} GB</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="disks.length" class="inv-block">
|
||||||
|
<h3>Диски</h3>
|
||||||
|
<table class="dash-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Размер, GB</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(d, i) in disks" :key="'disk-' + i">
|
||||||
|
<td>{{ d.friendly_name || "—" }}</td>
|
||||||
|
<td>{{ d.media_type || "—" }}</td>
|
||||||
|
<td>{{ d.size_gb ?? "—" }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="video.length" class="inv-block">
|
||||||
|
<h3>Видео</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(v, i) in video" :key="'gpu-' + i">{{ v.name }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-if="ipv4List.length" class="inv-block">
|
||||||
|
<h3>IPv4</h3>
|
||||||
|
<p>{{ ipv4List.join(", ") }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<p v-else class="muted">Инвентаризация ещё не поступала с агента (agent.inventory).</p>
|
||||||
|
|
||||||
|
<h2>События хоста</h2>
|
||||||
|
<p v-if="eventsLoading">Загрузка событий…</p>
|
||||||
|
<p v-else-if="eventsError" class="error">{{ eventsError }}</p>
|
||||||
|
<template v-else>
|
||||||
|
<p>Всего: {{ events?.total ?? 0 }}</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Время</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Title</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="e in events?.items ?? []" :key="e.id">
|
||||||
|
<td>
|
||||||
|
<RouterLink :to="{ path: `/events/${e.id}`, query: eventsBackQuery }">{{ e.id }}</RouterLink>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDt(e.occurred_at) }}</td>
|
||||||
|
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||||
|
<td><code>{{ e.type }}</code></td>
|
||||||
|
<td>{{ e.title }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="filters" style="margin-top: 1rem">
|
||||||
|
<button type="button" class="secondary" :disabled="eventsPage <= 1" @click="loadEvents(eventsPage - 1)">
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<span>Стр. {{ eventsPage }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="!events || eventsPage * eventsPageSize >= events.total"
|
||||||
|
@click="loadEvents(eventsPage + 1)"
|
||||||
|
>
|
||||||
|
Вперёд
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { apiFetch, type EventListResponse, type HostDetail } from "../api";
|
||||||
|
|
||||||
|
const props = defineProps<{ id: string }>();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const host = ref<HostDetail | null>(null);
|
||||||
|
const events = ref<EventListResponse | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const eventsLoading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const eventsError = ref("");
|
||||||
|
const eventsPage = ref(1);
|
||||||
|
const eventsPageSize = 50;
|
||||||
|
|
||||||
|
const hostId = computed(() => parseInt(props.id, 10));
|
||||||
|
|
||||||
|
const inventory = computed(() => host.value?.inventory ?? null);
|
||||||
|
|
||||||
|
const windowsInfo = computed(() => {
|
||||||
|
const w = inventory.value?.windows as Record<string, string> | undefined;
|
||||||
|
if (!w) return "";
|
||||||
|
const parts = [w.product_name, w.version, w.os_hardware_abstraction_layer].filter(Boolean);
|
||||||
|
return parts.join(" · ");
|
||||||
|
});
|
||||||
|
|
||||||
|
const computerName = computed(() => {
|
||||||
|
const v = inventory.value?.computer_name;
|
||||||
|
return typeof v === "string" && v.trim() ? v : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const processors = computed(() => {
|
||||||
|
const raw = inventory.value?.processor;
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.filter((p): p is Record<string, unknown> => p != null && typeof p === "object");
|
||||||
|
});
|
||||||
|
|
||||||
|
const motherboard = computed(() => {
|
||||||
|
const m = inventory.value?.motherboard as Record<string, string> | undefined;
|
||||||
|
if (!m) return "";
|
||||||
|
return [m.manufacturer, m.product].filter(Boolean).join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
const memoryGb = computed(() => {
|
||||||
|
const v = inventory.value?.memory_gb;
|
||||||
|
return typeof v === "number" ? v : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const disks = computed(() => {
|
||||||
|
const raw = inventory.value?.disks;
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.filter((d): d is Record<string, unknown> => d != null && typeof d === "object");
|
||||||
|
});
|
||||||
|
|
||||||
|
const video = computed(() => {
|
||||||
|
const raw = inventory.value?.video;
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.filter((v): v is Record<string, string> => v != null && typeof v === "object");
|
||||||
|
});
|
||||||
|
|
||||||
|
const ipv4List = computed(() => {
|
||||||
|
const raw = inventory.value?.ipv4;
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map(String).filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsBackQuery = computed(() => ({ host_id: String(hostId.value) }));
|
||||||
|
|
||||||
|
function formatDt(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentLabel(status: string) {
|
||||||
|
if (status === "online") return "online";
|
||||||
|
if (status === "stale") return "stale";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHost() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
host.value = await apiFetch<HostDetail>(`/api/v1/hosts/${hostId.value}`);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка загрузки хоста";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEvents(page = 1) {
|
||||||
|
eventsLoading.value = true;
|
||||||
|
eventsError.value = "";
|
||||||
|
eventsPage.value = page;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
page_size: String(eventsPageSize),
|
||||||
|
host_id: String(hostId.value),
|
||||||
|
});
|
||||||
|
events.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`);
|
||||||
|
} catch (e) {
|
||||||
|
eventsError.value = e instanceof Error ? e.message : "Ошибка загрузки событий";
|
||||||
|
} finally {
|
||||||
|
eventsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadHost();
|
||||||
|
await loadEvents(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.params.id,
|
||||||
|
async () => {
|
||||||
|
await loadHost();
|
||||||
|
await loadEvents(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.host-inventory {
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-block {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-block h3 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #9aa4b2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
<th><button type="button" class="th-sort" @click="setSort('id')">ID {{ sortMark('id') }}</button></th>
|
<th><button type="button" class="th-sort" @click="setSort('id')">ID {{ sortMark('id') }}</button></th>
|
||||||
<th><button type="button" class="th-sort" @click="setSort('display_name')">Имя {{ sortMark('display_name') }}</button></th>
|
<th><button type="button" class="th-sort" @click="setSort('display_name')">Имя {{ sortMark('display_name') }}</button></th>
|
||||||
<th><button type="button" class="th-sort" @click="setSort('hostname')">Hostname {{ sortMark('hostname') }}</button></th>
|
<th><button type="button" class="th-sort" @click="setSort('hostname')">Hostname {{ sortMark('hostname') }}</button></th>
|
||||||
<th><button type="button" class="th-sort" @click="setSort('product')">Product {{ sortMark('product') }}</button></th>
|
<th><button type="button" class="th-sort" @click="setSort('product_version')">Версия агента {{ sortMark('product_version') }}</button></th>
|
||||||
<th><button type="button" class="th-sort" @click="setSort('os_family')">OS {{ sortMark('os_family') }}</button></th>
|
<th><button type="button" class="th-sort" @click="setSort('os_family')">OS {{ sortMark('os_family') }}</button></th>
|
||||||
<th><button type="button" class="th-sort" @click="setSort('ipv4')">IPv4 {{ sortMark('ipv4') }}</button></th>
|
<th><button type="button" class="th-sort" @click="setSort('ipv4')">IPv4 {{ sortMark('ipv4') }}</button></th>
|
||||||
<th>Агент</th>
|
<th><button type="button" class="th-sort" @click="setSort('agent_status')">Статус {{ sortMark('agent_status') }}</button></th>
|
||||||
<th>Heartbeat</th>
|
<th>Heartbeat</th>
|
||||||
<th><button type="button" class="th-sort" @click="setSort('last_daily_report_at')">Отчёт {{ sortMark('last_daily_report_at') }}</button></th>
|
<th><button type="button" class="th-sort" @click="setSort('last_daily_report_at')">Отчёт {{ sortMark('last_daily_report_at') }}</button></th>
|
||||||
<th><button type="button" class="th-sort" @click="setSort('last_seen_at')">Last seen {{ sortMark('last_seen_at') }}</button></th>
|
<th><button type="button" class="th-sort" @click="setSort('last_seen_at')">Last seen {{ sortMark('last_seen_at') }}</button></th>
|
||||||
@@ -31,9 +31,18 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="h in sortedItems" :key="h.id">
|
<tr v-for="h in sortedItems" :key="h.id">
|
||||||
<td>{{ h.id }}</td>
|
<td>{{ h.id }}</td>
|
||||||
<td>{{ h.display_name || "—" }}</td>
|
<td>
|
||||||
<td>{{ h.hostname }}</td>
|
<RouterLink :to="`/hosts/${h.id}`" class="host-link">
|
||||||
<td>{{ h.product }} {{ h.product_version || "" }}</td>
|
{{ h.display_name || h.hostname }}
|
||||||
|
</RouterLink>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<RouterLink v-if="h.hostname !== (h.display_name || h.hostname)" :to="`/hosts/${h.id}`" class="host-link">
|
||||||
|
{{ h.hostname }}
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else>{{ h.hostname }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ h.product_version || "—" }}</td>
|
||||||
<td>{{ h.os_family }}</td>
|
<td>{{ h.os_family }}</td>
|
||||||
<td>{{ h.ipv4 || "—" }}</td>
|
<td>{{ h.ipv4 || "—" }}</td>
|
||||||
<td :class="'agent-' + h.agent_status">{{ agentLabel(h.agent_status) }}</td>
|
<td :class="'agent-' + h.agent_status">{{ agentLabel(h.agent_status) }}</td>
|
||||||
@@ -78,9 +87,10 @@ type SortKey =
|
|||||||
| "id"
|
| "id"
|
||||||
| "display_name"
|
| "display_name"
|
||||||
| "hostname"
|
| "hostname"
|
||||||
| "product"
|
| "product_version"
|
||||||
| "os_family"
|
| "os_family"
|
||||||
| "ipv4"
|
| "ipv4"
|
||||||
|
| "agent_status"
|
||||||
| "last_seen_at"
|
| "last_seen_at"
|
||||||
| "event_count"
|
| "event_count"
|
||||||
| "last_daily_report_at";
|
| "last_daily_report_at";
|
||||||
@@ -89,9 +99,10 @@ const HOSTS_SORT_KEYS: SortKey[] = [
|
|||||||
"id",
|
"id",
|
||||||
"display_name",
|
"display_name",
|
||||||
"hostname",
|
"hostname",
|
||||||
"product",
|
"product_version",
|
||||||
"os_family",
|
"os_family",
|
||||||
"ipv4",
|
"ipv4",
|
||||||
|
"agent_status",
|
||||||
"last_seen_at",
|
"last_seen_at",
|
||||||
"event_count",
|
"event_count",
|
||||||
"last_daily_report_at",
|
"last_daily_report_at",
|
||||||
@@ -137,6 +148,12 @@ function agentLabel(status: string) {
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function agentStatusOrder(status: string) {
|
||||||
|
if (status === "online") return 0;
|
||||||
|
if (status === "stale") return 1;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
function setSort(key: SortKey) {
|
function setSort(key: SortKey) {
|
||||||
if (sortBy.value === key) {
|
if (sortBy.value === key) {
|
||||||
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
||||||
@@ -180,8 +197,8 @@ const sortedItems = computed(() => {
|
|||||||
case "hostname":
|
case "hostname":
|
||||||
cmp = compareNullableStrings(a.hostname, b.hostname);
|
cmp = compareNullableStrings(a.hostname, b.hostname);
|
||||||
break;
|
break;
|
||||||
case "product":
|
case "product_version":
|
||||||
cmp = compareNullableStrings(a.product, b.product);
|
cmp = compareNullableStrings(a.product_version, b.product_version);
|
||||||
break;
|
break;
|
||||||
case "os_family":
|
case "os_family":
|
||||||
cmp = compareNullableStrings(a.os_family, b.os_family);
|
cmp = compareNullableStrings(a.os_family, b.os_family);
|
||||||
@@ -189,6 +206,9 @@ const sortedItems = computed(() => {
|
|||||||
case "ipv4":
|
case "ipv4":
|
||||||
cmp = compareNullableStrings(a.ipv4, b.ipv4);
|
cmp = compareNullableStrings(a.ipv4, b.ipv4);
|
||||||
break;
|
break;
|
||||||
|
case "agent_status":
|
||||||
|
cmp = agentStatusOrder(a.agent_status) - agentStatusOrder(b.agent_status);
|
||||||
|
break;
|
||||||
case "event_count":
|
case "event_count":
|
||||||
cmp = (a.event_count ?? 0) - (b.event_count ?? 0);
|
cmp = (a.event_count ?? 0) - (b.event_count ?? 0);
|
||||||
break;
|
break;
|
||||||
@@ -256,3 +276,13 @@ onMounted(() => {
|
|||||||
loadHosts();
|
loadHosts();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.host-link {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.host-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
"rdg.connection.failed",
|
"rdg.connection.failed",
|
||||||
"report.daily.ssh",
|
"report.daily.ssh",
|
||||||
"agent.heartbeat",
|
"agent.heartbeat",
|
||||||
|
"agent.inventory",
|
||||||
"agent.test"
|
"agent.test"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user