перепись дешборда
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,
|
DashboardCheckDefinition,
|
||||||
DashboardObjectCheckOverride,
|
DashboardObjectCheckOverride,
|
||||||
DashboardDeviceLiveState,
|
DashboardDeviceLiveState,
|
||||||
|
DashboardDeviceCheckHistory,
|
||||||
DashboardObjectMonitorPolicy,
|
DashboardObjectMonitorPolicy,
|
||||||
DashboardTagMonitorPolicy,
|
DashboardTagMonitorPolicy,
|
||||||
DashboardMonitorRunState,
|
DashboardMonitorRunState,
|
||||||
|
|
@ -44,6 +45,7 @@ __all__ = [
|
||||||
"DashboardCheckDefinition",
|
"DashboardCheckDefinition",
|
||||||
"DashboardObjectCheckOverride",
|
"DashboardObjectCheckOverride",
|
||||||
"DashboardDeviceLiveState",
|
"DashboardDeviceLiveState",
|
||||||
|
"DashboardDeviceCheckHistory",
|
||||||
"DashboardObjectMonitorPolicy",
|
"DashboardObjectMonitorPolicy",
|
||||||
"DashboardTagMonitorPolicy",
|
"DashboardTagMonitorPolicy",
|
||||||
"DashboardMonitorRunState",
|
"DashboardMonitorRunState",
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,24 @@ class DashboardDeviceLiveState(Base):
|
||||||
source: Mapped[str] = mapped_column(String(32), nullable=False, default="dashboard_monitor")
|
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):
|
class DashboardObjectMonitorPolicy(Base, TimestampMixin):
|
||||||
__tablename__ = "dashboard_object_monitor_policy"
|
__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.dependencies import get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
@ -7,8 +7,11 @@ from app.schemas.dashboard import (
|
||||||
DashboardChecksResponse,
|
DashboardChecksResponse,
|
||||||
DashboardHomeSummaryResponse,
|
DashboardHomeSummaryResponse,
|
||||||
DashboardMonitorStatus,
|
DashboardMonitorStatus,
|
||||||
|
DashboardObjectAvailabilityResponse,
|
||||||
|
DashboardObjectDevicesResponse,
|
||||||
DashboardObjectOverridePatchItem,
|
DashboardObjectOverridePatchItem,
|
||||||
DashboardObjectPolicyPatchItem,
|
DashboardObjectPolicyPatchItem,
|
||||||
|
DashboardObjectSummaryResponse,
|
||||||
DashboardPolicyResponse,
|
DashboardPolicyResponse,
|
||||||
DashboardRunRequest,
|
DashboardRunRequest,
|
||||||
DashboardRunResponse,
|
DashboardRunResponse,
|
||||||
|
|
@ -37,6 +40,65 @@ async def home_summary(
|
||||||
return DashboardHomeSummaryResponse.model_validate(data)
|
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)
|
@router.get("/monitor/status", response_model=DashboardMonitorStatus)
|
||||||
async def monitor_status(_: User = Depends(get_current_user)):
|
async def monitor_status(_: User = Depends(get_current_user)):
|
||||||
row = await dashboard_service.get_monitor_status()
|
row = await dashboard_service.get_monitor_status()
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
MonitorPolicyMode = Literal["inherit", "include", "exclude"]
|
MonitorPolicyMode = Literal["inherit", "include", "exclude"]
|
||||||
|
AvailabilityWindow = Literal["24h", "3d", "7d"]
|
||||||
|
|
||||||
|
|
||||||
class DashboardScope(BaseModel):
|
class DashboardScope(BaseModel):
|
||||||
|
|
@ -127,6 +128,7 @@ class DashboardObjectSummary(BaseModel):
|
||||||
client: Optional[str] = None
|
client: Optional[str] = None
|
||||||
allowed: bool
|
allowed: bool
|
||||||
monitor_mode: MonitorPolicyMode
|
monitor_mode: MonitorPolicyMode
|
||||||
|
inclusion_reason: str
|
||||||
device_count: int
|
device_count: int
|
||||||
checks: dict[str, DashboardObjectCheckSummary]
|
checks: dict[str, DashboardObjectCheckSummary]
|
||||||
health_score: Optional[float] = None
|
health_score: Optional[float] = None
|
||||||
|
|
@ -139,3 +141,87 @@ class DashboardHomeSummaryResponse(BaseModel):
|
||||||
checks_meta: list[DashboardCheckDefinitionRead]
|
checks_meta: list[DashboardCheckDefinitionRead]
|
||||||
objects: list[DashboardObjectSummary]
|
objects: list[DashboardObjectSummary]
|
||||||
problem_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 asyncio
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
@ -9,6 +10,7 @@ from sqlalchemy.orm import selectinload
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models.dashboard import (
|
from app.models.dashboard import (
|
||||||
DashboardCheckDefinition,
|
DashboardCheckDefinition,
|
||||||
|
DashboardDeviceCheckHistory,
|
||||||
DashboardDeviceLiveState,
|
DashboardDeviceLiveState,
|
||||||
DashboardMonitorRunState,
|
DashboardMonitorRunState,
|
||||||
DashboardObjectCheckOverride,
|
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()
|
_RUN_LOCK = asyncio.Lock()
|
||||||
_MANUAL_TASK: asyncio.Task | None = None
|
_MANUAL_TASK: asyncio.Task | None = None
|
||||||
|
|
||||||
|
|
@ -53,6 +67,47 @@ async def _noop_emit(_: str, __: str = "info") -> None:
|
||||||
return
|
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 def ensure_dashboard_bootstrap() -> None:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
|
|
@ -68,8 +123,6 @@ async def ensure_dashboard_bootstrap() -> None:
|
||||||
db.add(DashboardCheckDefinition(**payload))
|
db.add(DashboardCheckDefinition(**payload))
|
||||||
continue
|
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":
|
if row.check_key == "disk_usage" and row.plugin_action == "debian_system_info":
|
||||||
row.plugin_action = "disk_usage"
|
row.plugin_action = "disk_usage"
|
||||||
if not row.plugin_action:
|
if not row.plugin_action:
|
||||||
|
|
@ -94,17 +147,6 @@ async def _get_run_state(db) -> DashboardMonitorRunState:
|
||||||
return row
|
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 def get_monitor_status() -> DashboardMonitorRunState:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
return await _get_run_state(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())
|
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(
|
def _is_object_allowed(
|
||||||
obj: Object,
|
obj: Object,
|
||||||
object_modes: dict[int, str],
|
object_modes: dict[int, str],
|
||||||
tag_modes: dict[int, bool],
|
tag_modes: dict[int, bool],
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str, str]:
|
||||||
mode = object_modes.get(obj.id, "inherit")
|
mode = object_modes.get(obj.id, "inherit")
|
||||||
if mode == "include":
|
if mode == "include":
|
||||||
return True, mode
|
return True, mode, "object_include_policy"
|
||||||
if mode == "exclude":
|
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]
|
tag_values = [tag_modes[t.id] for t in obj.tags if t.id in tag_modes]
|
||||||
if not tag_values:
|
if not tag_values:
|
||||||
return True, mode
|
return True, mode, "inherit_default"
|
||||||
return any(tag_values), mode
|
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]
|
object_ids = [o.id for o in objects]
|
||||||
if not object_ids:
|
if not object_ids:
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -262,6 +317,40 @@ async def _upsert_live_state(
|
||||||
row.source = "dashboard_monitor"
|
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(
|
async def _run_check_on_device(
|
||||||
sem: asyncio.Semaphore,
|
sem: asyncio.Semaphore,
|
||||||
check: DashboardCheckDefinition,
|
check: DashboardCheckDefinition,
|
||||||
|
|
@ -345,7 +434,7 @@ async def run_dashboard_monitor_cycle(
|
||||||
counts["objects_in_scope"] = len(objects)
|
counts["objects_in_scope"] = len(objects)
|
||||||
object_policy_map = await _build_object_policy_map(db, 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["objects_allowed"] = len(allowed_object_ids)
|
||||||
counts["skipped_policy"] = max(0, len(objects) - 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):
|
if isinstance(result, Exception):
|
||||||
counts["failed"] += 1
|
counts["failed"] += 1
|
||||||
counts["checks_executed"] += 1
|
counts["checks_executed"] += 1
|
||||||
|
checked_at = datetime.now(timezone.utc)
|
||||||
await _upsert_live_state(
|
await _upsert_live_state(
|
||||||
dbw,
|
dbw,
|
||||||
device_id=device.id,
|
device_id=device.id,
|
||||||
|
|
@ -441,7 +531,17 @@ async def run_dashboard_monitor_cycle(
|
||||||
status="failed",
|
status="failed",
|
||||||
value_json=None,
|
value_json=None,
|
||||||
error=str(result),
|
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,
|
duration_ms=0,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
@ -462,11 +562,18 @@ async def run_dashboard_monitor_cycle(
|
||||||
checked_at=checked_at,
|
checked_at=checked_at,
|
||||||
duration_ms=duration_ms,
|
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 (
|
if settings.DASHBOARD_MONITOR_SYNC_DEVICE_STATUS and check.check_key == "ping":
|
||||||
settings.DASHBOARD_MONITOR_SYNC_DEVICE_STATUS
|
|
||||||
and check.check_key == "ping"
|
|
||||||
):
|
|
||||||
device_row = await dbw.get(Device, device.id)
|
device_row = await dbw.get(Device, device.id)
|
||||||
if device_row is not None:
|
if device_row is not None:
|
||||||
device_row.status = "online" if status == "online" else "offline"
|
device_row.status = "online" if status == "online" else "offline"
|
||||||
|
|
@ -692,6 +799,128 @@ def _calc_health(
|
||||||
return round(max(0.0, score), 1)
|
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]:
|
async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
scope = _scope_to_dict(scope)
|
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()
|
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 = []
|
summaries = []
|
||||||
for obj in objects:
|
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, [])
|
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(
|
summaries.append(
|
||||||
{
|
_to_object_summary(
|
||||||
"object_id": obj.id,
|
now=now,
|
||||||
"object_name": obj.name,
|
obj=obj,
|
||||||
"city": obj.city,
|
allowed=allowed,
|
||||||
"client": obj.client,
|
monitor_mode=mode,
|
||||||
"allowed": allowed,
|
inclusion_reason=reason,
|
||||||
"monitor_mode": mode,
|
checks=checks,
|
||||||
"device_count": len(obj_devices),
|
obj_devices=obj_devices,
|
||||||
"checks": check_summary,
|
state_map=state_map,
|
||||||
"health_score": health,
|
)
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
included_objects = [s for s in summaries if s["allowed"]]
|
||||||
|
excluded_objects = [s for s in summaries if not s["allowed"]]
|
||||||
problems = sorted(
|
problems = sorted(
|
||||||
summaries,
|
included_objects,
|
||||||
key=lambda x: (
|
key=lambda x: x["health_score"] if x["health_score"] is not None else 999,
|
||||||
x["allowed"] is False,
|
|
||||||
x["health_score"] if x["health_score"] is not None else 999,
|
|
||||||
),
|
|
||||||
)[:10]
|
)[:10]
|
||||||
|
|
||||||
totals = {
|
totals = {
|
||||||
"objects": len(objects),
|
"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),
|
"devices": len(device_rows),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -801,4 +997,333 @@ async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"checks_meta": checks,
|
"checks_meta": checks,
|
||||||
"objects": summaries,
|
"objects": summaries,
|
||||||
"problem_objects": problems,
|
"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 { AlertsPage } from './pages/Alerts'
|
||||||
import { DeviceDetailPage } from './pages/DeviceDetail'
|
import { DeviceDetailPage } from './pages/DeviceDetail'
|
||||||
import { HomePage } from './pages/Home'
|
import { HomePage } from './pages/Home'
|
||||||
|
import { HomeObjectDetailPage } from './pages/HomeObjectDetail'
|
||||||
|
import { HomeSettingsPage } from './pages/HomeSettings'
|
||||||
import { LoginPage } from './pages/Login'
|
import { LoginPage } from './pages/Login'
|
||||||
import { ObjectsPage } from './pages/Objects'
|
import { ObjectsPage } from './pages/Objects'
|
||||||
import { SnapshotsPage } from './pages/Snapshots'
|
import { SnapshotsPage } from './pages/Snapshots'
|
||||||
|
|
@ -32,6 +34,8 @@ export default function App() {
|
||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/home" replace />} />
|
<Route index element={<Navigate to="/home" replace />} />
|
||||||
<Route path="home" element={<HomePage />} />
|
<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" />} />
|
<Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} />
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ export interface DashboardScope {
|
||||||
tag_ids?: number[]
|
tag_ids?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DashboardAvailabilityWindow = '24h' | '3d' | '7d'
|
||||||
|
|
||||||
export interface DashboardCheckDefinition {
|
export interface DashboardCheckDefinition {
|
||||||
check_key: string
|
check_key: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|
@ -69,6 +71,7 @@ export interface DashboardObjectSummary {
|
||||||
client: string | null
|
client: string | null
|
||||||
allowed: boolean
|
allowed: boolean
|
||||||
monitor_mode: 'inherit' | 'include' | 'exclude'
|
monitor_mode: 'inherit' | 'include' | 'exclude'
|
||||||
|
inclusion_reason: string
|
||||||
device_count: number
|
device_count: number
|
||||||
checks: Record<string, DashboardObjectCheckSummary>
|
checks: Record<string, DashboardObjectCheckSummary>
|
||||||
health_score: number | null
|
health_score: number | null
|
||||||
|
|
@ -81,6 +84,90 @@ export interface DashboardHomeSummary {
|
||||||
checks_meta: DashboardCheckDefinition[]
|
checks_meta: DashboardCheckDefinition[]
|
||||||
objects: DashboardObjectSummary[]
|
objects: DashboardObjectSummary[]
|
||||||
problem_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 {
|
export interface DashboardChecksResponse {
|
||||||
|
|
@ -98,6 +185,45 @@ export const useDashboardHomeSummary = (scope: DashboardScope) =>
|
||||||
refetchInterval: 30_000,
|
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 = () =>
|
export const useDashboardMonitorStatus = () =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['dashboard-monitor-status'],
|
queryKey: ['dashboard-monitor-status'],
|
||||||
|
|
@ -116,6 +242,9 @@ export const useDashboardRunNow = () => {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard-monitor-status'] })
|
qc.invalidateQueries({ queryKey: ['dashboard-monitor-status'] })
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
|
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: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard-policy'] })
|
qc.invalidateQueries({ queryKey: ['dashboard-policy'] })
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
|
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -149,6 +279,7 @@ export const usePatchDashboardTagPolicy = () => {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard-policy'] })
|
qc.invalidateQueries({ queryKey: ['dashboard-policy'] })
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
|
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -178,6 +309,8 @@ export const usePatchDashboardChecks = () => {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard-checks'] })
|
qc.invalidateQueries({ queryKey: ['dashboard-checks'] })
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
|
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: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard-checks'] })
|
qc.invalidateQueries({ queryKey: ['dashboard-checks'] })
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
|
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['dashboard-object-devices'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,45 @@
|
||||||
import {
|
import { Alert, Button, Card, Col, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd'
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Col,
|
|
||||||
Divider,
|
|
||||||
InputNumber,
|
|
||||||
Row,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Statistic,
|
|
||||||
Switch,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
Typography,
|
|
||||||
message,
|
|
||||||
} from 'antd'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useObjects, useObjectsMeta } from '../api/objects'
|
import { useObjects, useObjectsMeta } from '../api/objects'
|
||||||
import {
|
import { useDashboardHomeSummary, useDashboardMonitorStatus, useDashboardRunNow, type DashboardScope } from '../api/dashboard'
|
||||||
useDashboardChecks,
|
|
||||||
useDashboardHomeSummary,
|
|
||||||
useDashboardMonitorStatus,
|
|
||||||
useDashboardPolicy,
|
|
||||||
useDashboardRunNow,
|
|
||||||
usePatchDashboardChecks,
|
|
||||||
usePatchDashboardObjectOverrides,
|
|
||||||
usePatchDashboardObjectPolicy,
|
|
||||||
usePatchDashboardTagPolicy,
|
|
||||||
type DashboardObjectOverride,
|
|
||||||
} from '../api/dashboard'
|
|
||||||
import { useTags } from '../api/tags'
|
import { useTags } from '../api/tags'
|
||||||
import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE'
|
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 {
|
function formatAge(seconds: number | null | undefined): string {
|
||||||
if (seconds == null) return '-'
|
if (seconds == null) return '-'
|
||||||
if (seconds < 60) return `${seconds}с`
|
if (seconds < 60) return `${seconds}ñ`
|
||||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}м`
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}ì`
|
||||||
return `${Math.floor(seconds / 3600)}ч ${Math.floor((seconds % 3600) / 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() {
|
export function HomePage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { data: meta } = useObjectsMeta()
|
const { data: meta } = useObjectsMeta()
|
||||||
const { data: objects } = useObjects()
|
const { data: objects } = useObjects()
|
||||||
|
|
@ -75,113 +57,54 @@ export function HomePage() {
|
||||||
|
|
||||||
const { data: summary, isLoading } = useDashboardHomeSummary(scope)
|
const { data: summary, isLoading } = useDashboardHomeSummary(scope)
|
||||||
const { data: monitorStatus } = useDashboardMonitorStatus()
|
const { data: monitorStatus } = useDashboardMonitorStatus()
|
||||||
const { data: policy } = useDashboardPolicy()
|
|
||||||
const { data: checksData } = useDashboardChecks()
|
|
||||||
|
|
||||||
const runNow = useDashboardRunNow()
|
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({
|
useDashboardMonitorSSE({
|
||||||
onUpdate: () => {
|
onUpdate: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
|
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard-monitor-status'] })
|
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 () => {
|
const handleRunNow = async () => {
|
||||||
try {
|
try {
|
||||||
await runNow.mutateAsync({ ...scope, force: true })
|
await runNow.mutateAsync({ ...scope, force: true })
|
||||||
message.success('Мониторинг запущен')
|
message.success('Ìîíèòîðèíã çàïóùåí')
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Не удалось запустить мониторинг')
|
message.error('Íå óäàëîñü çàïóñòèòü ìîíèòîðèíã')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totals = summary?.totals ?? { objects: 0, devices: 0, allowed_objects: 0 }
|
const totals = summary?.totals ?? { objects: 0, devices: 0, allowed_objects: 0, excluded_objects: 0 }
|
||||||
const problemObjects = summary?.problem_objects ?? []
|
|
||||||
|
|
||||||
const summaryColumns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: 'Объект',
|
title: 'Îáúåêò',
|
||||||
key: 'object',
|
key: 'object',
|
||||||
render: (_: unknown, row: any) => (
|
render: (_: unknown, row: any) => (
|
||||||
<Space>
|
<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.city && <Tag>{row.city}</Tag>}
|
||||||
{row.client && <Tag color="blue">{row.client}</Tag>}
|
{row.client && <Tag color="blue">{row.client}</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Политика',
|
title: 'Ïðè÷èíà',
|
||||||
key: 'policy',
|
key: 'reason',
|
||||||
render: (_: unknown, row: any) => <Tag color={row.allowed ? 'green' : 'red'}>{row.monitor_mode}</Tag>,
|
render: (_: unknown, row: any) => <Typography.Text>{reasonLabel(row.inclusion_reason)}</Typography.Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Устройств',
|
title: 'Óñòðîéñòâ',
|
||||||
dataIndex: 'device_count',
|
dataIndex: 'device_count',
|
||||||
key: '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',
|
title: 'Health',
|
||||||
key: 'health',
|
key: 'health',
|
||||||
|
|
@ -230,14 +137,22 @@ export function HomePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }} align="center">
|
||||||
Главная
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
</Typography.Title>
|
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 }}>
|
<Row gutter={12} style={{ marginBottom: 12 }}>
|
||||||
<Col span={5}>
|
<Col span={5}>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Город"
|
placeholder="Ãîðîä"
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))}
|
options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))}
|
||||||
|
|
@ -247,7 +162,7 @@ export function HomePage() {
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={5}>
|
<Col span={5}>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Клиент"
|
placeholder="Êëèåíò"
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))}
|
options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))}
|
||||||
|
|
@ -258,7 +173,7 @@ export function HomePage() {
|
||||||
<Col span={7}>
|
<Col span={7}>
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
placeholder="Объекты"
|
placeholder="Îáúåêòû"
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
value={objectIds}
|
value={objectIds}
|
||||||
|
|
@ -269,7 +184,7 @@ export function HomePage() {
|
||||||
<Col span={7}>
|
<Col span={7}>
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
placeholder="Теги"
|
placeholder="Òåãè"
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
value={tagIds}
|
value={tagIds}
|
||||||
|
|
@ -280,16 +195,11 @@ export function HomePage() {
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||||
<Col>
|
|
||||||
<Button type="primary" onClick={handleRunNow} loading={runNow.isPending}>
|
|
||||||
Запустить сейчас
|
|
||||||
</Button>
|
|
||||||
</Col>
|
|
||||||
<Col>
|
<Col>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
Статус: {monitorStatus?.status ?? '-'}
|
Ñòàòóñ: {monitorStatus?.status ?? '-'}
|
||||||
{monitorStatus?.last_success_at
|
{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>
|
</Typography.Text>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -300,270 +210,57 @@ export function HomePage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||||
<Col span={8}>
|
<Col span={6}>
|
||||||
<Card><Statistic title="Объекты" value={totals.objects} /></Card>
|
<Card><Statistic title="Îáúåêòû" value={totals.objects} /></Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={6}>
|
||||||
<Card><Statistic title="В мониторинге" value={totals.allowed_objects} /></Card>
|
<Card><Statistic title="Â ìîíèòîðèíãå" value={summary?.included_count ?? totals.allowed_objects} /></Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={6}>
|
||||||
<Card><Statistic title="Устройства" value={totals.devices} /></Card>
|
<Card><Statistic title="Âíå ìîíèòîðèíãà" value={summary?.excluded_count ?? totals.excluded_objects} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card><Statistic title="Óñòðîéñòâà" value={totals.devices} /></Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Card title="Расписание проверок" style={{ marginBottom: 16 }}>
|
<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
|
<Table
|
||||||
size="small"
|
size="small"
|
||||||
rowKey="id"
|
dataSource={summary?.problem_objects ?? []}
|
||||||
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}
|
|
||||||
rowKey="object_id"
|
rowKey="object_id"
|
||||||
columns={summaryColumns}
|
columns={columns}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
loading={isLoading}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Сводка по объектам">
|
<Row gutter={12}>
|
||||||
<Table
|
<Col span={12}>
|
||||||
loading={isLoading}
|
<Card title="Â ìîíèòîðèíãå" style={{ marginBottom: 16 }}>
|
||||||
dataSource={summary?.objects ?? []}
|
<Table
|
||||||
rowKey="object_id"
|
size="small"
|
||||||
columns={summaryColumns}
|
dataSource={summary?.included_objects ?? []}
|
||||||
pagination={{ pageSize: 12 }}
|
rowKey="object_id"
|
||||||
/>
|
columns={columns}
|
||||||
</Card>
|
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>
|
</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