diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index 16ac09c..66218d9 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -8,10 +8,12 @@ from app.config import get_settings from app.database import get_db from app.models import Event, Host from app.schemas.list_models import HostDetail, HostListResponse, HostSummary +from app.services.agent_version import latest_agent_versions_by_product from app.services.host_delete import delete_host_and_related from app.services.host_health import ( HEARTBEAT_TYPE, - agent_status, + agent_status as compute_agent_status, + apply_agent_status_host_filter, max_daily_report_time_by_host, max_event_time_by_host, ) @@ -25,6 +27,7 @@ def list_hosts( page_size: int = Query(50, ge=1, le=200), product: str | None = None, hostname: str | None = None, + agent_status: str | None = Query(None, pattern="^(online|stale|unknown)$"), db: Session = Depends(get_db), _user: str = Depends(get_current_user), ) -> HostListResponse: @@ -40,6 +43,15 @@ def list_hosts( stmt = stmt.where(host_match) count_stmt = count_stmt.where(host_match) + settings = get_settings() + if agent_status: + stmt, count_stmt = apply_agent_status_host_filter( + stmt, + count_stmt, + agent_status, + stale_minutes=settings.sac_heartbeat_stale_minutes, + ) + total = db.scalar(count_stmt) or 0 rows = db.scalars( stmt.order_by(Host.last_seen_at.desc()) @@ -47,7 +59,6 @@ def list_hosts( .limit(page_size) ).all() - settings = get_settings() hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE) report_map = max_daily_report_time_by_host(db) @@ -72,13 +83,19 @@ def list_hosts( 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( + agent_status=compute_agent_status( last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes ), ) ) - return HostListResponse(items=items, total=total, page=page, page_size=page_size) + return HostListResponse( + items=items, + total=total, + page=page, + page_size=page_size, + latest_agent_versions=latest_agent_versions_by_product(db), + ) def _host_detail_from_model( @@ -108,7 +125,7 @@ def _host_detail_from_model( 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_status=compute_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, diff --git a/backend/app/api/v1/problems.py b/backend/app/api/v1/problems.py index 2765cb3..4e9bd14 100644 --- a/backend/app/api/v1/problems.py +++ b/backend/app/api/v1/problems.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta, timezone @@ -156,6 +156,10 @@ def list_problems( hostname: str | None = Query(None), + created_within_hours: int | None = Query(None, ge=1, le=8760), + + resolved_within_hours: int | None = Query(None, ge=1, le=8760), + db: Session = Depends(get_db), _user: str = Depends(get_current_user), @@ -194,6 +198,22 @@ def list_problems( count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like)) + if created_within_hours is not None: + + since = datetime.now(timezone.utc) - timedelta(hours=created_within_hours) + + stmt = stmt.where(Problem.created_at >= since) + + count_stmt = count_stmt.where(Problem.created_at >= since) + + if resolved_within_hours is not None: + + since = datetime.now(timezone.utc) - timedelta(hours=resolved_within_hours) + + stmt = stmt.where(Problem.status == "resolved", Problem.updated_at >= since) + + count_stmt = count_stmt.where(Problem.status == "resolved", Problem.updated_at >= since) + total = db.scalar(count_stmt) or 0 diff --git a/backend/app/schemas/list_models.py b/backend/app/schemas/list_models.py index bfb3589..0f9a5f0 100644 --- a/backend/app/schemas/list_models.py +++ b/backend/app/schemas/list_models.py @@ -37,6 +37,7 @@ class HostListResponse(BaseModel): total: int page: int page_size: int + latest_agent_versions: dict[str, str] = Field(default_factory=dict) class EventSummary(BaseModel): diff --git a/backend/app/services/agent_version.py b/backend/app/services/agent_version.py new file mode 100644 index 0000000..3d8b1ec --- /dev/null +++ b/backend/app/services/agent_version.py @@ -0,0 +1,80 @@ +"""Сравнение версий агентов (ssh-monitor / rdp-login-monitor, формат X.Y.Z-SAC).""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import Host + +_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-SAC)?$", re.IGNORECASE) +_DEFAULT_LAG_THRESHOLD = 3 + + +@dataclass(frozen=True, order=True) +class ParsedAgentVersion: + major: int + minor: int + patch: int + + +def parse_agent_version(raw: str | None) -> ParsedAgentVersion | None: + if not raw: + return None + text = raw.strip() + match = _VERSION_RE.match(text) + if not match: + return None + return ParsedAgentVersion( + major=int(match.group(1)), + minor=int(match.group(2)), + patch=int(match.group(3)), + ) + + +def agent_version_lag(host: ParsedAgentVersion, latest: ParsedAgentVersion) -> int | None: + """Отставание от latest: patch при том же major.minor, иначе None (любое отставание).""" + if host > latest: + return 0 + if host.major != latest.major or host.minor != latest.minor: + return None + return latest.patch - host.patch + + +def is_agent_version_outdated( + host_version: str | None, + latest_version: str | None, + *, + lag_threshold: int = _DEFAULT_LAG_THRESHOLD, +) -> bool: + host = parse_agent_version(host_version) + latest = parse_agent_version(latest_version) + if host is None or latest is None or host >= latest: + return False + lag = agent_version_lag(host, latest) + if lag is None: + return True + return lag >= lag_threshold + + +def latest_agent_versions_by_product(db: Session) -> dict[str, str]: + rows = db.execute( + select(Host.product, Host.product_version).where( + Host.product_version.isnot(None), + Host.product_version != "", + ) + ).all() + best: dict[str, ParsedAgentVersion] = {} + best_raw: dict[str, str] = {} + for product, version in rows: + parsed = parse_agent_version(version) + if parsed is None: + continue + current = best.get(product) + if current is None or parsed > current: + best[product] = parsed + best_raw[product] = version.strip() + return best_raw diff --git a/backend/app/services/host_health.py b/backend/app/services/host_health.py index d9b0567..7c95344 100644 --- a/backend/app/services/host_health.py +++ b/backend/app/services/host_health.py @@ -62,3 +62,39 @@ def count_stale_hosts(db: Session, stale_minutes: int) -> int: for host_id in host_ids if agent_status(hb_map.get(host_id), stale_minutes=stale_minutes) == "stale" ) + + +def apply_agent_status_host_filter( + stmt, + count_stmt, + agent_status_filter: str, + *, + stale_minutes: int, +): + """Ограничить выборку Host по online / stale / unknown (как в agent_status).""" + from datetime import timedelta + + hb_subq = ( + select(Event.host_id, func.max(Event.received_at).label("last_hb")) + .where(Event.type == HEARTBEAT_TYPE) + .group_by(Event.host_id) + .subquery() + ) + cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes) + + if agent_status_filter == "online": + join = stmt.join(hb_subq, Host.id == hb_subq.c.host_id) + count_join = count_stmt.join(hb_subq, Host.id == hb_subq.c.host_id) + cond = hb_subq.c.last_hb >= cutoff + return join.where(cond), count_join.where(cond) + if agent_status_filter == "stale": + join = stmt.join(hb_subq, Host.id == hb_subq.c.host_id) + count_join = count_stmt.join(hb_subq, Host.id == hb_subq.c.host_id) + cond = hb_subq.c.last_hb < cutoff + return join.where(cond), count_join.where(cond) + if agent_status_filter == "unknown": + join = stmt.outerjoin(hb_subq, Host.id == hb_subq.c.host_id) + count_join = count_stmt.outerjoin(hb_subq, Host.id == hb_subq.c.host_id) + cond = hb_subq.c.last_hb.is_(None) + return join.where(cond), count_join.where(cond) + return stmt, count_stmt diff --git a/backend/tests/test_agent_version.py b/backend/tests/test_agent_version.py new file mode 100644 index 0000000..e118e12 --- /dev/null +++ b/backend/tests/test_agent_version.py @@ -0,0 +1,68 @@ +"""Agent version parse/compare for outdated highlighting.""" + +from app.services.agent_version import is_agent_version_outdated, latest_agent_versions_by_product, parse_agent_version +from app.models import Host +from datetime import datetime, timezone + + +def test_parse_agent_version(): + assert parse_agent_version("2.0.25-SAC") == parse_agent_version("2.0.25") + assert parse_agent_version("bad") is None + assert parse_agent_version(None) is None + + +def test_outdated_same_minor_patch_lag(): + latest = "2.0.25-SAC" + assert is_agent_version_outdated("2.0.25-SAC", latest) is False + assert is_agent_version_outdated("2.0.24-SAC", latest) is False + assert is_agent_version_outdated("2.0.23-SAC", latest) is False + assert is_agent_version_outdated("2.0.22-SAC", latest) is True + assert is_agent_version_outdated("2.0.18-SAC", latest) is True + assert is_agent_version_outdated("2.0.20-SAC", latest) is True + + +def test_outdated_different_minor_or_major(): + latest = "2.0.1-SAC" + assert is_agent_version_outdated("1.2.5-SAC", latest) is True + assert is_agent_version_outdated("2.1.0-SAC", latest) is False + + +def test_latest_agent_versions_by_product(db_session): + now = datetime.now(timezone.utc) + db_session.add_all( + [ + Host( + hostname="linux-a", + os_family="linux", + product="ssh-monitor", + product_version="1.2.5-SAC", + last_seen_at=now, + ), + Host( + hostname="linux-b", + os_family="linux", + product="ssh-monitor", + product_version="2.0.1-SAC", + last_seen_at=now, + ), + Host( + hostname="win-a", + os_family="windows", + product="rdp-login-monitor", + product_version="2.0.18-SAC", + last_seen_at=now, + ), + Host( + hostname="win-b", + os_family="windows", + product="rdp-login-monitor", + product_version="2.0.25-SAC", + last_seen_at=now, + ), + ] + ) + db_session.commit() + + latest = latest_agent_versions_by_product(db_session) + assert latest["ssh-monitor"] == "2.0.1-SAC" + assert latest["rdp-login-monitor"] == "2.0.25-SAC" diff --git a/backend/tests/test_hosts.py b/backend/tests/test_hosts.py index 34fffdc..d6f0ebf 100644 --- a/backend/tests/test_hosts.py +++ b/backend/tests/test_hosts.py @@ -1,11 +1,86 @@ """Hosts list API.""" import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +import pytest + +from app.config import get_settings from app.models import Event, Host, Problem +@pytest.fixture +def host_stale_settings(monkeypatch): + monkeypatch.setenv("SAC_HEARTBEAT_STALE_MINUTES", "60") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +def test_hosts_filter_agent_status_stale(client, db_session, jwt_headers, host_stale_settings): + now = datetime.now(timezone.utc) + stale_host = Host( + hostname="stale-pc", + os_family="windows", + product="rdp-login-monitor", + last_seen_at=now, + ) + fresh_host = Host( + hostname="fresh-pc", + os_family="windows", + product="rdp-login-monitor", + last_seen_at=now, + ) + unknown_host = Host( + hostname="unknown-pc", + os_family="linux", + product="ssh-monitor", + last_seen_at=now, + ) + db_session.add_all([stale_host, fresh_host, unknown_host]) + db_session.flush() + + db_session.add( + Event( + event_id=str(uuid.uuid4()), + host_id=stale_host.id, + occurred_at=now - timedelta(hours=2), + received_at=now - timedelta(hours=2), + category="agent", + type="agent.heartbeat", + severity="info", + title="hb", + summary="s", + payload={}, + ) + ) + db_session.add( + Event( + event_id=str(uuid.uuid4()), + host_id=fresh_host.id, + occurred_at=now - timedelta(minutes=5), + received_at=now - timedelta(minutes=5), + category="agent", + type="agent.heartbeat", + severity="info", + title="hb", + summary="s", + payload={}, + ) + ) + db_session.commit() + + r = client.get("/api/v1/hosts?agent_status=stale", headers=jwt_headers) + assert r.status_code == 200 + body = r.json() + assert body["total"] == 1 + assert body["items"][0]["hostname"] == "stale-pc" + assert body["items"][0]["agent_status"] == "stale" + + r_all = client.get("/api/v1/hosts", headers=jwt_headers) + assert r_all.json()["total"] == 3 + + def test_hosts_search_by_display_name(client, db_session, jwt_headers): h = Host( hostname="pc-01", diff --git a/backend/tests/test_problems.py b/backend/tests/test_problems.py index 73d34d6..e220b40 100644 --- a/backend/tests/test_problems.py +++ b/backend/tests/test_problems.py @@ -1,9 +1,9 @@ """Problems correlation and API smoke tests.""" import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone -from tests.test_ingest import VALID_EVENT +from app.models import Host, Problem def _event_payload(**overrides): @@ -67,3 +67,101 @@ def test_new_problem_after_resolve(client, auth_headers, jwt_headers): assert len(open_items) == 1 assert open_items[0]["event_count"] == 1 assert open_items[0]["id"] != pid + + +def test_problems_created_within_hours(client, db_session, jwt_headers): + now = datetime.now(timezone.utc) + h = Host( + hostname="p-host", + os_family="linux", + product="ssh-monitor", + last_seen_at=now, + ) + db_session.add(h) + db_session.flush() + db_session.add( + Problem( + host_id=h.id, + title="recent", + summary="s", + severity="warning", + status="open", + rule_id="rule:test", + fingerprint="fp-recent", + event_count=1, + last_seen_at=now, + created_at=now - timedelta(hours=2), + updated_at=now - timedelta(hours=2), + ) + ) + db_session.add( + Problem( + host_id=h.id, + title="old", + summary="s", + severity="warning", + status="resolved", + rule_id="rule:test", + fingerprint="fp-old", + event_count=1, + last_seen_at=now, + created_at=now - timedelta(days=3), + updated_at=now - timedelta(days=2), + ) + ) + db_session.commit() + + r = client.get("/api/v1/problems?created_within_hours=24", headers=jwt_headers) + assert r.status_code == 200 + body = r.json() + assert body["total"] == 1 + assert body["items"][0]["title"] == "recent" + + +def test_problems_resolved_within_hours(client, db_session, jwt_headers): + now = datetime.now(timezone.utc) + h = Host( + hostname="r-host", + os_family="linux", + product="ssh-monitor", + last_seen_at=now, + ) + db_session.add(h) + db_session.flush() + db_session.add( + Problem( + host_id=h.id, + title="closed today", + summary="s", + severity="warning", + status="resolved", + rule_id="rule:test", + fingerprint="fp-closed-today", + event_count=1, + last_seen_at=now, + created_at=now - timedelta(days=2), + updated_at=now - timedelta(hours=1), + ) + ) + db_session.add( + Problem( + host_id=h.id, + title="closed long ago", + summary="s", + severity="warning", + status="resolved", + rule_id="rule:test", + fingerprint="fp-closed-old", + event_count=1, + last_seen_at=now, + created_at=now - timedelta(days=10), + updated_at=now - timedelta(days=5), + ) + ) + db_session.commit() + + r = client.get("/api/v1/problems?resolved_within_hours=24", headers=jwt_headers) + assert r.status_code == 200 + body = r.json() + assert body["total"] == 1 + assert body["items"][0]["title"] == "closed today" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 826fecc..1eb2c3c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -223,6 +223,7 @@ export interface HostListResponse { total: number; page: number; page_size: number; + latest_agent_versions?: Record; } export interface ProblemSummary { diff --git a/frontend/src/style.css b/frontend/src/style.css index f6cdc9e..343e2f8 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -287,6 +287,15 @@ pre { color: #9aa4b2; } +.agent-version-outdated { + display: inline-block; + padding: 0.1rem 0.35rem; + border-radius: 4px; + background: #5c1a1a; + color: #ffd4d4; + font-weight: 600; +} + .live-badge { color: #6bcb77; font-size: 0.85rem; diff --git a/frontend/src/utils/agentVersion.ts b/frontend/src/utils/agentVersion.ts new file mode 100644 index 0000000..16c9d84 --- /dev/null +++ b/frontend/src/utils/agentVersion.ts @@ -0,0 +1,44 @@ +const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-SAC)?$/i; +const DEFAULT_LAG_THRESHOLD = 3; + +export interface ParsedAgentVersion { + major: number; + minor: number; + patch: number; +} + +export function parseAgentVersion(raw: string | null | undefined): ParsedAgentVersion | null { + if (!raw) return null; + const match = raw.trim().match(VERSION_RE); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +function compareVersions(a: ParsedAgentVersion, b: ParsedAgentVersion): number { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + return a.patch - b.patch; +} + +function agentVersionLag(host: ParsedAgentVersion, latest: ParsedAgentVersion): number | null { + if (compareVersions(host, latest) > 0) return 0; + if (host.major !== latest.major || host.minor !== latest.minor) return null; + return latest.patch - host.patch; +} + +export function isAgentVersionOutdated( + hostVersion: string | null | undefined, + latestVersion: string | null | undefined, + lagThreshold = DEFAULT_LAG_THRESHOLD, +): boolean { + const host = parseAgentVersion(hostVersion); + const latest = parseAgentVersion(latestVersion); + if (!host || !latest || compareVersions(host, latest) >= 0) return false; + const lag = agentVersionLag(host, latest); + if (lag === null) return true; + return lag >= lagThreshold; +} diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index cdb73d9..d076a6c 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -36,7 +36,7 @@
Новых проблем за 24 ч
- Все проблемы → + Новые за 24 ч → @@ -46,7 +46,7 @@
Закрыто за 24 ч
- Resolved → + Закрытые за 24 ч → @@ -66,7 +66,7 @@
Устаревший heartbeat
- Хосты → + Stale-хосты → diff --git a/frontend/src/views/HostsView.vue b/frontend/src/views/HostsView.vue index f4df894..15b0586 100644 --- a/frontend/src/views/HostsView.vue +++ b/frontend/src/views/HostsView.vue @@ -3,9 +3,18 @@
- + +

{{ error }}

Загрузка…

@@ -44,7 +53,14 @@ {{ h.id }} {{ h.display_name || h.hostname }} {{ h.hostname }} - {{ h.product_version || "—" }} + + + {{ h.product_version || "—" }} + + {{ h.os_family }} {{ h.ipv4 || "—" }} {{ agentLabel(h.agent_status) }} @@ -80,8 +96,8 @@ diff --git a/frontend/src/views/ProblemsView.vue b/frontend/src/views/ProblemsView.vue index 96adef3..cd37068 100644 --- a/frontend/src/views/ProblemsView.vue +++ b/frontend/src/views/ProblemsView.vue @@ -1,22 +1,23 @@