diff --git a/backend/alembic/versions/0017_dashboard_check_history.py b/backend/alembic/versions/0017_dashboard_check_history.py new file mode 100644 index 0000000..987712b --- /dev/null +++ b/backend/alembic/versions/0017_dashboard_check_history.py @@ -0,0 +1,57 @@ +"""0017 dashboard device check history + +Revision ID: 0017 +Revises: 0016 +Create Date: 2026-05-05 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0017" +down_revision = "0016" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "dashboard_device_check_history", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("device_id", sa.Integer(), sa.ForeignKey("devices.id", ondelete="CASCADE"), nullable=False), + sa.Column( + "check_key", + sa.String(length=64), + sa.ForeignKey("dashboard_check_definitions.check_key", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("ping_ms", sa.Integer(), nullable=True), + sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("duration_ms", sa.Integer(), nullable=True), + sa.Column("error", sa.Text(), nullable=True), + sa.Column("source", sa.String(length=32), nullable=False, server_default="dashboard_monitor"), + ) + op.create_index( + "ix_dashboard_device_check_history_device_id", + "dashboard_device_check_history", + ["device_id"], + ) + op.create_index( + "ix_dashboard_device_check_history_check_key", + "dashboard_device_check_history", + ["check_key"], + ) + op.create_index( + "ix_dashboard_device_check_history_checked_at", + "dashboard_device_check_history", + ["checked_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_dashboard_device_check_history_checked_at", table_name="dashboard_device_check_history") + op.drop_index("ix_dashboard_device_check_history_check_key", table_name="dashboard_device_check_history") + op.drop_index("ix_dashboard_device_check_history_device_id", table_name="dashboard_device_check_history") + op.drop_table("dashboard_device_check_history") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ceb6517..8991fdf 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -14,6 +14,7 @@ from .dashboard import ( DashboardCheckDefinition, DashboardObjectCheckOverride, DashboardDeviceLiveState, + DashboardDeviceCheckHistory, DashboardObjectMonitorPolicy, DashboardTagMonitorPolicy, DashboardMonitorRunState, @@ -44,6 +45,7 @@ __all__ = [ "DashboardCheckDefinition", "DashboardObjectCheckOverride", "DashboardDeviceLiveState", + "DashboardDeviceCheckHistory", "DashboardObjectMonitorPolicy", "DashboardTagMonitorPolicy", "DashboardMonitorRunState", diff --git a/backend/app/models/dashboard.py b/backend/app/models/dashboard.py index 822a15b..474d8e3 100644 --- a/backend/app/models/dashboard.py +++ b/backend/app/models/dashboard.py @@ -66,6 +66,24 @@ class DashboardDeviceLiveState(Base): source: Mapped[str] = mapped_column(String(32), nullable=False, default="dashboard_monitor") +class DashboardDeviceCheckHistory(Base): + __tablename__ = "dashboard_device_check_history" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + device_id: Mapped[int] = mapped_column( + Integer, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False, index=True + ) + check_key: Mapped[str] = mapped_column( + String(64), ForeignKey("dashboard_check_definitions.check_key", ondelete="CASCADE"), nullable=False, index=True + ) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="unknown") + ping_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + error: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + source: Mapped[str] = mapped_column(String(32), nullable=False, default="dashboard_monitor") + + class DashboardObjectMonitorPolicy(Base, TimestampMixin): __tablename__ = "dashboard_object_monitor_policy" diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 52355fb..63cbf00 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Query, status from app.dependencies import get_current_user from app.models.user import User @@ -7,8 +7,11 @@ from app.schemas.dashboard import ( DashboardChecksResponse, DashboardHomeSummaryResponse, DashboardMonitorStatus, + DashboardObjectAvailabilityResponse, + DashboardObjectDevicesResponse, DashboardObjectOverridePatchItem, DashboardObjectPolicyPatchItem, + DashboardObjectSummaryResponse, DashboardPolicyResponse, DashboardRunRequest, DashboardRunResponse, @@ -37,6 +40,65 @@ async def home_summary( return DashboardHomeSummaryResponse.model_validate(data) +@router.get("/object/{object_id}/summary", response_model=DashboardObjectSummaryResponse) +async def object_summary( + object_id: int, + city: str | None = None, + client: str | None = None, + object_ids: list[int] | None = None, + tag_ids: list[int] | None = None, + _: User = Depends(get_current_user), +): + scope = { + "city": city, + "client": client, + "object_ids": object_ids or [], + "tag_ids": tag_ids or [], + } + data = await dashboard_service.get_object_summary(object_id, scope) + if data is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found") + return DashboardObjectSummaryResponse.model_validate(data) + + +@router.get("/object/{object_id}/devices", response_model=DashboardObjectDevicesResponse) +async def object_devices( + object_id: int, + city: str | None = None, + client: str | None = None, + object_ids: list[int] | None = None, + tag_ids: list[int] | None = None, + _: User = Depends(get_current_user), +): + scope = { + "city": city, + "client": client, + "object_ids": object_ids or [], + "tag_ids": tag_ids or [], + } + data = await dashboard_service.get_object_devices(object_id, scope) + if data is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found") + return DashboardObjectDevicesResponse.model_validate(data) + + +@router.get("/object/{object_id}/availability", response_model=DashboardObjectAvailabilityResponse) +async def object_availability( + object_id: int, + window: str = Query("24h"), + device_ids: list[int] | None = None, + _: User = Depends(get_current_user), +): + data = await dashboard_service.get_object_availability( + object_id, + window=window, + device_ids=device_ids, + ) + if data is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found") + return DashboardObjectAvailabilityResponse.model_validate(data) + + @router.get("/monitor/status", response_model=DashboardMonitorStatus) async def monitor_status(_: User = Depends(get_current_user)): row = await dashboard_service.get_monitor_status() diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index 1f1d556..57bfaeb 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field MonitorPolicyMode = Literal["inherit", "include", "exclude"] +AvailabilityWindow = Literal["24h", "3d", "7d"] class DashboardScope(BaseModel): @@ -127,6 +128,7 @@ class DashboardObjectSummary(BaseModel): client: Optional[str] = None allowed: bool monitor_mode: MonitorPolicyMode + inclusion_reason: str device_count: int checks: dict[str, DashboardObjectCheckSummary] health_score: Optional[float] = None @@ -139,3 +141,87 @@ class DashboardHomeSummaryResponse(BaseModel): checks_meta: list[DashboardCheckDefinitionRead] objects: list[DashboardObjectSummary] problem_objects: list[DashboardObjectSummary] + included_count: int + excluded_count: int + included_objects: list[DashboardObjectSummary] + excluded_objects: list[DashboardObjectSummary] + + +class DashboardDeviceNOCItem(BaseModel): + device_id: int + object_id: int + ip: str + hostname: Optional[str] = None + category: str + role: Optional[str] = None + status: Optional[str] = None + monitor_enabled: bool + last_seen: Optional[datetime] = None + last_ping_ms: Optional[int] = None + ping_status: str + ping_checked_at: Optional[datetime] = None + ping_age_sec: Optional[int] = None + ping_fresh: bool + disk_status: Optional[str] = None + disk_max_pct: Optional[float] = None + has_problem: bool + + +class DashboardObjectSummaryResponse(BaseModel): + generated_at: datetime + scope: DashboardScope + object: DashboardObjectSummary + totals: dict[str, int] + checks_meta: list[DashboardCheckDefinitionRead] + top_problem_devices: list[DashboardDeviceNOCItem] + + +class DashboardObjectDevicesResponse(BaseModel): + generated_at: datetime + scope: DashboardScope + object_id: int + devices: list[DashboardDeviceNOCItem] + + +class DashboardAvailabilityPoint(BaseModel): + bucket_start: datetime + bucket_end: datetime + availability_pct: Optional[float] = None + samples: int + online: int + offline: int + + +class DashboardAvailabilityInterval(BaseModel): + status: str + started_at: datetime + ended_at: datetime + duration_sec: int + + +class DashboardDeviceAvailability(BaseModel): + device_id: int + ip: str + hostname: Optional[str] = None + availability_pct: Optional[float] = None + samples: int + online: int + offline: int + last_checked_at: Optional[datetime] = None + last_status: Optional[str] = None + last_ping_ms: Optional[int] = None + series: list[DashboardAvailabilityPoint] + intervals: list[DashboardAvailabilityInterval] + + +class DashboardObjectAvailabilityResponse(BaseModel): + generated_at: datetime + object_id: int + window: AvailabilityWindow + start_at: datetime + end_at: datetime + bucket_sec: int + overall_availability_pct: Optional[float] = None + overall_series: list[DashboardAvailabilityPoint] + devices: list[DashboardDeviceAvailability] + insufficient_data: bool diff --git a/backend/app/services/dashboard_monitor.py b/backend/app/services/dashboard_monitor.py index a7eff0d..86e8e65 100644 --- a/backend/app/services/dashboard_monitor.py +++ b/backend/app/services/dashboard_monitor.py @@ -1,6 +1,7 @@ import asyncio import time -from datetime import datetime, timezone +from collections import defaultdict +from datetime import datetime, timedelta, timezone from typing import Any from sqlalchemy import select @@ -9,6 +10,7 @@ from sqlalchemy.orm import selectinload from app.config import settings from app.models.dashboard import ( DashboardCheckDefinition, + DashboardDeviceCheckHistory, DashboardDeviceLiveState, DashboardMonitorRunState, DashboardObjectCheckOverride, @@ -45,6 +47,18 @@ DEFAULT_CHECKS = [ }, ] +WINDOW_SECONDS = { + "24h": 24 * 3600, + "3d": 3 * 24 * 3600, + "7d": 7 * 24 * 3600, +} + +WINDOW_BUCKET_SECONDS = { + "24h": 3600, + "3d": 7200, + "7d": 21600, +} + _RUN_LOCK = asyncio.Lock() _MANUAL_TASK: asyncio.Task | None = None @@ -53,6 +67,47 @@ async def _noop_emit(_: str, __: str = "info") -> None: return +def _scope_to_dict(scope: dict[str, Any] | None) -> dict[str, Any]: + if not scope: + return {} + return { + "city": scope.get("city"), + "client": scope.get("client"), + "object_ids": list(scope.get("object_ids") or []), + "tag_ids": list(scope.get("tag_ids") or []), + } + + +def _window_seconds(window: str) -> int: + return WINDOW_SECONDS.get(window, WINDOW_SECONDS["24h"]) + + +def _bucket_seconds(window: str) -> int: + return WINDOW_BUCKET_SECONDS.get(window, WINDOW_BUCKET_SECONDS["24h"]) + + +def _is_object_in_scope(obj: Object, scope: dict[str, Any]) -> bool: + city = scope.get("city") + if city and obj.city != city: + return False + + client = scope.get("client") + if client and obj.client != client: + return False + + object_ids = list(scope.get("object_ids") or []) + if object_ids and obj.id not in object_ids: + return False + + tag_ids = set(scope.get("tag_ids") or []) + if tag_ids: + obj_tag_ids = {t.id for t in obj.tags} + if obj_tag_ids.isdisjoint(tag_ids): + return False + + return True + + async def ensure_dashboard_bootstrap() -> None: async with AsyncSessionLocal() as db: async with db.begin(): @@ -68,8 +123,6 @@ async def ensure_dashboard_bootstrap() -> None: db.add(DashboardCheckDefinition(**payload)) continue - # keep user-tuned runtime settings, but backfill key defaults and - # transparently switch legacy disk_usage action source if row.check_key == "disk_usage" and row.plugin_action == "debian_system_info": row.plugin_action = "disk_usage" if not row.plugin_action: @@ -94,17 +147,6 @@ async def _get_run_state(db) -> DashboardMonitorRunState: return row -def _scope_to_dict(scope: dict[str, Any] | None) -> dict[str, Any]: - if not scope: - return {} - return { - "city": scope.get("city"), - "client": scope.get("client"), - "object_ids": list(scope.get("object_ids") or []), - "tag_ids": list(scope.get("tag_ids") or []), - } - - async def get_monitor_status() -> DashboardMonitorRunState: async with AsyncSessionLocal() as db: return await _get_run_state(db) @@ -130,24 +172,37 @@ async def _load_objects_with_scope(db, scope: dict[str, Any] | None) -> list[Obj return list(result.scalars().all()) +async def _load_object_with_tags(db, obj_id: int) -> Object | None: + row = ( + await db.execute( + select(Object) + .options(selectinload(Object.tags)) + .where(Object.id == obj_id, Object.is_active == True) + ) + ).scalar_one_or_none() + return row + + def _is_object_allowed( obj: Object, object_modes: dict[int, str], tag_modes: dict[int, bool], -) -> tuple[bool, str]: +) -> tuple[bool, str, str]: mode = object_modes.get(obj.id, "inherit") if mode == "include": - return True, mode + return True, mode, "object_include_policy" if mode == "exclude": - return False, mode + return False, mode, "object_exclude_policy" tag_values = [tag_modes[t.id] for t in obj.tags if t.id in tag_modes] if not tag_values: - return True, mode - return any(tag_values), mode + return True, mode, "inherit_default" + if any(tag_values): + return True, mode, "tag_policy_enabled" + return False, mode, "tag_policy_disabled" -async def _build_object_policy_map(db, objects: list[Object]) -> dict[int, tuple[bool, str]]: +async def _build_object_policy_map(db, objects: list[Object]) -> dict[int, tuple[bool, str, str]]: object_ids = [o.id for o in objects] if not object_ids: return {} @@ -262,6 +317,40 @@ async def _upsert_live_state( row.source = "dashboard_monitor" +async def _append_check_history( + db, + *, + device_id: int, + check_key: str, + status: str, + value_json: dict[str, Any] | None, + error: str | None, + checked_at: datetime, + duration_ms: int, +) -> None: + ping_ms = None + if check_key == "ping" and value_json is not None: + raw = value_json.get("ping_ms") + if raw is not None: + try: + ping_ms = int(raw) + except (TypeError, ValueError): + ping_ms = None + + db.add( + DashboardDeviceCheckHistory( + device_id=device_id, + check_key=check_key, + status=status, + ping_ms=ping_ms, + checked_at=checked_at, + duration_ms=duration_ms, + error=error, + source="dashboard_monitor", + ) + ) + + async def _run_check_on_device( sem: asyncio.Semaphore, check: DashboardCheckDefinition, @@ -345,7 +434,7 @@ async def run_dashboard_monitor_cycle( counts["objects_in_scope"] = len(objects) object_policy_map = await _build_object_policy_map(db, objects) - allowed_object_ids = [oid for oid, (allowed, _mode) in object_policy_map.items() if allowed] + allowed_object_ids = [oid for oid, (allowed, _mode, _reason) in object_policy_map.items() if allowed] counts["objects_allowed"] = len(allowed_object_ids) counts["skipped_policy"] = max(0, len(objects) - len(allowed_object_ids)) @@ -434,6 +523,7 @@ async def run_dashboard_monitor_cycle( if isinstance(result, Exception): counts["failed"] += 1 counts["checks_executed"] += 1 + checked_at = datetime.now(timezone.utc) await _upsert_live_state( dbw, device_id=device.id, @@ -441,7 +531,17 @@ async def run_dashboard_monitor_cycle( status="failed", value_json=None, error=str(result), - checked_at=datetime.now(timezone.utc), + checked_at=checked_at, + duration_ms=0, + ) + await _append_check_history( + dbw, + device_id=device.id, + check_key=check.check_key, + status="failed", + value_json=None, + error=str(result), + checked_at=checked_at, duration_ms=0, ) continue @@ -462,11 +562,18 @@ async def run_dashboard_monitor_cycle( checked_at=checked_at, duration_ms=duration_ms, ) + await _append_check_history( + dbw, + device_id=device.id, + check_key=check.check_key, + status=status, + value_json=value_json, + error=error, + checked_at=checked_at, + duration_ms=duration_ms, + ) - if ( - settings.DASHBOARD_MONITOR_SYNC_DEVICE_STATUS - and check.check_key == "ping" - ): + if settings.DASHBOARD_MONITOR_SYNC_DEVICE_STATUS and check.check_key == "ping": device_row = await dbw.get(Device, device.id) if device_row is not None: device_row.status = "online" if status == "online" else "offline" @@ -692,6 +799,128 @@ def _calc_health( return round(max(0.0, score), 1) +def _build_check_summary( + *, + now: datetime, + obj_devices: list[Device], + checks: list[DashboardCheckDefinition], + state_map: dict[tuple[int, str], DashboardDeviceLiveState], +) -> dict[str, dict[str, Any]]: + check_summary: dict[str, dict[str, Any]] = {} + for check in checks: + counts: dict[str, int] = {} + last_checked = None + max_age = None + for dev in obj_devices: + state = state_map.get((dev.id, check.check_key)) + if state is None: + counts["unknown"] = counts.get("unknown", 0) + 1 + continue + counts[state.status] = counts.get(state.status, 0) + 1 + if last_checked is None or state.checked_at > last_checked: + last_checked = state.checked_at + age_sec = int((now - state.checked_at).total_seconds()) + if max_age is None or age_sec > max_age: + max_age = age_sec + check_summary[check.check_key] = { + "status_counts": counts, + "last_checked_at": last_checked, + "max_age_sec": max_age, + } + return check_summary + + +def _to_object_summary( + *, + now: datetime, + obj: Object, + allowed: bool, + monitor_mode: str, + inclusion_reason: str, + checks: list[DashboardCheckDefinition], + obj_devices: list[Device], + state_map: dict[tuple[int, str], DashboardDeviceLiveState], +) -> dict[str, Any]: + check_summary = _build_check_summary(now=now, obj_devices=obj_devices, checks=checks, state_map=state_map) + check_interval_map = {c.check_key: c.interval_sec_default for c in checks} + + health = None + if allowed: + ping_meta = check_summary.get("ping", {}) + health = _calc_health( + {k: v["status_counts"] for k, v in check_summary.items()}, + device_count=len(obj_devices), + ping_interval_sec=check_interval_map.get("ping"), + ping_max_age_sec=ping_meta.get("max_age_sec"), + ) + + return { + "object_id": obj.id, + "object_name": obj.name, + "city": obj.city, + "client": obj.client, + "allowed": allowed, + "monitor_mode": monitor_mode, + "inclusion_reason": inclusion_reason, + "device_count": len(obj_devices), + "checks": check_summary, + "health_score": health, + } + + +def _device_noc_item( + *, + now: datetime, + device: Device, + ping_state: DashboardDeviceLiveState | None, + disk_state: DashboardDeviceLiveState | None, + ping_interval_sec: int, +) -> dict[str, Any]: + ping_age_sec = None + ping_checked_at = None + ping_status = "unknown" + ping_fresh = False + + if ping_state is not None: + ping_status = ping_state.status + ping_checked_at = ping_state.checked_at + ping_age_sec = int((now - ping_state.checked_at).total_seconds()) + ping_fresh = ping_age_sec <= ping_interval_sec * 3 + + disk_status = disk_state.status if disk_state is not None else None + disk_max_pct = None + if disk_state and isinstance(disk_state.value_json, dict): + val = disk_state.value_json.get("max_pct") + if isinstance(val, (int, float)): + disk_max_pct = float(val) + + has_problem = ( + ping_status == "offline" + or (ping_status == "unknown" and not ping_fresh) + or disk_status in ("warn", "failed") + ) + + return { + "device_id": device.id, + "object_id": device.object_id, + "ip": device.ip, + "hostname": device.hostname, + "category": device.category, + "role": device.role, + "status": device.status, + "monitor_enabled": device.monitor_enabled, + "last_seen": device.last_seen, + "last_ping_ms": device.last_ping_ms, + "ping_status": ping_status, + "ping_checked_at": ping_checked_at, + "ping_age_sec": ping_age_sec, + "ping_fresh": ping_fresh, + "disk_status": disk_status, + "disk_max_pct": disk_max_pct, + "has_problem": has_problem, + } + + async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]: now = datetime.now(timezone.utc) scope = _scope_to_dict(scope) @@ -730,67 +959,34 @@ async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]: checks = (await db.execute(select(DashboardCheckDefinition).order_by(DashboardCheckDefinition.check_key))).scalars().all() - check_interval_map = {c.check_key: c.interval_sec_default for c in checks} summaries = [] for obj in objects: - allowed, mode = policy_map.get(obj.id, (True, "inherit")) + allowed, mode, reason = policy_map.get(obj.id, (True, "inherit", "inherit_default")) obj_devices = devices_by_object.get(obj.id, []) - check_summary: dict[str, dict[str, Any]] = {} - for check in checks: - counts: dict[str, int] = {} - last_checked = None - max_age = None - for dev in obj_devices: - state = state_map.get((dev.id, check.check_key)) - if state is None: - counts["unknown"] = counts.get("unknown", 0) + 1 - continue - counts[state.status] = counts.get(state.status, 0) + 1 - if last_checked is None or state.checked_at > last_checked: - last_checked = state.checked_at - age_sec = int((now - state.checked_at).total_seconds()) - if max_age is None or age_sec > max_age: - max_age = age_sec - check_summary[check.check_key] = { - "status_counts": counts, - "last_checked_at": last_checked, - "max_age_sec": max_age, - } - - health = None - if allowed: - ping_meta = check_summary.get("ping", {}) - health = _calc_health( - {k: v["status_counts"] for k, v in check_summary.items()}, - device_count=len(obj_devices), - ping_interval_sec=check_interval_map.get("ping"), - ping_max_age_sec=ping_meta.get("max_age_sec"), - ) summaries.append( - { - "object_id": obj.id, - "object_name": obj.name, - "city": obj.city, - "client": obj.client, - "allowed": allowed, - "monitor_mode": mode, - "device_count": len(obj_devices), - "checks": check_summary, - "health_score": health, - } + _to_object_summary( + now=now, + obj=obj, + allowed=allowed, + monitor_mode=mode, + inclusion_reason=reason, + checks=checks, + obj_devices=obj_devices, + state_map=state_map, + ) ) + included_objects = [s for s in summaries if s["allowed"]] + excluded_objects = [s for s in summaries if not s["allowed"]] problems = sorted( - summaries, - key=lambda x: ( - x["allowed"] is False, - x["health_score"] if x["health_score"] is not None else 999, - ), + included_objects, + key=lambda x: x["health_score"] if x["health_score"] is not None else 999, )[:10] totals = { "objects": len(objects), - "allowed_objects": sum(1 for s in summaries if s["allowed"]), + "allowed_objects": len(included_objects), + "excluded_objects": len(excluded_objects), "devices": len(device_rows), } @@ -801,4 +997,333 @@ async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]: "checks_meta": checks, "objects": summaries, "problem_objects": problems, + "included_count": len(included_objects), + "excluded_count": len(excluded_objects), + "included_objects": included_objects, + "excluded_objects": excluded_objects, + } + + +async def get_object_devices(object_id: int, scope: dict[str, Any] | None) -> dict[str, Any] | None: + now = datetime.now(timezone.utc) + scope = _scope_to_dict(scope) + + async with AsyncSessionLocal() as db: + obj = await _load_object_with_tags(db, object_id) + if obj is None: + return None + + device_rows = ( + await db.execute( + select(Device) + .where(Device.object_id == object_id, Device.is_active == True) + .order_by(Device.category, Device.ip) + ) + ).scalars().all() + + state_rows = [] + if device_rows: + state_rows = ( + await db.execute( + select(DashboardDeviceLiveState).where( + DashboardDeviceLiveState.device_id.in_([d.id for d in device_rows]), + DashboardDeviceLiveState.check_key.in_(["ping", "disk_usage"]), + ) + ) + ).scalars().all() + + check_rows = ( + await db.execute( + select(DashboardCheckDefinition).where(DashboardCheckDefinition.check_key == "ping") + ) + ).scalars().all() + + ping_interval = check_rows[0].interval_sec_default if check_rows else 300 + state_map = {(row.device_id, row.check_key): row for row in state_rows} + + rows = [] + for device in device_rows: + rows.append( + _device_noc_item( + now=now, + device=device, + ping_state=state_map.get((device.id, "ping")), + disk_state=state_map.get((device.id, "disk_usage")), + ping_interval_sec=ping_interval, + ) + ) + + rows.sort( + key=lambda r: ( + not r["has_problem"], + 0 if r["ping_status"] == "offline" else 1, + r["ip"], + ) + ) + + return { + "generated_at": now, + "scope": scope, + "object_id": object_id, + "devices": rows, + } + + +async def get_object_summary(object_id: int, scope: dict[str, Any] | None) -> dict[str, Any] | None: + now = datetime.now(timezone.utc) + scope = _scope_to_dict(scope) + + async with AsyncSessionLocal() as db: + obj = await _load_object_with_tags(db, object_id) + if obj is None: + return None + + policy_map = await _build_object_policy_map(db, [obj]) + allowed, mode, reason = policy_map.get(obj.id, (True, "inherit", "inherit_default")) + + if not _is_object_in_scope(obj, scope): + allowed = False + reason = "scope_filtered" + + checks = (await db.execute(select(DashboardCheckDefinition).order_by(DashboardCheckDefinition.check_key))).scalars().all() + + device_rows = ( + await db.execute( + select(Device).where(Device.object_id == object_id, Device.is_active == True) + ) + ).scalars().all() + + state_rows = [] + if device_rows: + state_rows = ( + await db.execute( + select(DashboardDeviceLiveState).where( + DashboardDeviceLiveState.device_id.in_([d.id for d in device_rows]) + ) + ) + ).scalars().all() + + state_map: dict[tuple[int, str], DashboardDeviceLiveState] = { + (row.device_id, row.check_key): row for row in state_rows + } + + summary = _to_object_summary( + now=now, + obj=obj, + allowed=allowed, + monitor_mode=mode, + inclusion_reason=reason, + checks=checks, + obj_devices=device_rows, + state_map=state_map, + ) + + ping_interval = next((c.interval_sec_default for c in checks if c.check_key == "ping"), 300) + top_problem_devices = [ + _device_noc_item( + now=now, + device=device, + ping_state=state_map.get((device.id, "ping")), + disk_state=state_map.get((device.id, "disk_usage")), + ping_interval_sec=ping_interval, + ) + for device in device_rows + ] + top_problem_devices.sort( + key=lambda r: ( + not r["has_problem"], + 0 if r["ping_status"] == "offline" else 1, + r["ip"], + ) + ) + + totals = { + "devices": len(device_rows), + "problem_devices": sum(1 for d in top_problem_devices if d["has_problem"]), + "offline": sum(1 for d in top_problem_devices if d["ping_status"] == "offline"), + "warn": sum(1 for d in top_problem_devices if d["disk_status"] in ("warn", "failed")), + } + + return { + "generated_at": now, + "scope": scope, + "object": summary, + "totals": totals, + "checks_meta": checks, + "top_problem_devices": top_problem_devices[:12], + } + + +def _build_bucket_points(start_at: datetime, end_at: datetime, bucket_sec: int) -> list[tuple[datetime, datetime]]: + buckets = [] + cur = start_at + while cur < end_at: + nxt = min(end_at, cur + timedelta(seconds=bucket_sec)) + buckets.append((cur, nxt)) + cur = nxt + return buckets + + +def _series_from_rows( + rows: list[DashboardDeviceCheckHistory], + *, + start_at: datetime, + end_at: datetime, + bucket_sec: int, +) -> list[dict[str, Any]]: + buckets = _build_bucket_points(start_at, end_at, bucket_sec) + points = [] + for b_start, b_end in buckets: + bucket_rows = [r for r in rows if b_start <= r.checked_at < b_end and r.status in ("online", "offline")] + online = sum(1 for r in bucket_rows if r.status == "online") + offline = sum(1 for r in bucket_rows if r.status == "offline") + samples = online + offline + availability = round((online / samples) * 100, 1) if samples > 0 else None + points.append( + { + "bucket_start": b_start, + "bucket_end": b_end, + "availability_pct": availability, + "samples": samples, + "online": online, + "offline": offline, + } + ) + return points + + +def _intervals_from_rows(rows: list[DashboardDeviceCheckHistory], *, end_at: datetime) -> list[dict[str, Any]]: + if not rows: + return [] + intervals = [] + cur_status = rows[0].status + cur_start = rows[0].checked_at + + for row in rows[1:]: + if row.status == cur_status: + continue + duration = int((row.checked_at - cur_start).total_seconds()) + intervals.append( + { + "status": cur_status, + "started_at": cur_start, + "ended_at": row.checked_at, + "duration_sec": max(0, duration), + } + ) + cur_status = row.status + cur_start = row.checked_at + + intervals.append( + { + "status": cur_status, + "started_at": cur_start, + "ended_at": end_at, + "duration_sec": max(0, int((end_at - cur_start).total_seconds())), + } + ) + + return intervals + + +async def get_object_availability( + object_id: int, + *, + window: str, + device_ids: list[int] | None = None, +) -> dict[str, Any] | None: + now = datetime.now(timezone.utc) + window_key = window if window in WINDOW_SECONDS else "24h" + start_at = now - timedelta(seconds=_window_seconds(window_key)) + bucket_sec = _bucket_seconds(window_key) + + async with AsyncSessionLocal() as db: + obj = await _load_object_with_tags(db, object_id) + if obj is None: + return None + + q_devices = select(Device).where(Device.object_id == object_id, Device.is_active == True) + if device_ids: + q_devices = q_devices.where(Device.id.in_(device_ids)) + devices = (await db.execute(q_devices.order_by(Device.ip))).scalars().all() + if not devices: + return { + "generated_at": now, + "object_id": object_id, + "window": window_key, + "start_at": start_at, + "end_at": now, + "bucket_sec": bucket_sec, + "overall_availability_pct": None, + "overall_series": _series_from_rows([], start_at=start_at, end_at=now, bucket_sec=bucket_sec), + "devices": [], + "insufficient_data": True, + } + + rows = ( + await db.execute( + select(DashboardDeviceCheckHistory).where( + DashboardDeviceCheckHistory.check_key == "ping", + DashboardDeviceCheckHistory.device_id.in_([d.id for d in devices]), + DashboardDeviceCheckHistory.checked_at >= start_at, + ) + ) + ).scalars().all() + + rows_by_device: dict[int, list[DashboardDeviceCheckHistory]] = defaultdict(list) + for row in rows: + rows_by_device[row.device_id].append(row) + for lst in rows_by_device.values(): + lst.sort(key=lambda r: r.checked_at) + + device_payload = [] + all_rows: list[DashboardDeviceCheckHistory] = [] + + for device in devices: + d_rows = rows_by_device.get(device.id, []) + all_rows.extend(d_rows) + + online = sum(1 for r in d_rows if r.status == "online") + offline = sum(1 for r in d_rows if r.status == "offline") + samples = online + offline + availability = round((online / samples) * 100, 1) if samples > 0 else None + + last_row = d_rows[-1] if d_rows else None + + device_payload.append( + { + "device_id": device.id, + "ip": device.ip, + "hostname": device.hostname, + "availability_pct": availability, + "samples": samples, + "online": online, + "offline": offline, + "last_checked_at": last_row.checked_at if last_row else None, + "last_status": last_row.status if last_row else None, + "last_ping_ms": last_row.ping_ms if last_row else None, + "series": _series_from_rows(d_rows, start_at=start_at, end_at=now, bucket_sec=bucket_sec), + "intervals": _intervals_from_rows(d_rows, end_at=now), + } + ) + + all_rows.sort(key=lambda r: r.checked_at) + overall_online = sum(1 for r in all_rows if r.status == "online") + overall_offline = sum(1 for r in all_rows if r.status == "offline") + overall_samples = overall_online + overall_offline + overall_availability = round((overall_online / overall_samples) * 100, 1) if overall_samples > 0 else None + + insufficient_data = overall_samples == 0 + + return { + "generated_at": now, + "object_id": object_id, + "window": window_key, + "start_at": start_at, + "end_at": now, + "bucket_sec": bucket_sec, + "overall_availability_pct": overall_availability, + "overall_series": _series_from_rows(all_rows, start_at=start_at, end_at=now, bucket_sec=bucket_sec), + "devices": device_payload, + "insufficient_data": insufficient_data, } diff --git a/backend/tests/test_dashboard_monitor_service.py b/backend/tests/test_dashboard_monitor_service.py new file mode 100644 index 0000000..f765f9a --- /dev/null +++ b/backend/tests/test_dashboard_monitor_service.py @@ -0,0 +1,95 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +from app.models.dashboard import DashboardDeviceCheckHistory +from app.services.dashboard_monitor import _intervals_from_rows, _is_object_allowed, _series_from_rows + + +def _obj(obj_id: int, tag_ids: list[int]): + return SimpleNamespace(id=obj_id, tags=[SimpleNamespace(id=t) for t in tag_ids]) + + +def test_object_allowed_respects_object_mode() -> None: + obj = _obj(1, [10, 20]) + + allowed, mode, reason = _is_object_allowed(obj, {1: "include"}, {}) + assert allowed is True + assert mode == "include" + assert reason == "object_include_policy" + + allowed, mode, reason = _is_object_allowed(obj, {1: "exclude"}, {}) + assert allowed is False + assert mode == "exclude" + assert reason == "object_exclude_policy" + + +def test_object_allowed_respects_tag_policy() -> None: + obj = _obj(1, [10, 20]) + + allowed, mode, reason = _is_object_allowed(obj, {}, {10: False, 20: False}) + assert allowed is False + assert mode == "inherit" + assert reason == "tag_policy_disabled" + + allowed, mode, reason = _is_object_allowed(obj, {}, {10: False, 20: True}) + assert allowed is True + assert mode == "inherit" + assert reason == "tag_policy_enabled" + + +def test_object_allowed_default_when_no_tag_policy() -> None: + obj = _obj(2, [100]) + allowed, mode, reason = _is_object_allowed(obj, {}, {}) + assert allowed is True + assert mode == "inherit" + assert reason == "inherit_default" + + +def test_series_and_intervals_for_ping_history() -> None: + start = datetime(2026, 5, 5, 0, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=3) + + rows = [ + DashboardDeviceCheckHistory( + device_id=1, + check_key="ping", + status="online", + ping_ms=12, + checked_at=start + timedelta(minutes=10), + duration_ms=20, + error=None, + source="dashboard_monitor", + ), + DashboardDeviceCheckHistory( + device_id=1, + check_key="ping", + status="offline", + ping_ms=None, + checked_at=start + timedelta(minutes=70), + duration_ms=30, + error="timeout", + source="dashboard_monitor", + ), + DashboardDeviceCheckHistory( + device_id=1, + check_key="ping", + status="online", + ping_ms=25, + checked_at=start + timedelta(minutes=130), + duration_ms=25, + error=None, + source="dashboard_monitor", + ), + ] + + series = _series_from_rows(rows, start_at=start, end_at=end, bucket_sec=3600) + assert len(series) == 3 + assert series[0]["availability_pct"] == 100.0 + assert series[1]["availability_pct"] == 0.0 + assert series[2]["availability_pct"] == 100.0 + + intervals = _intervals_from_rows(rows, end_at=end) + assert len(intervals) == 3 + assert intervals[0]["status"] == "online" + assert intervals[1]["status"] == "offline" + assert intervals[2]["status"] == "online" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 81a8318..d5a74c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,8 @@ import { AdminPage } from './pages/Admin' import { AlertsPage } from './pages/Alerts' import { DeviceDetailPage } from './pages/DeviceDetail' import { HomePage } from './pages/Home' +import { HomeObjectDetailPage } from './pages/HomeObjectDetail' +import { HomeSettingsPage } from './pages/HomeSettings' import { LoginPage } from './pages/Login' import { ObjectsPage } from './pages/Objects' import { SnapshotsPage } from './pages/Snapshots' @@ -32,6 +34,8 @@ export default function App() { > } /> } /> + } /> + } /> {/* Инвентарь */} } /> diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 989fab0..9d31852 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -8,6 +8,8 @@ export interface DashboardScope { tag_ids?: number[] } +export type DashboardAvailabilityWindow = '24h' | '3d' | '7d' + export interface DashboardCheckDefinition { check_key: string enabled: boolean @@ -69,6 +71,7 @@ export interface DashboardObjectSummary { client: string | null allowed: boolean monitor_mode: 'inherit' | 'include' | 'exclude' + inclusion_reason: string device_count: number checks: Record health_score: number | null @@ -81,6 +84,90 @@ export interface DashboardHomeSummary { checks_meta: DashboardCheckDefinition[] objects: DashboardObjectSummary[] problem_objects: DashboardObjectSummary[] + included_count: number + excluded_count: number + included_objects: DashboardObjectSummary[] + excluded_objects: DashboardObjectSummary[] +} + +export interface DashboardDeviceNOCItem { + device_id: number + object_id: number + ip: string + hostname: string | null + category: string + role: string | null + status: string | null + monitor_enabled: boolean + last_seen: string | null + last_ping_ms: number | null + ping_status: string + ping_checked_at: string | null + ping_age_sec: number | null + ping_fresh: boolean + disk_status: string | null + disk_max_pct: number | null + has_problem: boolean +} + +export interface DashboardObjectSummaryResponse { + generated_at: string + scope: DashboardScope + object: DashboardObjectSummary + totals: Record + checks_meta: DashboardCheckDefinition[] + top_problem_devices: DashboardDeviceNOCItem[] +} + +export interface DashboardObjectDevicesResponse { + generated_at: string + scope: DashboardScope + object_id: number + devices: DashboardDeviceNOCItem[] +} + +export interface DashboardAvailabilityPoint { + bucket_start: string + bucket_end: string + availability_pct: number | null + samples: number + online: number + offline: number +} + +export interface DashboardAvailabilityInterval { + status: string + started_at: string + ended_at: string + duration_sec: number +} + +export interface DashboardDeviceAvailability { + device_id: number + ip: string + hostname: string | null + availability_pct: number | null + samples: number + online: number + offline: number + last_checked_at: string | null + last_status: string | null + last_ping_ms: number | null + series: DashboardAvailabilityPoint[] + intervals: DashboardAvailabilityInterval[] +} + +export interface DashboardObjectAvailabilityResponse { + generated_at: string + object_id: number + window: DashboardAvailabilityWindow + start_at: string + end_at: string + bucket_sec: number + overall_availability_pct: number | null + overall_series: DashboardAvailabilityPoint[] + devices: DashboardDeviceAvailability[] + insufficient_data: boolean } export interface DashboardChecksResponse { @@ -98,6 +185,45 @@ export const useDashboardHomeSummary = (scope: DashboardScope) => refetchInterval: 30_000, }) +export const useDashboardObjectSummary = (objectId: number, scope: DashboardScope) => + useQuery({ + queryKey: ['dashboard-object-summary', objectId, scope], + queryFn: () => + api + .get(`/api/v1/dashboard/object/${objectId}/summary`, { params: scope }) + .then((r) => r.data), + enabled: Number.isFinite(objectId) && objectId > 0, + refetchInterval: 30_000, + }) + +export const useDashboardObjectDevices = (objectId: number, scope: DashboardScope) => + useQuery({ + queryKey: ['dashboard-object-devices', objectId, scope], + queryFn: () => + api + .get(`/api/v1/dashboard/object/${objectId}/devices`, { params: scope }) + .then((r) => r.data), + enabled: Number.isFinite(objectId) && objectId > 0, + refetchInterval: 30_000, + }) + +export const useDashboardObjectAvailability = ( + objectId: number, + window: DashboardAvailabilityWindow, + deviceIds?: number[] +) => + useQuery({ + queryKey: ['dashboard-object-availability', objectId, window, deviceIds], + queryFn: () => + api + .get(`/api/v1/dashboard/object/${objectId}/availability`, { + params: { window, device_ids: deviceIds }, + }) + .then((r) => r.data), + enabled: Number.isFinite(objectId) && objectId > 0, + refetchInterval: 45_000, + }) + export const useDashboardMonitorStatus = () => useQuery({ queryKey: ['dashboard-monitor-status'], @@ -116,6 +242,9 @@ export const useDashboardRunNow = () => { onSuccess: () => { qc.invalidateQueries({ queryKey: ['dashboard-monitor-status'] }) qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] }) + qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] }) + qc.invalidateQueries({ queryKey: ['dashboard-object-devices'] }) + qc.invalidateQueries({ queryKey: ['dashboard-object-availability'] }) }, }) } @@ -137,6 +266,7 @@ export const usePatchDashboardObjectPolicy = () => { onSuccess: () => { qc.invalidateQueries({ queryKey: ['dashboard-policy'] }) qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] }) + qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] }) }, }) } @@ -149,6 +279,7 @@ export const usePatchDashboardTagPolicy = () => { onSuccess: () => { qc.invalidateQueries({ queryKey: ['dashboard-policy'] }) qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] }) + qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] }) }, }) } @@ -178,6 +309,8 @@ export const usePatchDashboardChecks = () => { onSuccess: () => { qc.invalidateQueries({ queryKey: ['dashboard-checks'] }) qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] }) + qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] }) + qc.invalidateQueries({ queryKey: ['dashboard-object-devices'] }) }, }) } @@ -197,6 +330,8 @@ export const usePatchDashboardObjectOverrides = () => { onSuccess: () => { qc.invalidateQueries({ queryKey: ['dashboard-checks'] }) qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] }) + qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] }) + qc.invalidateQueries({ queryKey: ['dashboard-object-devices'] }) }, }) } diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 6664ffd..b6846bd 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,63 +1,45 @@ -import { - Alert, - Button, - Card, - Col, - Divider, - InputNumber, - Row, - Select, - Space, - Statistic, - Switch, - Table, - Tag, - Typography, - message, -} from 'antd' +import { Alert, Button, Card, Col, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd' import dayjs from 'dayjs' -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' import { useObjects, useObjectsMeta } from '../api/objects' -import { - useDashboardChecks, - useDashboardHomeSummary, - useDashboardMonitorStatus, - useDashboardPolicy, - useDashboardRunNow, - usePatchDashboardChecks, - usePatchDashboardObjectOverrides, - usePatchDashboardObjectPolicy, - usePatchDashboardTagPolicy, - type DashboardObjectOverride, -} from '../api/dashboard' +import { useDashboardHomeSummary, useDashboardMonitorStatus, useDashboardRunNow, type DashboardScope } from '../api/dashboard' import { useTags } from '../api/tags' import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE' -const CATEGORY_OPTIONS = [ - 'main_server', - 'vm', - 'router', - 'embedded', - 'camera', - 'io_board', - 'bank_terminal', - 'cash_register', - 'other', -] - -type OverrideEnabledMode = 'inherit' | 'enabled' | 'disabled' -type OverrideCategoriesMode = 'inherit' | 'custom' - function formatAge(seconds: number | null | undefined): string { if (seconds == null) return '-' - if (seconds < 60) return `${seconds}с` - if (seconds < 3600) return `${Math.floor(seconds / 60)}м` - return `${Math.floor(seconds / 3600)}ч ${Math.floor((seconds % 3600) / 60)}м` + if (seconds < 60) return `${seconds}` + if (seconds < 3600) return `${Math.floor(seconds / 60)}` + return `${Math.floor(seconds / 3600)} ${Math.floor((seconds % 3600) / 60)}` +} + +function reasonLabel(reason: string): string { + const map: Record = { + object_include_policy: ' ', + object_exclude_policy: ' ', + inherit_default: ' ', + tag_policy_enabled: ' ', + tag_policy_disabled: ' ', + scope_filtered: ' scope', + } + return map[reason] ?? reason +} + +function scopeToSearch(scope: DashboardScope): string { + const params = new URLSearchParams() + if (scope.city) params.set('city', scope.city) + if (scope.client) params.set('client', scope.client) + for (const id of scope.object_ids ?? []) params.append('object_ids', String(id)) + for (const id of scope.tag_ids ?? []) params.append('tag_ids', String(id)) + const raw = params.toString() + return raw ? `?${raw}` : '' } export function HomePage() { const qc = useQueryClient() + const navigate = useNavigate() const { data: meta } = useObjectsMeta() const { data: objects } = useObjects() @@ -75,113 +57,54 @@ export function HomePage() { const { data: summary, isLoading } = useDashboardHomeSummary(scope) const { data: monitorStatus } = useDashboardMonitorStatus() - const { data: policy } = useDashboardPolicy() - const { data: checksData } = useDashboardChecks() - const runNow = useDashboardRunNow() - const patchTagPolicy = usePatchDashboardTagPolicy() - const patchObjectPolicy = usePatchDashboardObjectPolicy() - const patchChecks = usePatchDashboardChecks() - const patchOverrides = usePatchDashboardObjectOverrides() - - const [overrideObjectId, setOverrideObjectId] = useState() - const [overrideCheckKey, setOverrideCheckKey] = useState() - const [overrideInterval, setOverrideInterval] = useState() - const [overrideEnabledMode, setOverrideEnabledMode] = useState('inherit') - const [overrideCategoriesMode, setOverrideCategoriesMode] = useState('inherit') - const [overrideCategories, setOverrideCategories] = useState([]) - - const tagPolicyMap = useMemo(() => { - const map = new Map() - for (const row of policy?.tag_policies ?? []) map.set(row.tag_id, row.enabled) - return map - }, [policy]) - - const objectPolicyMap = useMemo(() => { - const map = new Map() - for (const row of policy?.object_policies ?? []) map.set(row.object_id, row.mode) - return map - }, [policy]) - - const overrideMap = useMemo(() => { - const map = new Map() - for (const row of checksData?.object_overrides ?? []) { - map.set(`${row.object_id}:${row.check_key}`, row) - } - return map - }, [checksData]) - - useEffect(() => { - if (!overrideObjectId || !overrideCheckKey) { - setOverrideEnabledMode('inherit') - setOverrideInterval(undefined) - setOverrideCategoriesMode('inherit') - setOverrideCategories([]) - return - } - - const current = overrideMap.get(`${overrideObjectId}:${overrideCheckKey}`) - if (!current) { - setOverrideEnabledMode('inherit') - setOverrideInterval(undefined) - setOverrideCategoriesMode('inherit') - setOverrideCategories([]) - return - } - - if (current.enabled_override === true) setOverrideEnabledMode('enabled') - else if (current.enabled_override === false) setOverrideEnabledMode('disabled') - else setOverrideEnabledMode('inherit') - - setOverrideInterval(current.interval_sec_override ?? undefined) - - if (current.categories_override && current.categories_override.length > 0) { - setOverrideCategoriesMode('custom') - setOverrideCategories(current.categories_override) - } else { - setOverrideCategoriesMode('inherit') - setOverrideCategories([]) - } - }, [overrideObjectId, overrideCheckKey, overrideMap]) useDashboardMonitorSSE({ onUpdate: () => { qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] }) qc.invalidateQueries({ queryKey: ['dashboard-monitor-status'] }) + qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] }) + qc.invalidateQueries({ queryKey: ['dashboard-object-devices'] }) + qc.invalidateQueries({ queryKey: ['dashboard-object-availability'] }) }, }) const handleRunNow = async () => { try { await runNow.mutateAsync({ ...scope, force: true }) - message.success('Мониторинг запущен') + message.success(' ') } catch { - message.error('Не удалось запустить мониторинг') + message.error(' ') } } - const totals = summary?.totals ?? { objects: 0, devices: 0, allowed_objects: 0 } - const problemObjects = summary?.problem_objects ?? [] + const totals = summary?.totals ?? { objects: 0, devices: 0, allowed_objects: 0, excluded_objects: 0 } - const summaryColumns = [ + const columns = [ { - title: 'Объект', + title: '', key: 'object', render: (_: unknown, row: any) => ( - {row.object_name} + {row.city && {row.city}} {row.client && {row.client}} ), }, { - title: 'Политика', - key: 'policy', - render: (_: unknown, row: any) => {row.monitor_mode}, + title: '', + key: 'reason', + render: (_: unknown, row: any) => {reasonLabel(row.inclusion_reason)}, }, { - title: 'Устройств', + title: '', dataIndex: 'device_count', key: 'device_count', }, @@ -201,22 +124,6 @@ export function HomePage() { ) }, }, - { - title: 'Диски', - key: 'disk', - render: (_: unknown, row: any) => { - const disk = row.checks?.disk_usage - const cnt = disk?.status_counts ?? {} - return ( - - ok {cnt.ok ?? 0} / warn {cnt.warn ?? 0} / failed {cnt.failed ?? 0} - - age: {formatAge(disk?.max_age_sec)} - - - ) - }, - }, { title: 'Health', key: 'health', @@ -230,14 +137,22 @@ export function HomePage() { return (
- - Главная - + + + NOC + + + + + + ({ value: c, label: c }))} @@ -258,7 +173,7 @@ export function HomePage() { - - - - Статус: {monitorStatus?.status ?? '-'} + : {monitorStatus?.status ?? '-'} {monitorStatus?.last_success_at - ? ` · успешный запуск ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}` + ? ` ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}` : ''} @@ -300,270 +210,57 @@ export function HomePage() { )} - - + + - - + + - - + + + + + - - - {(checksData?.checks ?? []).map((check) => ( - - {check.check_key} - - { - await patchChecks.mutateAsync([{ check_key: check.check_key, enabled }]) - }} - /> - - - { - const val = Number((e.target as HTMLInputElement).value) - if (!Number.isFinite(val) || val <= 0) return - await patchChecks.mutateAsync([{ check_key: check.check_key, interval_sec_default: val }]) - }} - /> - - - { - await patchObjectPolicy.mutateAsync([{ object_id: row.id, mode }]) - }} - /> - ), - }, - ]} - /> - - - - - ({ value: c.check_key, label: c.check_key }))} - /> - setOverrideCategoriesMode(v as OverrideCategoriesMode)} - options={[ - { value: 'inherit', label: 'inherit' }, - { value: 'custom', label: 'custom' }, - ]} - /> - ({ value: c, label: c }))} + onChange={async (categories) => { + await patchChecks.mutateAsync([{ check_key: check.check_key, categories }]) + }} + /> + + + ))} + + + + + + {(tags ?? []).map((tag) => ( + + + {tag.name} + { + await patchTagPolicy.mutateAsync([{ tag_id: tag.id, enabled }]) + }} + /> + + + ))} + + + + + ( + ({ value: o.id, label: o.name }))} + /> + setOverrideEnabledMode(v as OverrideEnabledMode)} + options={[ + { value: 'inherit', label: 'inherit' }, + { value: 'enabled', label: 'enabled' }, + { value: 'disabled', label: 'disabled' }, + ]} + /> + setOverrideInterval(typeof v === 'number' ? v : undefined)} + /> + ({ value: c, label: c }))} + /> + + + + + + +
`${row.object_id}:${row.check_key}`} + pagination={{ pageSize: 6 }} + dataSource={checksData?.object_overrides ?? []} + columns={[ + { + title: '', + key: 'object', + render: (_: unknown, row: DashboardObjectOverride) => + objects?.find((o) => o.id === row.object_id)?.name ?? row.object_id, + }, + { title: '', dataIndex: 'check_key', key: 'check_key' }, + { + title: 'Enabled', + key: 'enabled_override', + render: (_: unknown, row: DashboardObjectOverride) => + row.enabled_override == null ? 'inherit' : row.enabled_override ? 'enabled' : 'disabled', + }, + { + title: '', + key: 'interval_sec_override', + render: (_: unknown, row: DashboardObjectOverride) => + row.interval_sec_override == null ? 'inherit' : `${row.interval_sec_override}s`, + }, + { + title: '', + key: 'categories_override', + render: (_: unknown, row: DashboardObjectOverride) => + row.categories_override == null + ? 'inherit' + : row.categories_override.length === 0 + ? 'all' + : row.categories_override.join(', '), + }, + ]} + /> + + + ) +}