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

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,
DashboardObjectCheckOverride,
DashboardDeviceLiveState,
DashboardDeviceCheckHistory,
DashboardObjectMonitorPolicy,
DashboardTagMonitorPolicy,
DashboardMonitorRunState,
@ -44,6 +45,7 @@ __all__ = [
"DashboardCheckDefinition",
"DashboardObjectCheckOverride",
"DashboardDeviceLiveState",
"DashboardDeviceCheckHistory",
"DashboardObjectMonitorPolicy",
"DashboardTagMonitorPolicy",
"DashboardMonitorRunState",

View file

@ -66,6 +66,24 @@ class DashboardDeviceLiveState(Base):
source: Mapped[str] = mapped_column(String(32), nullable=False, default="dashboard_monitor")
class DashboardDeviceCheckHistory(Base):
__tablename__ = "dashboard_device_check_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False, index=True
)
check_key: Mapped[str] = mapped_column(
String(64), ForeignKey("dashboard_check_definitions.check_key", ondelete="CASCADE"), nullable=False, index=True
)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="unknown")
ping_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(32), nullable=False, default="dashboard_monitor")
class DashboardObjectMonitorPolicy(Base, TimestampMixin):
__tablename__ = "dashboard_object_monitor_policy"

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.models.user import User
@ -7,8 +7,11 @@ from app.schemas.dashboard import (
DashboardChecksResponse,
DashboardHomeSummaryResponse,
DashboardMonitorStatus,
DashboardObjectAvailabilityResponse,
DashboardObjectDevicesResponse,
DashboardObjectOverridePatchItem,
DashboardObjectPolicyPatchItem,
DashboardObjectSummaryResponse,
DashboardPolicyResponse,
DashboardRunRequest,
DashboardRunResponse,
@ -37,6 +40,65 @@ async def home_summary(
return DashboardHomeSummaryResponse.model_validate(data)
@router.get("/object/{object_id}/summary", response_model=DashboardObjectSummaryResponse)
async def object_summary(
object_id: int,
city: str | None = None,
client: str | None = None,
object_ids: list[int] | None = None,
tag_ids: list[int] | None = None,
_: User = Depends(get_current_user),
):
scope = {
"city": city,
"client": client,
"object_ids": object_ids or [],
"tag_ids": tag_ids or [],
}
data = await dashboard_service.get_object_summary(object_id, scope)
if data is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
return DashboardObjectSummaryResponse.model_validate(data)
@router.get("/object/{object_id}/devices", response_model=DashboardObjectDevicesResponse)
async def object_devices(
object_id: int,
city: str | None = None,
client: str | None = None,
object_ids: list[int] | None = None,
tag_ids: list[int] | None = None,
_: User = Depends(get_current_user),
):
scope = {
"city": city,
"client": client,
"object_ids": object_ids or [],
"tag_ids": tag_ids or [],
}
data = await dashboard_service.get_object_devices(object_id, scope)
if data is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
return DashboardObjectDevicesResponse.model_validate(data)
@router.get("/object/{object_id}/availability", response_model=DashboardObjectAvailabilityResponse)
async def object_availability(
object_id: int,
window: str = Query("24h"),
device_ids: list[int] | None = None,
_: User = Depends(get_current_user),
):
data = await dashboard_service.get_object_availability(
object_id,
window=window,
device_ids=device_ids,
)
if data is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
return DashboardObjectAvailabilityResponse.model_validate(data)
@router.get("/monitor/status", response_model=DashboardMonitorStatus)
async def monitor_status(_: User = Depends(get_current_user)):
row = await dashboard_service.get_monitor_status()

View file

@ -5,6 +5,7 @@ from pydantic import BaseModel, Field
MonitorPolicyMode = Literal["inherit", "include", "exclude"]
AvailabilityWindow = Literal["24h", "3d", "7d"]
class DashboardScope(BaseModel):
@ -127,6 +128,7 @@ class DashboardObjectSummary(BaseModel):
client: Optional[str] = None
allowed: bool
monitor_mode: MonitorPolicyMode
inclusion_reason: str
device_count: int
checks: dict[str, DashboardObjectCheckSummary]
health_score: Optional[float] = None
@ -139,3 +141,87 @@ class DashboardHomeSummaryResponse(BaseModel):
checks_meta: list[DashboardCheckDefinitionRead]
objects: list[DashboardObjectSummary]
problem_objects: list[DashboardObjectSummary]
included_count: int
excluded_count: int
included_objects: list[DashboardObjectSummary]
excluded_objects: list[DashboardObjectSummary]
class DashboardDeviceNOCItem(BaseModel):
device_id: int
object_id: int
ip: str
hostname: Optional[str] = None
category: str
role: Optional[str] = None
status: Optional[str] = None
monitor_enabled: bool
last_seen: Optional[datetime] = None
last_ping_ms: Optional[int] = None
ping_status: str
ping_checked_at: Optional[datetime] = None
ping_age_sec: Optional[int] = None
ping_fresh: bool
disk_status: Optional[str] = None
disk_max_pct: Optional[float] = None
has_problem: bool
class DashboardObjectSummaryResponse(BaseModel):
generated_at: datetime
scope: DashboardScope
object: DashboardObjectSummary
totals: dict[str, int]
checks_meta: list[DashboardCheckDefinitionRead]
top_problem_devices: list[DashboardDeviceNOCItem]
class DashboardObjectDevicesResponse(BaseModel):
generated_at: datetime
scope: DashboardScope
object_id: int
devices: list[DashboardDeviceNOCItem]
class DashboardAvailabilityPoint(BaseModel):
bucket_start: datetime
bucket_end: datetime
availability_pct: Optional[float] = None
samples: int
online: int
offline: int
class DashboardAvailabilityInterval(BaseModel):
status: str
started_at: datetime
ended_at: datetime
duration_sec: int
class DashboardDeviceAvailability(BaseModel):
device_id: int
ip: str
hostname: Optional[str] = None
availability_pct: Optional[float] = None
samples: int
online: int
offline: int
last_checked_at: Optional[datetime] = None
last_status: Optional[str] = None
last_ping_ms: Optional[int] = None
series: list[DashboardAvailabilityPoint]
intervals: list[DashboardAvailabilityInterval]
class DashboardObjectAvailabilityResponse(BaseModel):
generated_at: datetime
object_id: int
window: AvailabilityWindow
start_at: datetime
end_at: datetime
bucket_sec: int
overall_availability_pct: Optional[float] = None
overall_series: list[DashboardAvailabilityPoint]
devices: list[DashboardDeviceAvailability]
insufficient_data: bool

View file

@ -1,6 +1,7 @@
import asyncio
import time
from datetime import datetime, timezone
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
@ -9,6 +10,7 @@ from sqlalchemy.orm import selectinload
from app.config import settings
from app.models.dashboard import (
DashboardCheckDefinition,
DashboardDeviceCheckHistory,
DashboardDeviceLiveState,
DashboardMonitorRunState,
DashboardObjectCheckOverride,
@ -45,6 +47,18 @@ DEFAULT_CHECKS = [
},
]
WINDOW_SECONDS = {
"24h": 24 * 3600,
"3d": 3 * 24 * 3600,
"7d": 7 * 24 * 3600,
}
WINDOW_BUCKET_SECONDS = {
"24h": 3600,
"3d": 7200,
"7d": 21600,
}
_RUN_LOCK = asyncio.Lock()
_MANUAL_TASK: asyncio.Task | None = None
@ -53,6 +67,47 @@ async def _noop_emit(_: str, __: str = "info") -> None:
return
def _scope_to_dict(scope: dict[str, Any] | None) -> dict[str, Any]:
if not scope:
return {}
return {
"city": scope.get("city"),
"client": scope.get("client"),
"object_ids": list(scope.get("object_ids") or []),
"tag_ids": list(scope.get("tag_ids") or []),
}
def _window_seconds(window: str) -> int:
return WINDOW_SECONDS.get(window, WINDOW_SECONDS["24h"])
def _bucket_seconds(window: str) -> int:
return WINDOW_BUCKET_SECONDS.get(window, WINDOW_BUCKET_SECONDS["24h"])
def _is_object_in_scope(obj: Object, scope: dict[str, Any]) -> bool:
city = scope.get("city")
if city and obj.city != city:
return False
client = scope.get("client")
if client and obj.client != client:
return False
object_ids = list(scope.get("object_ids") or [])
if object_ids and obj.id not in object_ids:
return False
tag_ids = set(scope.get("tag_ids") or [])
if tag_ids:
obj_tag_ids = {t.id for t in obj.tags}
if obj_tag_ids.isdisjoint(tag_ids):
return False
return True
async def ensure_dashboard_bootstrap() -> None:
async with AsyncSessionLocal() as db:
async with db.begin():
@ -68,8 +123,6 @@ async def ensure_dashboard_bootstrap() -> None:
db.add(DashboardCheckDefinition(**payload))
continue
# keep user-tuned runtime settings, but backfill key defaults and
# transparently switch legacy disk_usage action source
if row.check_key == "disk_usage" and row.plugin_action == "debian_system_info":
row.plugin_action = "disk_usage"
if not row.plugin_action:
@ -94,17 +147,6 @@ async def _get_run_state(db) -> DashboardMonitorRunState:
return row
def _scope_to_dict(scope: dict[str, Any] | None) -> dict[str, Any]:
if not scope:
return {}
return {
"city": scope.get("city"),
"client": scope.get("client"),
"object_ids": list(scope.get("object_ids") or []),
"tag_ids": list(scope.get("tag_ids") or []),
}
async def get_monitor_status() -> DashboardMonitorRunState:
async with AsyncSessionLocal() as db:
return await _get_run_state(db)
@ -130,24 +172,37 @@ async def _load_objects_with_scope(db, scope: dict[str, Any] | None) -> list[Obj
return list(result.scalars().all())
async def _load_object_with_tags(db, obj_id: int) -> Object | None:
row = (
await db.execute(
select(Object)
.options(selectinload(Object.tags))
.where(Object.id == obj_id, Object.is_active == True)
)
).scalar_one_or_none()
return row
def _is_object_allowed(
obj: Object,
object_modes: dict[int, str],
tag_modes: dict[int, bool],
) -> tuple[bool, str]:
) -> tuple[bool, str, str]:
mode = object_modes.get(obj.id, "inherit")
if mode == "include":
return True, mode
return True, mode, "object_include_policy"
if mode == "exclude":
return False, mode
return False, mode, "object_exclude_policy"
tag_values = [tag_modes[t.id] for t in obj.tags if t.id in tag_modes]
if not tag_values:
return True, mode
return any(tag_values), mode
return True, mode, "inherit_default"
if any(tag_values):
return True, mode, "tag_policy_enabled"
return False, mode, "tag_policy_disabled"
async def _build_object_policy_map(db, objects: list[Object]) -> dict[int, tuple[bool, str]]:
async def _build_object_policy_map(db, objects: list[Object]) -> dict[int, tuple[bool, str, str]]:
object_ids = [o.id for o in objects]
if not object_ids:
return {}
@ -262,6 +317,40 @@ async def _upsert_live_state(
row.source = "dashboard_monitor"
async def _append_check_history(
db,
*,
device_id: int,
check_key: str,
status: str,
value_json: dict[str, Any] | None,
error: str | None,
checked_at: datetime,
duration_ms: int,
) -> None:
ping_ms = None
if check_key == "ping" and value_json is not None:
raw = value_json.get("ping_ms")
if raw is not None:
try:
ping_ms = int(raw)
except (TypeError, ValueError):
ping_ms = None
db.add(
DashboardDeviceCheckHistory(
device_id=device_id,
check_key=check_key,
status=status,
ping_ms=ping_ms,
checked_at=checked_at,
duration_ms=duration_ms,
error=error,
source="dashboard_monitor",
)
)
async def _run_check_on_device(
sem: asyncio.Semaphore,
check: DashboardCheckDefinition,
@ -345,7 +434,7 @@ async def run_dashboard_monitor_cycle(
counts["objects_in_scope"] = len(objects)
object_policy_map = await _build_object_policy_map(db, objects)
allowed_object_ids = [oid for oid, (allowed, _mode) in object_policy_map.items() if allowed]
allowed_object_ids = [oid for oid, (allowed, _mode, _reason) in object_policy_map.items() if allowed]
counts["objects_allowed"] = len(allowed_object_ids)
counts["skipped_policy"] = max(0, len(objects) - len(allowed_object_ids))
@ -434,6 +523,7 @@ async def run_dashboard_monitor_cycle(
if isinstance(result, Exception):
counts["failed"] += 1
counts["checks_executed"] += 1
checked_at = datetime.now(timezone.utc)
await _upsert_live_state(
dbw,
device_id=device.id,
@ -441,7 +531,17 @@ async def run_dashboard_monitor_cycle(
status="failed",
value_json=None,
error=str(result),
checked_at=datetime.now(timezone.utc),
checked_at=checked_at,
duration_ms=0,
)
await _append_check_history(
dbw,
device_id=device.id,
check_key=check.check_key,
status="failed",
value_json=None,
error=str(result),
checked_at=checked_at,
duration_ms=0,
)
continue
@ -462,11 +562,18 @@ async def run_dashboard_monitor_cycle(
checked_at=checked_at,
duration_ms=duration_ms,
)
await _append_check_history(
dbw,
device_id=device.id,
check_key=check.check_key,
status=status,
value_json=value_json,
error=error,
checked_at=checked_at,
duration_ms=duration_ms,
)
if (
settings.DASHBOARD_MONITOR_SYNC_DEVICE_STATUS
and check.check_key == "ping"
):
if settings.DASHBOARD_MONITOR_SYNC_DEVICE_STATUS and check.check_key == "ping":
device_row = await dbw.get(Device, device.id)
if device_row is not None:
device_row.status = "online" if status == "online" else "offline"
@ -692,6 +799,128 @@ def _calc_health(
return round(max(0.0, score), 1)
def _build_check_summary(
*,
now: datetime,
obj_devices: list[Device],
checks: list[DashboardCheckDefinition],
state_map: dict[tuple[int, str], DashboardDeviceLiveState],
) -> dict[str, dict[str, Any]]:
check_summary: dict[str, dict[str, Any]] = {}
for check in checks:
counts: dict[str, int] = {}
last_checked = None
max_age = None
for dev in obj_devices:
state = state_map.get((dev.id, check.check_key))
if state is None:
counts["unknown"] = counts.get("unknown", 0) + 1
continue
counts[state.status] = counts.get(state.status, 0) + 1
if last_checked is None or state.checked_at > last_checked:
last_checked = state.checked_at
age_sec = int((now - state.checked_at).total_seconds())
if max_age is None or age_sec > max_age:
max_age = age_sec
check_summary[check.check_key] = {
"status_counts": counts,
"last_checked_at": last_checked,
"max_age_sec": max_age,
}
return check_summary
def _to_object_summary(
*,
now: datetime,
obj: Object,
allowed: bool,
monitor_mode: str,
inclusion_reason: str,
checks: list[DashboardCheckDefinition],
obj_devices: list[Device],
state_map: dict[tuple[int, str], DashboardDeviceLiveState],
) -> dict[str, Any]:
check_summary = _build_check_summary(now=now, obj_devices=obj_devices, checks=checks, state_map=state_map)
check_interval_map = {c.check_key: c.interval_sec_default for c in checks}
health = None
if allowed:
ping_meta = check_summary.get("ping", {})
health = _calc_health(
{k: v["status_counts"] for k, v in check_summary.items()},
device_count=len(obj_devices),
ping_interval_sec=check_interval_map.get("ping"),
ping_max_age_sec=ping_meta.get("max_age_sec"),
)
return {
"object_id": obj.id,
"object_name": obj.name,
"city": obj.city,
"client": obj.client,
"allowed": allowed,
"monitor_mode": monitor_mode,
"inclusion_reason": inclusion_reason,
"device_count": len(obj_devices),
"checks": check_summary,
"health_score": health,
}
def _device_noc_item(
*,
now: datetime,
device: Device,
ping_state: DashboardDeviceLiveState | None,
disk_state: DashboardDeviceLiveState | None,
ping_interval_sec: int,
) -> dict[str, Any]:
ping_age_sec = None
ping_checked_at = None
ping_status = "unknown"
ping_fresh = False
if ping_state is not None:
ping_status = ping_state.status
ping_checked_at = ping_state.checked_at
ping_age_sec = int((now - ping_state.checked_at).total_seconds())
ping_fresh = ping_age_sec <= ping_interval_sec * 3
disk_status = disk_state.status if disk_state is not None else None
disk_max_pct = None
if disk_state and isinstance(disk_state.value_json, dict):
val = disk_state.value_json.get("max_pct")
if isinstance(val, (int, float)):
disk_max_pct = float(val)
has_problem = (
ping_status == "offline"
or (ping_status == "unknown" and not ping_fresh)
or disk_status in ("warn", "failed")
)
return {
"device_id": device.id,
"object_id": device.object_id,
"ip": device.ip,
"hostname": device.hostname,
"category": device.category,
"role": device.role,
"status": device.status,
"monitor_enabled": device.monitor_enabled,
"last_seen": device.last_seen,
"last_ping_ms": device.last_ping_ms,
"ping_status": ping_status,
"ping_checked_at": ping_checked_at,
"ping_age_sec": ping_age_sec,
"ping_fresh": ping_fresh,
"disk_status": disk_status,
"disk_max_pct": disk_max_pct,
"has_problem": has_problem,
}
async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]:
now = datetime.now(timezone.utc)
scope = _scope_to_dict(scope)
@ -730,67 +959,34 @@ async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]:
checks = (await db.execute(select(DashboardCheckDefinition).order_by(DashboardCheckDefinition.check_key))).scalars().all()
check_interval_map = {c.check_key: c.interval_sec_default for c in checks}
summaries = []
for obj in objects:
allowed, mode = policy_map.get(obj.id, (True, "inherit"))
allowed, mode, reason = policy_map.get(obj.id, (True, "inherit", "inherit_default"))
obj_devices = devices_by_object.get(obj.id, [])
check_summary: dict[str, dict[str, Any]] = {}
for check in checks:
counts: dict[str, int] = {}
last_checked = None
max_age = None
for dev in obj_devices:
state = state_map.get((dev.id, check.check_key))
if state is None:
counts["unknown"] = counts.get("unknown", 0) + 1
continue
counts[state.status] = counts.get(state.status, 0) + 1
if last_checked is None or state.checked_at > last_checked:
last_checked = state.checked_at
age_sec = int((now - state.checked_at).total_seconds())
if max_age is None or age_sec > max_age:
max_age = age_sec
check_summary[check.check_key] = {
"status_counts": counts,
"last_checked_at": last_checked,
"max_age_sec": max_age,
}
health = None
if allowed:
ping_meta = check_summary.get("ping", {})
health = _calc_health(
{k: v["status_counts"] for k, v in check_summary.items()},
device_count=len(obj_devices),
ping_interval_sec=check_interval_map.get("ping"),
ping_max_age_sec=ping_meta.get("max_age_sec"),
)
summaries.append(
{
"object_id": obj.id,
"object_name": obj.name,
"city": obj.city,
"client": obj.client,
"allowed": allowed,
"monitor_mode": mode,
"device_count": len(obj_devices),
"checks": check_summary,
"health_score": health,
}
_to_object_summary(
now=now,
obj=obj,
allowed=allowed,
monitor_mode=mode,
inclusion_reason=reason,
checks=checks,
obj_devices=obj_devices,
state_map=state_map,
)
)
included_objects = [s for s in summaries if s["allowed"]]
excluded_objects = [s for s in summaries if not s["allowed"]]
problems = sorted(
summaries,
key=lambda x: (
x["allowed"] is False,
x["health_score"] if x["health_score"] is not None else 999,
),
included_objects,
key=lambda x: x["health_score"] if x["health_score"] is not None else 999,
)[:10]
totals = {
"objects": len(objects),
"allowed_objects": sum(1 for s in summaries if s["allowed"]),
"allowed_objects": len(included_objects),
"excluded_objects": len(excluded_objects),
"devices": len(device_rows),
}
@ -801,4 +997,333 @@ async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]:
"checks_meta": checks,
"objects": summaries,
"problem_objects": problems,
"included_count": len(included_objects),
"excluded_count": len(excluded_objects),
"included_objects": included_objects,
"excluded_objects": excluded_objects,
}
async def get_object_devices(object_id: int, scope: dict[str, Any] | None) -> dict[str, Any] | None:
now = datetime.now(timezone.utc)
scope = _scope_to_dict(scope)
async with AsyncSessionLocal() as db:
obj = await _load_object_with_tags(db, object_id)
if obj is None:
return None
device_rows = (
await db.execute(
select(Device)
.where(Device.object_id == object_id, Device.is_active == True)
.order_by(Device.category, Device.ip)
)
).scalars().all()
state_rows = []
if device_rows:
state_rows = (
await db.execute(
select(DashboardDeviceLiveState).where(
DashboardDeviceLiveState.device_id.in_([d.id for d in device_rows]),
DashboardDeviceLiveState.check_key.in_(["ping", "disk_usage"]),
)
)
).scalars().all()
check_rows = (
await db.execute(
select(DashboardCheckDefinition).where(DashboardCheckDefinition.check_key == "ping")
)
).scalars().all()
ping_interval = check_rows[0].interval_sec_default if check_rows else 300
state_map = {(row.device_id, row.check_key): row for row in state_rows}
rows = []
for device in device_rows:
rows.append(
_device_noc_item(
now=now,
device=device,
ping_state=state_map.get((device.id, "ping")),
disk_state=state_map.get((device.id, "disk_usage")),
ping_interval_sec=ping_interval,
)
)
rows.sort(
key=lambda r: (
not r["has_problem"],
0 if r["ping_status"] == "offline" else 1,
r["ip"],
)
)
return {
"generated_at": now,
"scope": scope,
"object_id": object_id,
"devices": rows,
}
async def get_object_summary(object_id: int, scope: dict[str, Any] | None) -> dict[str, Any] | None:
now = datetime.now(timezone.utc)
scope = _scope_to_dict(scope)
async with AsyncSessionLocal() as db:
obj = await _load_object_with_tags(db, object_id)
if obj is None:
return None
policy_map = await _build_object_policy_map(db, [obj])
allowed, mode, reason = policy_map.get(obj.id, (True, "inherit", "inherit_default"))
if not _is_object_in_scope(obj, scope):
allowed = False
reason = "scope_filtered"
checks = (await db.execute(select(DashboardCheckDefinition).order_by(DashboardCheckDefinition.check_key))).scalars().all()
device_rows = (
await db.execute(
select(Device).where(Device.object_id == object_id, Device.is_active == True)
)
).scalars().all()
state_rows = []
if device_rows:
state_rows = (
await db.execute(
select(DashboardDeviceLiveState).where(
DashboardDeviceLiveState.device_id.in_([d.id for d in device_rows])
)
)
).scalars().all()
state_map: dict[tuple[int, str], DashboardDeviceLiveState] = {
(row.device_id, row.check_key): row for row in state_rows
}
summary = _to_object_summary(
now=now,
obj=obj,
allowed=allowed,
monitor_mode=mode,
inclusion_reason=reason,
checks=checks,
obj_devices=device_rows,
state_map=state_map,
)
ping_interval = next((c.interval_sec_default for c in checks if c.check_key == "ping"), 300)
top_problem_devices = [
_device_noc_item(
now=now,
device=device,
ping_state=state_map.get((device.id, "ping")),
disk_state=state_map.get((device.id, "disk_usage")),
ping_interval_sec=ping_interval,
)
for device in device_rows
]
top_problem_devices.sort(
key=lambda r: (
not r["has_problem"],
0 if r["ping_status"] == "offline" else 1,
r["ip"],
)
)
totals = {
"devices": len(device_rows),
"problem_devices": sum(1 for d in top_problem_devices if d["has_problem"]),
"offline": sum(1 for d in top_problem_devices if d["ping_status"] == "offline"),
"warn": sum(1 for d in top_problem_devices if d["disk_status"] in ("warn", "failed")),
}
return {
"generated_at": now,
"scope": scope,
"object": summary,
"totals": totals,
"checks_meta": checks,
"top_problem_devices": top_problem_devices[:12],
}
def _build_bucket_points(start_at: datetime, end_at: datetime, bucket_sec: int) -> list[tuple[datetime, datetime]]:
buckets = []
cur = start_at
while cur < end_at:
nxt = min(end_at, cur + timedelta(seconds=bucket_sec))
buckets.append((cur, nxt))
cur = nxt
return buckets
def _series_from_rows(
rows: list[DashboardDeviceCheckHistory],
*,
start_at: datetime,
end_at: datetime,
bucket_sec: int,
) -> list[dict[str, Any]]:
buckets = _build_bucket_points(start_at, end_at, bucket_sec)
points = []
for b_start, b_end in buckets:
bucket_rows = [r for r in rows if b_start <= r.checked_at < b_end and r.status in ("online", "offline")]
online = sum(1 for r in bucket_rows if r.status == "online")
offline = sum(1 for r in bucket_rows if r.status == "offline")
samples = online + offline
availability = round((online / samples) * 100, 1) if samples > 0 else None
points.append(
{
"bucket_start": b_start,
"bucket_end": b_end,
"availability_pct": availability,
"samples": samples,
"online": online,
"offline": offline,
}
)
return points
def _intervals_from_rows(rows: list[DashboardDeviceCheckHistory], *, end_at: datetime) -> list[dict[str, Any]]:
if not rows:
return []
intervals = []
cur_status = rows[0].status
cur_start = rows[0].checked_at
for row in rows[1:]:
if row.status == cur_status:
continue
duration = int((row.checked_at - cur_start).total_seconds())
intervals.append(
{
"status": cur_status,
"started_at": cur_start,
"ended_at": row.checked_at,
"duration_sec": max(0, duration),
}
)
cur_status = row.status
cur_start = row.checked_at
intervals.append(
{
"status": cur_status,
"started_at": cur_start,
"ended_at": end_at,
"duration_sec": max(0, int((end_at - cur_start).total_seconds())),
}
)
return intervals
async def get_object_availability(
object_id: int,
*,
window: str,
device_ids: list[int] | None = None,
) -> dict[str, Any] | None:
now = datetime.now(timezone.utc)
window_key = window if window in WINDOW_SECONDS else "24h"
start_at = now - timedelta(seconds=_window_seconds(window_key))
bucket_sec = _bucket_seconds(window_key)
async with AsyncSessionLocal() as db:
obj = await _load_object_with_tags(db, object_id)
if obj is None:
return None
q_devices = select(Device).where(Device.object_id == object_id, Device.is_active == True)
if device_ids:
q_devices = q_devices.where(Device.id.in_(device_ids))
devices = (await db.execute(q_devices.order_by(Device.ip))).scalars().all()
if not devices:
return {
"generated_at": now,
"object_id": object_id,
"window": window_key,
"start_at": start_at,
"end_at": now,
"bucket_sec": bucket_sec,
"overall_availability_pct": None,
"overall_series": _series_from_rows([], start_at=start_at, end_at=now, bucket_sec=bucket_sec),
"devices": [],
"insufficient_data": True,
}
rows = (
await db.execute(
select(DashboardDeviceCheckHistory).where(
DashboardDeviceCheckHistory.check_key == "ping",
DashboardDeviceCheckHistory.device_id.in_([d.id for d in devices]),
DashboardDeviceCheckHistory.checked_at >= start_at,
)
)
).scalars().all()
rows_by_device: dict[int, list[DashboardDeviceCheckHistory]] = defaultdict(list)
for row in rows:
rows_by_device[row.device_id].append(row)
for lst in rows_by_device.values():
lst.sort(key=lambda r: r.checked_at)
device_payload = []
all_rows: list[DashboardDeviceCheckHistory] = []
for device in devices:
d_rows = rows_by_device.get(device.id, [])
all_rows.extend(d_rows)
online = sum(1 for r in d_rows if r.status == "online")
offline = sum(1 for r in d_rows if r.status == "offline")
samples = online + offline
availability = round((online / samples) * 100, 1) if samples > 0 else None
last_row = d_rows[-1] if d_rows else None
device_payload.append(
{
"device_id": device.id,
"ip": device.ip,
"hostname": device.hostname,
"availability_pct": availability,
"samples": samples,
"online": online,
"offline": offline,
"last_checked_at": last_row.checked_at if last_row else None,
"last_status": last_row.status if last_row else None,
"last_ping_ms": last_row.ping_ms if last_row else None,
"series": _series_from_rows(d_rows, start_at=start_at, end_at=now, bucket_sec=bucket_sec),
"intervals": _intervals_from_rows(d_rows, end_at=now),
}
)
all_rows.sort(key=lambda r: r.checked_at)
overall_online = sum(1 for r in all_rows if r.status == "online")
overall_offline = sum(1 for r in all_rows if r.status == "offline")
overall_samples = overall_online + overall_offline
overall_availability = round((overall_online / overall_samples) * 100, 1) if overall_samples > 0 else None
insufficient_data = overall_samples == 0
return {
"generated_at": now,
"object_id": object_id,
"window": window_key,
"start_at": start_at,
"end_at": now,
"bucket_sec": bucket_sec,
"overall_availability_pct": overall_availability,
"overall_series": _series_from_rows(all_rows, start_at=start_at, end_at=now, bucket_sec=bucket_sec),
"devices": device_payload,
"insufficient_data": insufficient_data,
}

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 { DeviceDetailPage } from './pages/DeviceDetail'
import { HomePage } from './pages/Home'
import { HomeObjectDetailPage } from './pages/HomeObjectDetail'
import { HomeSettingsPage } from './pages/HomeSettings'
import { LoginPage } from './pages/Login'
import { ObjectsPage } from './pages/Objects'
import { SnapshotsPage } from './pages/Snapshots'
@ -32,6 +34,8 @@ export default function App() {
>
<Route index element={<Navigate to="/home" replace />} />
<Route path="home" element={<HomePage />} />
<Route path="home/settings" element={<HomeSettingsPage />} />
<Route path="home/objects/:id" element={<HomeObjectDetailPage />} />
{/* Инвентарь */}
<Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} />

View file

@ -8,6 +8,8 @@ export interface DashboardScope {
tag_ids?: number[]
}
export type DashboardAvailabilityWindow = '24h' | '3d' | '7d'
export interface DashboardCheckDefinition {
check_key: string
enabled: boolean
@ -69,6 +71,7 @@ export interface DashboardObjectSummary {
client: string | null
allowed: boolean
monitor_mode: 'inherit' | 'include' | 'exclude'
inclusion_reason: string
device_count: number
checks: Record<string, DashboardObjectCheckSummary>
health_score: number | null
@ -81,6 +84,90 @@ export interface DashboardHomeSummary {
checks_meta: DashboardCheckDefinition[]
objects: DashboardObjectSummary[]
problem_objects: DashboardObjectSummary[]
included_count: number
excluded_count: number
included_objects: DashboardObjectSummary[]
excluded_objects: DashboardObjectSummary[]
}
export interface DashboardDeviceNOCItem {
device_id: number
object_id: number
ip: string
hostname: string | null
category: string
role: string | null
status: string | null
monitor_enabled: boolean
last_seen: string | null
last_ping_ms: number | null
ping_status: string
ping_checked_at: string | null
ping_age_sec: number | null
ping_fresh: boolean
disk_status: string | null
disk_max_pct: number | null
has_problem: boolean
}
export interface DashboardObjectSummaryResponse {
generated_at: string
scope: DashboardScope
object: DashboardObjectSummary
totals: Record<string, number>
checks_meta: DashboardCheckDefinition[]
top_problem_devices: DashboardDeviceNOCItem[]
}
export interface DashboardObjectDevicesResponse {
generated_at: string
scope: DashboardScope
object_id: number
devices: DashboardDeviceNOCItem[]
}
export interface DashboardAvailabilityPoint {
bucket_start: string
bucket_end: string
availability_pct: number | null
samples: number
online: number
offline: number
}
export interface DashboardAvailabilityInterval {
status: string
started_at: string
ended_at: string
duration_sec: number
}
export interface DashboardDeviceAvailability {
device_id: number
ip: string
hostname: string | null
availability_pct: number | null
samples: number
online: number
offline: number
last_checked_at: string | null
last_status: string | null
last_ping_ms: number | null
series: DashboardAvailabilityPoint[]
intervals: DashboardAvailabilityInterval[]
}
export interface DashboardObjectAvailabilityResponse {
generated_at: string
object_id: number
window: DashboardAvailabilityWindow
start_at: string
end_at: string
bucket_sec: number
overall_availability_pct: number | null
overall_series: DashboardAvailabilityPoint[]
devices: DashboardDeviceAvailability[]
insufficient_data: boolean
}
export interface DashboardChecksResponse {
@ -98,6 +185,45 @@ export const useDashboardHomeSummary = (scope: DashboardScope) =>
refetchInterval: 30_000,
})
export const useDashboardObjectSummary = (objectId: number, scope: DashboardScope) =>
useQuery({
queryKey: ['dashboard-object-summary', objectId, scope],
queryFn: () =>
api
.get<DashboardObjectSummaryResponse>(`/api/v1/dashboard/object/${objectId}/summary`, { params: scope })
.then((r) => r.data),
enabled: Number.isFinite(objectId) && objectId > 0,
refetchInterval: 30_000,
})
export const useDashboardObjectDevices = (objectId: number, scope: DashboardScope) =>
useQuery({
queryKey: ['dashboard-object-devices', objectId, scope],
queryFn: () =>
api
.get<DashboardObjectDevicesResponse>(`/api/v1/dashboard/object/${objectId}/devices`, { params: scope })
.then((r) => r.data),
enabled: Number.isFinite(objectId) && objectId > 0,
refetchInterval: 30_000,
})
export const useDashboardObjectAvailability = (
objectId: number,
window: DashboardAvailabilityWindow,
deviceIds?: number[]
) =>
useQuery({
queryKey: ['dashboard-object-availability', objectId, window, deviceIds],
queryFn: () =>
api
.get<DashboardObjectAvailabilityResponse>(`/api/v1/dashboard/object/${objectId}/availability`, {
params: { window, device_ids: deviceIds },
})
.then((r) => r.data),
enabled: Number.isFinite(objectId) && objectId > 0,
refetchInterval: 45_000,
})
export const useDashboardMonitorStatus = () =>
useQuery({
queryKey: ['dashboard-monitor-status'],
@ -116,6 +242,9 @@ export const useDashboardRunNow = () => {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['dashboard-monitor-status'] })
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] })
qc.invalidateQueries({ queryKey: ['dashboard-object-devices'] })
qc.invalidateQueries({ queryKey: ['dashboard-object-availability'] })
},
})
}
@ -137,6 +266,7 @@ export const usePatchDashboardObjectPolicy = () => {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['dashboard-policy'] })
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] })
},
})
}
@ -149,6 +279,7 @@ export const usePatchDashboardTagPolicy = () => {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['dashboard-policy'] })
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] })
},
})
}
@ -178,6 +309,8 @@ export const usePatchDashboardChecks = () => {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['dashboard-checks'] })
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] })
qc.invalidateQueries({ queryKey: ['dashboard-object-devices'] })
},
})
}
@ -197,6 +330,8 @@ export const usePatchDashboardObjectOverrides = () => {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['dashboard-checks'] })
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] })
qc.invalidateQueries({ queryKey: ['dashboard-object-devices'] })
},
})
}

View file

@ -1,63 +1,45 @@
import {
Alert,
Button,
Card,
Col,
Divider,
InputNumber,
Row,
Select,
Space,
Statistic,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd'
import { Alert, Button, Card, Col, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd'
import dayjs from 'dayjs'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useObjects, useObjectsMeta } from '../api/objects'
import {
useDashboardChecks,
useDashboardHomeSummary,
useDashboardMonitorStatus,
useDashboardPolicy,
useDashboardRunNow,
usePatchDashboardChecks,
usePatchDashboardObjectOverrides,
usePatchDashboardObjectPolicy,
usePatchDashboardTagPolicy,
type DashboardObjectOverride,
} from '../api/dashboard'
import { useDashboardHomeSummary, useDashboardMonitorStatus, useDashboardRunNow, type DashboardScope } from '../api/dashboard'
import { useTags } from '../api/tags'
import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE'
const CATEGORY_OPTIONS = [
'main_server',
'vm',
'router',
'embedded',
'camera',
'io_board',
'bank_terminal',
'cash_register',
'other',
]
type OverrideEnabledMode = 'inherit' | 'enabled' | 'disabled'
type OverrideCategoriesMode = 'inherit' | 'custom'
function formatAge(seconds: number | null | undefined): string {
if (seconds == null) return '-'
if (seconds < 60) return `${seconds}с`
if (seconds < 3600) return `${Math.floor(seconds / 60)}м`
return `${Math.floor(seconds / 3600)}ч ${Math.floor((seconds % 3600) / 60)}м`
if (seconds < 60) return `${seconds}ñ`
if (seconds < 3600) return `${Math.floor(seconds / 60)}ì`
return `${Math.floor(seconds / 3600)}÷ ${Math.floor((seconds % 3600) / 60)}ì`
}
function reasonLabel(reason: string): string {
const map: Record<string, string> = {
object_include_policy: 'Âêëþ÷åí ïîëèòèêîé îáúåêòà',
object_exclude_policy: 'Èñêëþ÷åí ïîëèòèêîé îáúåêòà',
inherit_default: 'Íàñëåäîâàíèå ïî óìîë÷àíèþ',
tag_policy_enabled: 'Âêëþ÷åí ïîëèòèêîé òåãîâ',
tag_policy_disabled: 'Èñêëþ÷åí ïîëèòèêîé òåãîâ',
scope_filtered: 'Âíå òåêóùåãî scope',
}
return map[reason] ?? reason
}
function scopeToSearch(scope: DashboardScope): string {
const params = new URLSearchParams()
if (scope.city) params.set('city', scope.city)
if (scope.client) params.set('client', scope.client)
for (const id of scope.object_ids ?? []) params.append('object_ids', String(id))
for (const id of scope.tag_ids ?? []) params.append('tag_ids', String(id))
const raw = params.toString()
return raw ? `?${raw}` : ''
}
export function HomePage() {
const qc = useQueryClient()
const navigate = useNavigate()
const { data: meta } = useObjectsMeta()
const { data: objects } = useObjects()
@ -75,113 +57,54 @@ export function HomePage() {
const { data: summary, isLoading } = useDashboardHomeSummary(scope)
const { data: monitorStatus } = useDashboardMonitorStatus()
const { data: policy } = useDashboardPolicy()
const { data: checksData } = useDashboardChecks()
const runNow = useDashboardRunNow()
const patchTagPolicy = usePatchDashboardTagPolicy()
const patchObjectPolicy = usePatchDashboardObjectPolicy()
const patchChecks = usePatchDashboardChecks()
const patchOverrides = usePatchDashboardObjectOverrides()
const [overrideObjectId, setOverrideObjectId] = useState<number | undefined>()
const [overrideCheckKey, setOverrideCheckKey] = useState<string | undefined>()
const [overrideInterval, setOverrideInterval] = useState<number | undefined>()
const [overrideEnabledMode, setOverrideEnabledMode] = useState<OverrideEnabledMode>('inherit')
const [overrideCategoriesMode, setOverrideCategoriesMode] = useState<OverrideCategoriesMode>('inherit')
const [overrideCategories, setOverrideCategories] = useState<string[]>([])
const tagPolicyMap = useMemo(() => {
const map = new Map<number, boolean>()
for (const row of policy?.tag_policies ?? []) map.set(row.tag_id, row.enabled)
return map
}, [policy])
const objectPolicyMap = useMemo(() => {
const map = new Map<number, 'inherit' | 'include' | 'exclude'>()
for (const row of policy?.object_policies ?? []) map.set(row.object_id, row.mode)
return map
}, [policy])
const overrideMap = useMemo(() => {
const map = new Map<string, DashboardObjectOverride>()
for (const row of checksData?.object_overrides ?? []) {
map.set(`${row.object_id}:${row.check_key}`, row)
}
return map
}, [checksData])
useEffect(() => {
if (!overrideObjectId || !overrideCheckKey) {
setOverrideEnabledMode('inherit')
setOverrideInterval(undefined)
setOverrideCategoriesMode('inherit')
setOverrideCategories([])
return
}
const current = overrideMap.get(`${overrideObjectId}:${overrideCheckKey}`)
if (!current) {
setOverrideEnabledMode('inherit')
setOverrideInterval(undefined)
setOverrideCategoriesMode('inherit')
setOverrideCategories([])
return
}
if (current.enabled_override === true) setOverrideEnabledMode('enabled')
else if (current.enabled_override === false) setOverrideEnabledMode('disabled')
else setOverrideEnabledMode('inherit')
setOverrideInterval(current.interval_sec_override ?? undefined)
if (current.categories_override && current.categories_override.length > 0) {
setOverrideCategoriesMode('custom')
setOverrideCategories(current.categories_override)
} else {
setOverrideCategoriesMode('inherit')
setOverrideCategories([])
}
}, [overrideObjectId, overrideCheckKey, overrideMap])
useDashboardMonitorSSE({
onUpdate: () => {
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
qc.invalidateQueries({ queryKey: ['dashboard-monitor-status'] })
qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] })
qc.invalidateQueries({ queryKey: ['dashboard-object-devices'] })
qc.invalidateQueries({ queryKey: ['dashboard-object-availability'] })
},
})
const handleRunNow = async () => {
try {
await runNow.mutateAsync({ ...scope, force: true })
message.success('Мониторинг запущен')
message.success('Ìîíèòîðèíã çàïóùåí')
} catch {
message.error('Не удалось запустить мониторинг')
message.error('Íå óäàëîñü çàïóñòèòü ìîíèòîðèíã')
}
}
const totals = summary?.totals ?? { objects: 0, devices: 0, allowed_objects: 0 }
const problemObjects = summary?.problem_objects ?? []
const totals = summary?.totals ?? { objects: 0, devices: 0, allowed_objects: 0, excluded_objects: 0 }
const summaryColumns = [
const columns = [
{
title: 'Объект',
title: 'Îáúåêò',
key: 'object',
render: (_: unknown, row: any) => (
<Space>
<Typography.Text strong>{row.object_name}</Typography.Text>
<Button
type="link"
style={{ padding: 0 }}
onClick={() => navigate(`/home/objects/${row.object_id}${scopeToSearch(scope)}`)}
>
{row.object_name}
</Button>
{row.city && <Tag>{row.city}</Tag>}
{row.client && <Tag color="blue">{row.client}</Tag>}
</Space>
),
},
{
title: 'Политика',
key: 'policy',
render: (_: unknown, row: any) => <Tag color={row.allowed ? 'green' : 'red'}>{row.monitor_mode}</Tag>,
title: 'Ïðè÷èíà',
key: 'reason',
render: (_: unknown, row: any) => <Typography.Text>{reasonLabel(row.inclusion_reason)}</Typography.Text>,
},
{
title: 'Устройств',
title: 'Óñòðîéñòâ',
dataIndex: 'device_count',
key: 'device_count',
},
@ -201,22 +124,6 @@ export function HomePage() {
)
},
},
{
title: 'Диски',
key: 'disk',
render: (_: unknown, row: any) => {
const disk = row.checks?.disk_usage
const cnt = disk?.status_counts ?? {}
return (
<Space direction="vertical" size={0}>
<span>ok {cnt.ok ?? 0} / warn {cnt.warn ?? 0} / failed {cnt.failed ?? 0}</span>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
age: {formatAge(disk?.max_age_sec)}
</Typography.Text>
</Space>
)
},
},
{
title: 'Health',
key: 'health',
@ -230,14 +137,22 @@ export function HomePage() {
return (
<div>
<Typography.Title level={4} style={{ marginTop: 0 }}>
Главная
</Typography.Title>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }} align="center">
<Typography.Title level={4} style={{ margin: 0 }}>
NOC Äåøáîðä
</Typography.Title>
<Space>
<Button onClick={() => navigate(`/home/settings${scopeToSearch(scope)}`)}>Íàñòðîéêè ìîíèòîðèíãà</Button>
<Button type="primary" onClick={handleRunNow} loading={runNow.isPending}>
Çàïóñòèòü ñåé÷àñ
</Button>
</Space>
</Space>
<Row gutter={12} style={{ marginBottom: 12 }}>
<Col span={5}>
<Select
placeholder="Город"
placeholder="Ãîðîä"
allowClear
style={{ width: '100%' }}
options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))}
@ -247,7 +162,7 @@ export function HomePage() {
</Col>
<Col span={5}>
<Select
placeholder="Клиент"
placeholder="Êëèåíò"
allowClear
style={{ width: '100%' }}
options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))}
@ -258,7 +173,7 @@ export function HomePage() {
<Col span={7}>
<Select
mode="multiple"
placeholder="Объекты"
placeholder="Îáúåêòû"
allowClear
style={{ width: '100%' }}
value={objectIds}
@ -269,7 +184,7 @@ export function HomePage() {
<Col span={7}>
<Select
mode="multiple"
placeholder="Теги"
placeholder="Òåãè"
allowClear
style={{ width: '100%' }}
value={tagIds}
@ -280,16 +195,11 @@ export function HomePage() {
</Row>
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col>
<Button type="primary" onClick={handleRunNow} loading={runNow.isPending}>
Запустить сейчас
</Button>
</Col>
<Col>
<Typography.Text type="secondary">
Статус: {monitorStatus?.status ?? '-'}
Ñòàòóñ: {monitorStatus?.status ?? '-'}
{monitorStatus?.last_success_at
? ` · успешный запуск ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}`
? ` · óñïåøíûé çàïóñê ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}`
: ''}
</Typography.Text>
</Col>
@ -300,270 +210,57 @@ export function HomePage() {
)}
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card><Statistic title="Объекты" value={totals.objects} /></Card>
<Col span={6}>
<Card><Statistic title="Îáúåêòû" value={totals.objects} /></Card>
</Col>
<Col span={8}>
<Card><Statistic title="В мониторинге" value={totals.allowed_objects} /></Card>
<Col span={6}>
<Card><Statistic title="Â ìîíèòîðèíãå" value={summary?.included_count ?? totals.allowed_objects} /></Card>
</Col>
<Col span={8}>
<Card><Statistic title="Устройства" value={totals.devices} /></Card>
<Col span={6}>
<Card><Statistic title="Âíå ìîíèòîðèíãà" value={summary?.excluded_count ?? totals.excluded_objects} /></Card>
</Col>
<Col span={6}>
<Card><Statistic title="Óñòðîéñòâà" value={totals.devices} /></Card>
</Col>
</Row>
<Card title="Расписание проверок" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: '100%' }}>
{(checksData?.checks ?? []).map((check) => (
<Row key={check.check_key} gutter={12} align="middle">
<Col span={4}><Typography.Text strong>{check.check_key}</Typography.Text></Col>
<Col span={3}>
<Switch
checked={check.enabled}
onChange={async (enabled) => {
await patchChecks.mutateAsync([{ check_key: check.check_key, enabled }])
}}
/>
</Col>
<Col span={5}>
<InputNumber
min={30}
value={check.interval_sec_default}
addonAfter="sec"
onBlur={async (e) => {
const val = Number((e.target as HTMLInputElement).value)
if (!Number.isFinite(val) || val <= 0) return
await patchChecks.mutateAsync([{ check_key: check.check_key, interval_sec_default: val }])
}}
/>
</Col>
<Col span={10}>
<Select
mode="multiple"
allowClear
style={{ width: '100%' }}
value={check.categories}
options={CATEGORY_OPTIONS.map((c) => ({ value: c, label: c }))}
onChange={async (categories) => {
await patchChecks.mutateAsync([{ check_key: check.check_key, categories }])
}}
/>
</Col>
</Row>
))}
</Space>
</Card>
<Card title="Политика по тегам" style={{ marginBottom: 16 }}>
<Row gutter={[12, 8]}>
{(tags ?? []).map((tag) => (
<Col span={8} key={tag.id}>
<Space>
<Tag color={tag.color}>{tag.name}</Tag>
<Switch
size="small"
checked={tagPolicyMap.get(tag.id) ?? true}
onChange={async (enabled) => {
await patchTagPolicy.mutateAsync([{ tag_id: tag.id, enabled }])
}}
/>
</Space>
</Col>
))}
</Row>
</Card>
<Card title="Политика объектов и overrides" style={{ marginBottom: 16 }}>
<Card title="Ïðîáëåìíûå îáúåêòû" style={{ marginBottom: 16 }}>
<Table
size="small"
rowKey="id"
pagination={{ pageSize: 8 }}
dataSource={objects ?? []}
columns={[
{ title: 'Объект', dataIndex: 'name', key: 'name' },
{
title: 'Режим',
key: 'mode',
render: (_: unknown, row: any) => (
<Select
value={objectPolicyMap.get(row.id) ?? 'inherit'}
style={{ width: 150 }}
options={[
{ value: 'inherit', label: 'inherit' },
{ value: 'include', label: 'include' },
{ value: 'exclude', label: 'exclude' },
]}
onChange={async (mode) => {
await patchObjectPolicy.mutateAsync([{ object_id: row.id, mode }])
}}
/>
),
},
]}
/>
<Divider style={{ margin: '12px 0' }} />
<Space wrap>
<Select
placeholder="Объект"
style={{ width: 220 }}
value={overrideObjectId}
onChange={setOverrideObjectId}
options={(objects ?? []).map((o) => ({ value: o.id, label: o.name }))}
/>
<Select
placeholder="Проверка"
style={{ width: 160 }}
value={overrideCheckKey}
onChange={setOverrideCheckKey}
options={(checksData?.checks ?? []).map((c) => ({ value: c.check_key, label: c.check_key }))}
/>
<Select
placeholder="Enabled override"
style={{ width: 170 }}
value={overrideEnabledMode}
onChange={(v) => setOverrideEnabledMode(v as OverrideEnabledMode)}
options={[
{ value: 'inherit', label: 'inherit' },
{ value: 'enabled', label: 'enabled' },
{ value: 'disabled', label: 'disabled' },
]}
/>
<InputNumber
placeholder="Интервал (сек)"
min={30}
value={overrideInterval}
onChange={(v) => setOverrideInterval(typeof v === 'number' ? v : undefined)}
/>
<Select
placeholder="Режим категорий"
style={{ width: 170 }}
value={overrideCategoriesMode}
onChange={(v) => setOverrideCategoriesMode(v as OverrideCategoriesMode)}
options={[
{ value: 'inherit', label: 'inherit' },
{ value: 'custom', label: 'custom' },
]}
/>
<Select
mode="multiple"
allowClear
disabled={overrideCategoriesMode !== 'custom'}
placeholder="Категории override"
style={{ width: 280 }}
value={overrideCategories}
onChange={setOverrideCategories}
options={CATEGORY_OPTIONS.map((c) => ({ value: c, label: c }))}
/>
<Button
type="primary"
onClick={async () => {
if (!overrideObjectId || !overrideCheckKey) {
message.warning('Выберите объект и проверку')
return
}
await patchOverrides.mutateAsync([
{
object_id: overrideObjectId,
check_key: overrideCheckKey,
enabled_override:
overrideEnabledMode === 'inherit'
? null
: overrideEnabledMode === 'enabled',
interval_sec_override: overrideInterval ?? null,
categories_override:
overrideCategoriesMode === 'custom'
? overrideCategories
: null,
},
])
message.success('Override обновлен')
}}
>
Сохранить override
</Button>
<Button
onClick={async () => {
if (!overrideObjectId || !overrideCheckKey) {
message.warning('Выберите объект и проверку')
return
}
await patchOverrides.mutateAsync([
{
object_id: overrideObjectId,
check_key: overrideCheckKey,
enabled_override: null,
interval_sec_override: null,
categories_override: null,
},
])
message.success('Override очищен')
}}
>
Очистить override
</Button>
</Space>
<Divider style={{ margin: '12px 0' }} />
<Table
size="small"
rowKey={(row) => `${row.object_id}:${row.check_key}`}
pagination={{ pageSize: 6 }}
dataSource={checksData?.object_overrides ?? []}
columns={[
{
title: 'Объект',
key: 'object',
render: (_: unknown, row: DashboardObjectOverride) =>
objects?.find((o) => o.id === row.object_id)?.name ?? row.object_id,
},
{ title: 'Проверка', dataIndex: 'check_key', key: 'check_key' },
{
title: 'Enabled',
key: 'enabled_override',
render: (_: unknown, row: DashboardObjectOverride) =>
row.enabled_override == null ? 'inherit' : row.enabled_override ? 'enabled' : 'disabled',
},
{
title: 'Интервал',
key: 'interval_sec_override',
render: (_: unknown, row: DashboardObjectOverride) =>
row.interval_sec_override == null ? 'inherit' : `${row.interval_sec_override}s`,
},
{
title: 'Категории',
key: 'categories_override',
render: (_: unknown, row: DashboardObjectOverride) =>
row.categories_override == null
? 'inherit'
: row.categories_override.length === 0
? 'all'
: row.categories_override.join(', '),
},
]}
/>
</Card>
<Card title="Проблемные объекты" style={{ marginBottom: 16 }}>
<Table
size="small"
dataSource={problemObjects}
dataSource={summary?.problem_objects ?? []}
rowKey="object_id"
columns={summaryColumns}
columns={columns}
pagination={false}
loading={isLoading}
/>
</Card>
<Card title="Сводка по объектам">
<Table
loading={isLoading}
dataSource={summary?.objects ?? []}
rowKey="object_id"
columns={summaryColumns}
pagination={{ pageSize: 12 }}
/>
</Card>
<Row gutter={12}>
<Col span={12}>
<Card title="Â ìîíèòîðèíãå" style={{ marginBottom: 16 }}>
<Table
size="small"
dataSource={summary?.included_objects ?? []}
rowKey="object_id"
columns={columns}
pagination={{ pageSize: 8 }}
loading={isLoading}
/>
</Card>
</Col>
<Col span={12}>
<Card title="Âíå ìîíèòîðèíãà" style={{ marginBottom: 16 }}>
<Table
size="small"
dataSource={summary?.excluded_objects ?? []}
rowKey="object_id"
columns={columns}
pagination={{ pageSize: 8 }}
loading={isLoading}
/>
</Card>
</Col>
</Row>
</div>
)
}

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