перепись дешборда

This commit is contained in:
dv 2026-05-05 12:18:02 +03:00
parent e613561b45
commit 3feb05ce0f
12 changed files with 1798 additions and 485 deletions

View 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")

View file

@ -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",

View file

@ -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"

View file

@ -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()

View file

@ -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

View file

@ -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,
} }

View 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"

View file

@ -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" />} />

View file

@ -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'] })
}, },
}) })
} }

View file

@ -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 }}>
NOC Äåøáîðä
</Typography.Title> </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}>
<Col span={12}>
<Card title="Â ìîíèòîðèíãå" style={{ marginBottom: 16 }}>
<Table <Table
loading={isLoading} size="small"
dataSource={summary?.objects ?? []} dataSource={summary?.included_objects ?? []}
rowKey="object_id" rowKey="object_id"
columns={summaryColumns} columns={columns}
pagination={{ pageSize: 12 }} pagination={{ pageSize: 8 }}
loading={isLoading}
/> />
</Card> </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>
) )
} }

View 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>
)
}

View 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>
)
}