перепись дешборда
This commit is contained in:
parent
e613561b45
commit
3feb05ce0f
12 changed files with 1798 additions and 485 deletions
57
backend/alembic/versions/0017_dashboard_check_history.py
Normal file
57
backend/alembic/versions/0017_dashboard_check_history.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
95
backend/tests/test_dashboard_monitor_service.py
Normal file
95
backend/tests/test_dashboard_monitor_service.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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() {
|
|||
>
|
||||
<Route index element={<Navigate to="/home" replace />} />
|
||||
<Route path="home" element={<HomePage />} />
|
||||
<Route path="home/settings" element={<HomeSettingsPage />} />
|
||||
<Route path="home/objects/:id" element={<HomeObjectDetailPage />} />
|
||||
|
||||
{/* Инвентарь */}
|
||||
<Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} />
|
||||
|
|
|
|||
|
|
@ -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<string, DashboardObjectCheckSummary>
|
||||
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<string, number>
|
||||
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<DashboardObjectSummaryResponse>(`/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<DashboardObjectDevicesResponse>(`/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<DashboardObjectAvailabilityResponse>(`/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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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<number | undefined>()
|
||||
const [overrideCheckKey, setOverrideCheckKey] = useState<string | undefined>()
|
||||
const [overrideInterval, setOverrideInterval] = useState<number | undefined>()
|
||||
const [overrideEnabledMode, setOverrideEnabledMode] = useState<OverrideEnabledMode>('inherit')
|
||||
const [overrideCategoriesMode, setOverrideCategoriesMode] = useState<OverrideCategoriesMode>('inherit')
|
||||
const [overrideCategories, setOverrideCategories] = useState<string[]>([])
|
||||
|
||||
const tagPolicyMap = useMemo(() => {
|
||||
const map = new Map<number, boolean>()
|
||||
for (const row of policy?.tag_policies ?? []) map.set(row.tag_id, row.enabled)
|
||||
return map
|
||||
}, [policy])
|
||||
|
||||
const objectPolicyMap = useMemo(() => {
|
||||
const map = new Map<number, 'inherit' | 'include' | 'exclude'>()
|
||||
for (const row of policy?.object_policies ?? []) map.set(row.object_id, row.mode)
|
||||
return map
|
||||
}, [policy])
|
||||
|
||||
const overrideMap = useMemo(() => {
|
||||
const map = new Map<string, DashboardObjectOverride>()
|
||||
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) => (
|
||||
<Space>
|
||||
<Typography.Text strong>{row.object_name}</Typography.Text>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => navigate(`/home/objects/${row.object_id}${scopeToSearch(scope)}`)}
|
||||
>
|
||||
{row.object_name}
|
||||
</Button>
|
||||
{row.city && <Tag>{row.city}</Tag>}
|
||||
{row.client && <Tag color="blue">{row.client}</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Политика',
|
||||
key: 'policy',
|
||||
render: (_: unknown, row: any) => <Tag color={row.allowed ? 'green' : 'red'}>{row.monitor_mode}</Tag>,
|
||||
title: 'Ïðè÷èíà',
|
||||
key: 'reason',
|
||||
render: (_: unknown, row: any) => <Typography.Text>{reasonLabel(row.inclusion_reason)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Space direction="vertical" size={0}>
|
||||
<span>ok {cnt.ok ?? 0} / warn {cnt.warn ?? 0} / failed {cnt.failed ?? 0}</span>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
age: {formatAge(disk?.max_age_sec)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Health',
|
||||
key: 'health',
|
||||
|
|
@ -230,14 +137,22 @@ export function HomePage() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
Главная
|
||||
</Typography.Title>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }} align="center">
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
NOC Äåøáîðä
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
<Button onClick={() => navigate(`/home/settings${scopeToSearch(scope)}`)}>Íàñòðîéêè ìîíèòîðèíãà</Button>
|
||||
<Button type="primary" onClick={handleRunNow} loading={runNow.isPending}>
|
||||
Çàïóñòèòü ñåé÷àñ
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 12 }}>
|
||||
<Col span={5}>
|
||||
<Select
|
||||
placeholder="Город"
|
||||
placeholder="Ãîðîä"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))}
|
||||
|
|
@ -247,7 +162,7 @@ export function HomePage() {
|
|||
</Col>
|
||||
<Col span={5}>
|
||||
<Select
|
||||
placeholder="Клиент"
|
||||
placeholder="Êëèåíò"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))}
|
||||
|
|
@ -258,7 +173,7 @@ export function HomePage() {
|
|||
<Col span={7}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Объекты"
|
||||
placeholder="Îáúåêòû"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
value={objectIds}
|
||||
|
|
@ -269,7 +184,7 @@ export function HomePage() {
|
|||
<Col span={7}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Теги"
|
||||
placeholder="Òåãè"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
value={tagIds}
|
||||
|
|
@ -280,16 +195,11 @@ export function HomePage() {
|
|||
</Row>
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Button type="primary" onClick={handleRunNow} loading={runNow.isPending}>
|
||||
Запустить сейчас
|
||||
</Button>
|
||||
</Col>
|
||||
<Col>
|
||||
<Typography.Text type="secondary">
|
||||
Статус: {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')}`
|
||||
: ''}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
|
|
@ -300,270 +210,57 @@ export function HomePage() {
|
|||
)}
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card><Statistic title="Объекты" value={totals.objects} /></Card>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="Îáúåêòû" value={totals.objects} /></Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card><Statistic title="В мониторинге" value={totals.allowed_objects} /></Card>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="Â ìîíèòîðèíãå" value={summary?.included_count ?? totals.allowed_objects} /></Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card><Statistic title="Устройства" value={totals.devices} /></Card>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="Âíå ìîíèòîðèíãà" value={summary?.excluded_count ?? totals.excluded_objects} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="Óñòðîéñòâà" value={totals.devices} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="Расписание проверок" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{(checksData?.checks ?? []).map((check) => (
|
||||
<Row key={check.check_key} gutter={12} align="middle">
|
||||
<Col span={4}><Typography.Text strong>{check.check_key}</Typography.Text></Col>
|
||||
<Col span={3}>
|
||||
<Switch
|
||||
checked={check.enabled}
|
||||
onChange={async (enabled) => {
|
||||
await patchChecks.mutateAsync([{ check_key: check.check_key, enabled }])
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<InputNumber
|
||||
min={30}
|
||||
value={check.interval_sec_default}
|
||||
addonAfter="sec"
|
||||
onBlur={async (e) => {
|
||||
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 }])
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
value={check.categories}
|
||||
options={CATEGORY_OPTIONS.map((c) => ({ value: c, label: c }))}
|
||||
onChange={async (categories) => {
|
||||
await patchChecks.mutateAsync([{ check_key: check.check_key, categories }])
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="Политика по тегам" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[12, 8]}>
|
||||
{(tags ?? []).map((tag) => (
|
||||
<Col span={8} key={tag.id}>
|
||||
<Space>
|
||||
<Tag color={tag.color}>{tag.name}</Tag>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={tagPolicyMap.get(tag.id) ?? true}
|
||||
onChange={async (enabled) => {
|
||||
await patchTagPolicy.mutateAsync([{ tag_id: tag.id, enabled }])
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card title="Политика объектов и overrides" style={{ marginBottom: 16 }}>
|
||||
<Card title="Ïðîáëåìíûå îáúåêòû" style={{ marginBottom: 16 }}>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 8 }}
|
||||
dataSource={objects ?? []}
|
||||
columns={[
|
||||
{ title: 'Объект', dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: 'Режим',
|
||||
key: 'mode',
|
||||
render: (_: unknown, row: any) => (
|
||||
<Select
|
||||
value={objectPolicyMap.get(row.id) ?? 'inherit'}
|
||||
style={{ width: 150 }}
|
||||
options={[
|
||||
{ value: 'inherit', label: 'inherit' },
|
||||
{ value: 'include', label: 'include' },
|
||||
{ value: 'exclude', label: 'exclude' },
|
||||
]}
|
||||
onChange={async (mode) => {
|
||||
await patchObjectPolicy.mutateAsync([{ object_id: row.id, mode }])
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<Space wrap>
|
||||
<Select
|
||||
placeholder="Объект"
|
||||
style={{ width: 220 }}
|
||||
value={overrideObjectId}
|
||||
onChange={setOverrideObjectId}
|
||||
options={(objects ?? []).map((o) => ({ value: o.id, label: o.name }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Проверка"
|
||||
style={{ width: 160 }}
|
||||
value={overrideCheckKey}
|
||||
onChange={setOverrideCheckKey}
|
||||
options={(checksData?.checks ?? []).map((c) => ({ value: c.check_key, label: c.check_key }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Enabled override"
|
||||
style={{ width: 170 }}
|
||||
value={overrideEnabledMode}
|
||||
onChange={(v) => setOverrideEnabledMode(v as OverrideEnabledMode)}
|
||||
options={[
|
||||
{ value: 'inherit', label: 'inherit' },
|
||||
{ value: 'enabled', label: 'enabled' },
|
||||
{ value: 'disabled', label: 'disabled' },
|
||||
]}
|
||||
/>
|
||||
<InputNumber
|
||||
placeholder="Интервал (сек)"
|
||||
min={30}
|
||||
value={overrideInterval}
|
||||
onChange={(v) => setOverrideInterval(typeof v === 'number' ? v : undefined)}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Режим категорий"
|
||||
style={{ width: 170 }}
|
||||
value={overrideCategoriesMode}
|
||||
onChange={(v) => setOverrideCategoriesMode(v as OverrideCategoriesMode)}
|
||||
options={[
|
||||
{ value: 'inherit', label: 'inherit' },
|
||||
{ value: 'custom', label: 'custom' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
disabled={overrideCategoriesMode !== 'custom'}
|
||||
placeholder="Категории override"
|
||||
style={{ width: 280 }}
|
||||
value={overrideCategories}
|
||||
onChange={setOverrideCategories}
|
||||
options={CATEGORY_OPTIONS.map((c) => ({ value: c, label: c }))}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={async () => {
|
||||
if (!overrideObjectId || !overrideCheckKey) {
|
||||
message.warning('Выберите объект и проверку')
|
||||
return
|
||||
}
|
||||
await patchOverrides.mutateAsync([
|
||||
{
|
||||
object_id: overrideObjectId,
|
||||
check_key: overrideCheckKey,
|
||||
enabled_override:
|
||||
overrideEnabledMode === 'inherit'
|
||||
? null
|
||||
: overrideEnabledMode === 'enabled',
|
||||
interval_sec_override: overrideInterval ?? null,
|
||||
categories_override:
|
||||
overrideCategoriesMode === 'custom'
|
||||
? overrideCategories
|
||||
: null,
|
||||
},
|
||||
])
|
||||
message.success('Override обновлен')
|
||||
}}
|
||||
>
|
||||
Сохранить override
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!overrideObjectId || !overrideCheckKey) {
|
||||
message.warning('Выберите объект и проверку')
|
||||
return
|
||||
}
|
||||
await patchOverrides.mutateAsync([
|
||||
{
|
||||
object_id: overrideObjectId,
|
||||
check_key: overrideCheckKey,
|
||||
enabled_override: null,
|
||||
interval_sec_override: null,
|
||||
categories_override: null,
|
||||
},
|
||||
])
|
||||
message.success('Override очищен')
|
||||
}}
|
||||
>
|
||||
Очистить override
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(row) => `${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(', '),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Проблемные объекты" style={{ marginBottom: 16 }}>
|
||||
<Table
|
||||
size="small"
|
||||
dataSource={problemObjects}
|
||||
dataSource={summary?.problem_objects ?? []}
|
||||
rowKey="object_id"
|
||||
columns={summaryColumns}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Сводка по объектам">
|
||||
<Table
|
||||
loading={isLoading}
|
||||
dataSource={summary?.objects ?? []}
|
||||
rowKey="object_id"
|
||||
columns={summaryColumns}
|
||||
pagination={{ pageSize: 12 }}
|
||||
/>
|
||||
</Card>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Card title="Â ìîíèòîðèíãå" style={{ marginBottom: 16 }}>
|
||||
<Table
|
||||
size="small"
|
||||
dataSource={summary?.included_objects ?? []}
|
||||
rowKey="object_id"
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 8 }}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="Âíå ìîíèòîðèíãà" style={{ marginBottom: 16 }}>
|
||||
<Table
|
||||
size="small"
|
||||
dataSource={summary?.excluded_objects ?? []}
|
||||
rowKey="object_id"
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 8 }}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
251
frontend/src/pages/HomeObjectDetail.tsx
Normal file
251
frontend/src/pages/HomeObjectDetail.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Card, Col, Row, Select, Space, Table, Tag, Typography } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
useDashboardObjectAvailability,
|
||||
useDashboardObjectDevices,
|
||||
useDashboardObjectSummary,
|
||||
type DashboardAvailabilityPoint,
|
||||
type DashboardAvailabilityWindow,
|
||||
type DashboardScope,
|
||||
} from '../api/dashboard'
|
||||
import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
function reasonLabel(reason: string): string {
|
||||
const map: Record<string, string> = {
|
||||
object_include_policy: 'Âêëþ÷åí ïîëèòèêîé îáúåêòà',
|
||||
object_exclude_policy: 'Èñêëþ÷åí ïîëèòèêîé îáúåêòà',
|
||||
inherit_default: 'Íàñëåäîâàíèå ïî óìîë÷àíèþ',
|
||||
tag_policy_enabled: 'Âêëþ÷åí ïîëèòèêîé òåãîâ',
|
||||
tag_policy_disabled: 'Èñêëþ÷åí ïîëèòèêîé òåãîâ',
|
||||
scope_filtered: 'Âíå òåêóùåãî scope',
|
||||
}
|
||||
return map[reason] ?? reason
|
||||
}
|
||||
|
||||
function parseScope(params: URLSearchParams): DashboardScope {
|
||||
const city = params.get('city') ?? undefined
|
||||
const client = params.get('client') ?? undefined
|
||||
const object_ids = params.getAll('object_ids').map(Number).filter((v) => Number.isFinite(v))
|
||||
const tag_ids = params.getAll('tag_ids').map(Number).filter((v) => Number.isFinite(v))
|
||||
return { city, client, object_ids, tag_ids }
|
||||
}
|
||||
|
||||
function AvailabilityChart({ points }: { points: DashboardAvailabilityPoint[] }) {
|
||||
const viewW = 800
|
||||
const viewH = 180
|
||||
const pad = 20
|
||||
|
||||
const coords = useMemo(() => {
|
||||
if (!points.length) return ''
|
||||
return points
|
||||
.map((p, idx) => {
|
||||
const x = pad + (idx / Math.max(1, points.length - 1)) * (viewW - pad * 2)
|
||||
const val = p.availability_pct ?? 0
|
||||
const y = pad + ((100 - val) / 100) * (viewH - pad * 2)
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(' ')
|
||||
}, [points])
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${viewW} ${viewH}`} style={{ width: '100%', height: 220 }}>
|
||||
<rect x={0} y={0} width={viewW} height={viewH} fill="#0f1720" rx={12} />
|
||||
{[0, 25, 50, 75, 100].map((v) => {
|
||||
const y = pad + ((100 - v) / 100) * (viewH - pad * 2)
|
||||
return (
|
||||
<g key={v}>
|
||||
<line x1={pad} y1={y} x2={viewW - pad} y2={y} stroke="#2b394b" strokeWidth={1} />
|
||||
<text x={6} y={y + 4} fill="#98aac1" fontSize={10}>{v}%</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{coords && <polyline fill="none" stroke="#37c47a" strokeWidth={2.5} points={coords} />}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeObjectDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const objectId = Number(id)
|
||||
const [search] = useSearchParams()
|
||||
const scope = useMemo(() => parseScope(search), [search])
|
||||
const [window, setWindow] = useState<DashboardAvailabilityWindow>('24h')
|
||||
|
||||
const { data: summary, isLoading: summaryLoading } = useDashboardObjectSummary(objectId, scope)
|
||||
const { data: devices, isLoading: devicesLoading } = useDashboardObjectDevices(objectId, scope)
|
||||
const { data: availability, isLoading: availLoading } = useDashboardObjectAvailability(objectId, window)
|
||||
|
||||
useDashboardMonitorSSE({
|
||||
onUpdate: () => {
|
||||
qc.invalidateQueries({ queryKey: ['dashboard-object-summary', objectId] })
|
||||
qc.invalidateQueries({ queryKey: ['dashboard-object-devices', objectId] })
|
||||
qc.invalidateQueries({ queryKey: ['dashboard-object-availability', objectId] })
|
||||
},
|
||||
})
|
||||
|
||||
const object = summary?.object
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }} align="center">
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/home')}>
|
||||
Íàçàä
|
||||
</Button>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{object?.object_name ?? `Îáúåêò #${objectId}`}
|
||||
</Typography.Title>
|
||||
</Space>
|
||||
<Space>
|
||||
{object?.allowed ? <Tag color="green">Â ìîíèòîðèíãå</Tag> : <Tag color="red">Âíå ìîíèòîðèíãà</Tag>}
|
||||
{object?.health_score != null && (
|
||||
<Tag color={object.health_score >= 80 ? 'green' : object.health_score >= 60 ? 'orange' : 'red'}>
|
||||
Health {object.health_score}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{object && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
{reasonLabel(object.inclusion_reason)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
{availability?.insufficient_data && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="Íåäîñòàòî÷íî äàííûõ äëÿ ãðàôèêà äîñòóïíîñòè â âûáðàííîì îêíå"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card loading={summaryLoading}>
|
||||
<Typography.Text type="secondary">Óñòðîéñòâ</Typography.Text>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>{summary?.totals.devices ?? 0}</Typography.Title>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card loading={summaryLoading}>
|
||||
<Typography.Text type="secondary">Ïðîáëåìíûõ</Typography.Text>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>{summary?.totals.problem_devices ?? 0}</Typography.Title>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card loading={availLoading}>
|
||||
<Typography.Text type="secondary">Äîñòóïíîñòü ({window})</Typography.Text>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
{availability?.overall_availability_pct != null ? `${availability.overall_availability_pct}%` : '—'}
|
||||
</Typography.Title>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="Ãðàôèê äîñòóïíîñòè ïî Ping"
|
||||
extra={
|
||||
<Select
|
||||
value={window}
|
||||
onChange={(v) => setWindow(v as DashboardAvailabilityWindow)}
|
||||
options={[
|
||||
{ value: '24h', label: '24÷' },
|
||||
{ value: '3d', label: '3ä' },
|
||||
{ value: '7d', label: '7ä' },
|
||||
]}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<AvailabilityChart points={availability?.overall_series ?? []} />
|
||||
</Card>
|
||||
|
||||
<Card title="Ñòàòóñ óñòðîéñòâ" style={{ marginBottom: 16 }}>
|
||||
<Table
|
||||
loading={devicesLoading}
|
||||
dataSource={devices?.devices ?? []}
|
||||
rowKey="device_id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 20 }}
|
||||
columns={[
|
||||
{
|
||||
title: 'IP / Hostname',
|
||||
key: 'identity',
|
||||
render: (_: unknown, row: any) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<code>{row.ip}</code>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.hostname ?? '—'}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: 'Êàòåãîðèÿ', dataIndex: 'category', key: 'category' },
|
||||
{
|
||||
title: 'Ping',
|
||||
key: 'ping',
|
||||
render: (_: unknown, row: any) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color={row.ping_status === 'online' ? 'green' : row.ping_status === 'offline' ? 'red' : 'default'}>
|
||||
{row.ping_status}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.ping_checked_at ? dayjs(row.ping_checked_at).format('DD.MM HH:mm:ss') : '—'}
|
||||
{row.last_ping_ms != null ? ` · ${row.last_ping_ms}ms` : ''}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Disk',
|
||||
key: 'disk',
|
||||
render: (_: unknown, row: any) =>
|
||||
row.disk_status ? (
|
||||
<Tag color={row.disk_status === 'ok' ? 'green' : row.disk_status === 'warn' ? 'orange' : 'red'}>
|
||||
{row.disk_status}{row.disk_max_pct != null ? ` (${row.disk_max_pct}%)` : ''}
|
||||
</Tag>
|
||||
) : '—',
|
||||
},
|
||||
{
|
||||
title: 'Ïðîáëåìà',
|
||||
key: 'problem',
|
||||
render: (_: unknown, row: any) => row.has_problem ? <Tag color="red">Äà</Tag> : <Tag color="green">Íåò</Tag>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Per-device äîñòóïíîñòü (âûáîðêà)">
|
||||
<Table
|
||||
loading={availLoading}
|
||||
dataSource={availability?.devices ?? []}
|
||||
rowKey="device_id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{ title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> },
|
||||
{ title: 'Hostname', dataIndex: 'hostname', key: 'hostname', render: (v: string | null) => v ?? '—' },
|
||||
{
|
||||
title: 'Availability',
|
||||
key: 'availability_pct',
|
||||
render: (_: unknown, row: any) => row.availability_pct != null ? `${row.availability_pct}%` : '—',
|
||||
},
|
||||
{ title: 'Samples', dataIndex: 'samples', key: 'samples' },
|
||||
{
|
||||
title: 'Ïîñëåäíèé ñòàòóñ',
|
||||
key: 'last_status',
|
||||
render: (_: unknown, row: any) => row.last_status ? <Tag>{row.last_status}</Tag> : '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
381
frontend/src/pages/HomeSettings.tsx
Normal file
381
frontend/src/pages/HomeSettings.tsx
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Card, Col, Divider, InputNumber, Row, Select, Space, Switch, Table, Tag, Typography, message } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useObjects } from '../api/objects'
|
||||
import {
|
||||
useDashboardChecks,
|
||||
useDashboardMonitorStatus,
|
||||
useDashboardPolicy,
|
||||
useDashboardRunNow,
|
||||
usePatchDashboardChecks,
|
||||
usePatchDashboardObjectOverrides,
|
||||
usePatchDashboardObjectPolicy,
|
||||
usePatchDashboardTagPolicy,
|
||||
type DashboardObjectOverride,
|
||||
} from '../api/dashboard'
|
||||
import { useTags } from '../api/tags'
|
||||
|
||||
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'
|
||||
|
||||
export function HomeSettingsPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: objects } = useObjects()
|
||||
const { data: tags } = useTags()
|
||||
const { data: policy } = useDashboardPolicy()
|
||||
const { data: checksData } = useDashboardChecks()
|
||||
const { data: monitorStatus } = useDashboardMonitorStatus()
|
||||
|
||||
const runNow = useDashboardRunNow()
|
||||
const patchTagPolicy = usePatchDashboardTagPolicy()
|
||||
const patchObjectPolicy = usePatchDashboardObjectPolicy()
|
||||
const patchChecks = usePatchDashboardChecks()
|
||||
const patchOverrides = usePatchDashboardObjectOverrides()
|
||||
|
||||
const [overrideObjectId, setOverrideObjectId] = useState<number | undefined>()
|
||||
const [overrideCheckKey, setOverrideCheckKey] = useState<string | undefined>()
|
||||
const [overrideInterval, setOverrideInterval] = useState<number | undefined>()
|
||||
const [overrideEnabledMode, setOverrideEnabledMode] = useState<OverrideEnabledMode>('inherit')
|
||||
const [overrideCategoriesMode, setOverrideCategoriesMode] = useState<OverrideCategoriesMode>('inherit')
|
||||
const [overrideCategories, setOverrideCategories] = useState<string[]>([])
|
||||
|
||||
const tagPolicyMap = useMemo(() => {
|
||||
const map = new Map<number, boolean>()
|
||||
for (const row of policy?.tag_policies ?? []) map.set(row.tag_id, row.enabled)
|
||||
return map
|
||||
}, [policy])
|
||||
|
||||
const objectPolicyMap = useMemo(() => {
|
||||
const map = new Map<number, 'inherit' | 'include' | 'exclude'>()
|
||||
for (const row of policy?.object_policies ?? []) map.set(row.object_id, row.mode)
|
||||
return map
|
||||
}, [policy])
|
||||
|
||||
const overrideMap = useMemo(() => {
|
||||
const map = new Map<string, DashboardObjectOverride>()
|
||||
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])
|
||||
|
||||
const handleRunNow = async () => {
|
||||
try {
|
||||
await runNow.mutateAsync({ force: true })
|
||||
message.success('Ìîíèòîðèíã çàïóùåí')
|
||||
} catch {
|
||||
message.error('Íå óäàëîñü çàïóñòèòü ìîíèòîðèíã')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }} align="center">
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/home')}>
|
||||
Íàçàä
|
||||
</Button>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
Íàñòðîéêè ìîíèòîðèíãà
|
||||
</Typography.Title>
|
||||
</Space>
|
||||
<Button type="primary" onClick={handleRunNow} loading={runNow.isPending}>
|
||||
Çàïóñòèòü ñåé÷àñ
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
Ñòàòóñ: {monitorStatus?.status ?? '-'}
|
||||
{monitorStatus?.last_success_at
|
||||
? ` · óñïåøíûé çàïóñê ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}`
|
||||
: ''}
|
||||
</Typography.Text>
|
||||
|
||||
{monitorStatus?.last_error && (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 12 }} message={monitorStatus.last_error} />
|
||||
)}
|
||||
|
||||
<Card title="Ðàñïèñàíèå ïðîâåðîê" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{(checksData?.checks ?? []).map((check) => (
|
||||
<Row key={check.check_key} gutter={12} align="middle">
|
||||
<Col span={4}><Typography.Text strong>{check.check_key}</Typography.Text></Col>
|
||||
<Col span={3}>
|
||||
<Switch
|
||||
checked={check.enabled}
|
||||
onChange={async (enabled) => {
|
||||
await patchChecks.mutateAsync([{ check_key: check.check_key, enabled }])
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<InputNumber
|
||||
min={30}
|
||||
value={check.interval_sec_default}
|
||||
addonAfter="sec"
|
||||
onBlur={async (e) => {
|
||||
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 }])
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
value={check.categories}
|
||||
options={CATEGORY_OPTIONS.map((c) => ({ value: c, label: c }))}
|
||||
onChange={async (categories) => {
|
||||
await patchChecks.mutateAsync([{ check_key: check.check_key, categories }])
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="Ïîëèòèêà ïî òåãàì" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[12, 8]}>
|
||||
{(tags ?? []).map((tag) => (
|
||||
<Col span={8} key={tag.id}>
|
||||
<Space>
|
||||
<Tag color={tag.color}>{tag.name}</Tag>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={tagPolicyMap.get(tag.id) ?? true}
|
||||
onChange={async (enabled) => {
|
||||
await patchTagPolicy.mutateAsync([{ tag_id: tag.id, enabled }])
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card title="Ïîëèòèêà îáúåêòîâ è overrides" style={{ marginBottom: 16 }}>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 8 }}
|
||||
dataSource={objects ?? []}
|
||||
columns={[
|
||||
{ title: 'Îáúåêò', dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: 'Ðåæèì',
|
||||
key: 'mode',
|
||||
render: (_: unknown, row: any) => (
|
||||
<Select
|
||||
value={objectPolicyMap.get(row.id) ?? 'inherit'}
|
||||
style={{ width: 150 }}
|
||||
options={[
|
||||
{ value: 'inherit', label: 'inherit' },
|
||||
{ value: 'include', label: 'include' },
|
||||
{ value: 'exclude', label: 'exclude' },
|
||||
]}
|
||||
onChange={async (mode) => {
|
||||
await patchObjectPolicy.mutateAsync([{ object_id: row.id, mode }])
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<Space wrap>
|
||||
<Select
|
||||
placeholder="Îáúåêò"
|
||||
style={{ width: 220 }}
|
||||
value={overrideObjectId}
|
||||
onChange={setOverrideObjectId}
|
||||
options={(objects ?? []).map((o) => ({ value: o.id, label: o.name }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Ïðîâåðêà"
|
||||
style={{ width: 160 }}
|
||||
value={overrideCheckKey}
|
||||
onChange={setOverrideCheckKey}
|
||||
options={(checksData?.checks ?? []).map((c) => ({ value: c.check_key, label: c.check_key }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Enabled override"
|
||||
style={{ width: 170 }}
|
||||
value={overrideEnabledMode}
|
||||
onChange={(v) => setOverrideEnabledMode(v as OverrideEnabledMode)}
|
||||
options={[
|
||||
{ value: 'inherit', label: 'inherit' },
|
||||
{ value: 'enabled', label: 'enabled' },
|
||||
{ value: 'disabled', label: 'disabled' },
|
||||
]}
|
||||
/>
|
||||
<InputNumber
|
||||
placeholder="Èíòåðâàë (ñåê)"
|
||||
min={30}
|
||||
value={overrideInterval}
|
||||
onChange={(v) => setOverrideInterval(typeof v === 'number' ? v : undefined)}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Ðåæèì êàòåãîðèé"
|
||||
style={{ width: 170 }}
|
||||
value={overrideCategoriesMode}
|
||||
onChange={(v) => setOverrideCategoriesMode(v as OverrideCategoriesMode)}
|
||||
options={[
|
||||
{ value: 'inherit', label: 'inherit' },
|
||||
{ value: 'custom', label: 'custom' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
disabled={overrideCategoriesMode !== 'custom'}
|
||||
placeholder="Êàòåãîðèè override"
|
||||
style={{ width: 280 }}
|
||||
value={overrideCategories}
|
||||
onChange={setOverrideCategories}
|
||||
options={CATEGORY_OPTIONS.map((c) => ({ value: c, label: c }))}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={async () => {
|
||||
if (!overrideObjectId || !overrideCheckKey) {
|
||||
message.warning('Âûáåðèòå îáúåêò è ïðîâåðêó')
|
||||
return
|
||||
}
|
||||
await patchOverrides.mutateAsync([
|
||||
{
|
||||
object_id: overrideObjectId,
|
||||
check_key: overrideCheckKey,
|
||||
enabled_override:
|
||||
overrideEnabledMode === 'inherit'
|
||||
? null
|
||||
: overrideEnabledMode === 'enabled',
|
||||
interval_sec_override: overrideInterval ?? null,
|
||||
categories_override:
|
||||
overrideCategoriesMode === 'custom'
|
||||
? overrideCategories
|
||||
: null,
|
||||
},
|
||||
])
|
||||
message.success('Override îáíîâëåí')
|
||||
}}
|
||||
>
|
||||
Ñîõðàíèòü override
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!overrideObjectId || !overrideCheckKey) {
|
||||
message.warning('Âûáåðèòå îáúåêò è ïðîâåðêó')
|
||||
return
|
||||
}
|
||||
await patchOverrides.mutateAsync([
|
||||
{
|
||||
object_id: overrideObjectId,
|
||||
check_key: overrideCheckKey,
|
||||
enabled_override: null,
|
||||
interval_sec_override: null,
|
||||
categories_override: null,
|
||||
},
|
||||
])
|
||||
message.success('Override î÷èùåí')
|
||||
}}
|
||||
>
|
||||
Î÷èñòèòü override
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(row) => `${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(', '),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue