trying homepage

This commit is contained in:
dv 2026-05-05 11:32:25 +03:00
parent 826e0b4198
commit 0f4fb999cc
18 changed files with 2259 additions and 3 deletions

View file

@ -5,6 +5,8 @@ DATABASE_MAX_OVERFLOW=20
DATABASE_POOL_TIMEOUT=30 DATABASE_POOL_TIMEOUT=30
TASK_DEVICE_CONCURRENCY=10 TASK_DEVICE_CONCURRENCY=10
MONITOR_PING_CONCURRENCY=20 MONITOR_PING_CONCURRENCY=20
DASHBOARD_MONITOR_TICK_SECONDS=60
DASHBOARD_MONITOR_SYNC_DEVICE_STATUS=false
# ── Security ────────────────────────────────────────────────────────────────── # ── Security ──────────────────────────────────────────────────────────────────
# Generate with: python -c "import secrets; print(secrets.token_hex(32))" # Generate with: python -c "import secrets; print(secrets.token_hex(32))"

View file

@ -0,0 +1,106 @@
"""0016 dashboard monitor tables
Revision ID: 0016
Revises: 0015
Create Date: 2026-05-05
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0016"
down_revision = "0015"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"dashboard_check_definitions",
sa.Column("check_key", sa.String(length=64), primary_key=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("interval_sec_default", sa.Integer(), nullable=False),
sa.Column("plugin_action", sa.String(length=128), nullable=False),
sa.Column("categories", postgresql.ARRAY(sa.String()), nullable=False),
sa.Column("timeout_sec", sa.Integer(), nullable=False),
sa.Column("concurrency", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"dashboard_object_monitor_policy",
sa.Column("object_id", sa.Integer(), sa.ForeignKey("objects.id", ondelete="CASCADE"), primary_key=True),
sa.Column("mode", sa.String(length=16), nullable=False, server_default="inherit"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"dashboard_tag_monitor_policy",
sa.Column("tag_id", sa.Integer(), sa.ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"dashboard_object_check_overrides",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("object_id", sa.Integer(), sa.ForeignKey("objects.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("enabled_override", sa.Boolean(), nullable=True),
sa.Column("interval_sec_override", sa.Integer(), nullable=True),
sa.Column("categories_override", postgresql.ARRAY(sa.String()), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("object_id", "check_key", name="uq_dashboard_obj_check"),
)
op.create_index("ix_dashboard_object_check_overrides_object_id", "dashboard_object_check_overrides", ["object_id"])
op.create_table(
"dashboard_device_live_state",
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("value_json", postgresql.JSONB(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column("source", sa.String(length=32), nullable=False, server_default="dashboard_monitor"),
sa.UniqueConstraint("device_id", "check_key", name="uq_dashboard_device_check"),
)
op.create_index("ix_dashboard_device_live_state_device_id", "dashboard_device_live_state", ["device_id"])
op.create_index("ix_dashboard_device_live_state_check_key", "dashboard_device_live_state", ["check_key"])
op.create_index("ix_dashboard_device_live_state_checked_at", "dashboard_device_live_state", ["checked_at"])
op.create_table(
"dashboard_monitor_run_state",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="idle"),
sa.Column("run_type", sa.String(length=20), nullable=True),
sa.Column("scope", postgresql.JSONB(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("last_counts", postgresql.JSONB(), nullable=True),
)
def downgrade() -> None:
op.drop_table("dashboard_monitor_run_state")
op.drop_index("ix_dashboard_device_live_state_checked_at", table_name="dashboard_device_live_state")
op.drop_index("ix_dashboard_device_live_state_check_key", table_name="dashboard_device_live_state")
op.drop_index("ix_dashboard_device_live_state_device_id", table_name="dashboard_device_live_state")
op.drop_table("dashboard_device_live_state")
op.drop_index("ix_dashboard_object_check_overrides_object_id", table_name="dashboard_object_check_overrides")
op.drop_table("dashboard_object_check_overrides")
op.drop_table("dashboard_tag_monitor_policy")
op.drop_table("dashboard_object_monitor_policy")
op.drop_table("dashboard_check_definitions")

View file

@ -23,6 +23,8 @@ class Settings(BaseSettings):
TASK_DEVICE_CONCURRENCY: int = 10 TASK_DEVICE_CONCURRENCY: int = 10
MONITOR_PING_CONCURRENCY: int = 20 MONITOR_PING_CONCURRENCY: int = 20
DASHBOARD_MONITOR_TICK_SECONDS: int = 60
DASHBOARD_MONITOR_SYNC_DEVICE_STATUS: bool = False
settings = Settings() settings = Settings()

View file

@ -7,7 +7,9 @@ from fastapi.openapi.utils import get_openapi
from app.middleware.auth import AuthMiddleware from app.middleware.auth import AuthMiddleware
from app.plugins.loader import load_plugins from app.plugins.loader import load_plugins
from app.routers import admin, alerts, auth, devices, events, global_devices, objects, racks, snapshots, ssh_keys, tags, tasks, zones from app.routers import admin, alerts, auth, dashboard, devices, events, global_devices, objects, racks, snapshots, ssh_keys, tags, tasks, zones
from app.services.dashboard_monitor import ensure_dashboard_bootstrap
from app.workers.dashboard_monitor import start_dashboard_monitor, stop_dashboard_monitor
from app.workers.monitor import start_monitor, stop_monitor from app.workers.monitor import start_monitor, stop_monitor
from app.workers.startup import recover_stale_tasks from app.workers.startup import recover_stale_tasks
@ -22,8 +24,11 @@ logging.basicConfig(
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await load_plugins() await load_plugins()
await recover_stale_tasks() await recover_stale_tasks()
await ensure_dashboard_bootstrap()
start_monitor() start_monitor()
start_dashboard_monitor()
yield yield
await stop_dashboard_monitor()
await stop_monitor() await stop_monitor()
@ -59,6 +64,7 @@ def create_app() -> FastAPI:
app.include_router(snapshots.router) app.include_router(snapshots.router)
app.include_router(tasks.router) app.include_router(tasks.router)
app.include_router(events.router) app.include_router(events.router)
app.include_router(dashboard.router)
def custom_openapi(): def custom_openapi():
if app.openapi_schema: if app.openapi_schema:

View file

@ -10,6 +10,14 @@ from .snapshot import ConfigSnapshot, SnapshotContent
from .alert import Alert from .alert import Alert
from .plugin import Plugin from .plugin import Plugin
from .ssh_key import SSHKey, ObjectSSHCredential, ObjectCategoryCredential, GlobalSSHCredential from .ssh_key import SSHKey, ObjectSSHCredential, ObjectCategoryCredential, GlobalSSHCredential
from .dashboard import (
DashboardCheckDefinition,
DashboardObjectCheckOverride,
DashboardDeviceLiveState,
DashboardObjectMonitorPolicy,
DashboardTagMonitorPolicy,
DashboardMonitorRunState,
)
__all__ = [ __all__ = [
"Base", "Base",
@ -33,4 +41,10 @@ __all__ = [
"ObjectSSHCredential", "ObjectSSHCredential",
"ObjectCategoryCredential", "ObjectCategoryCredential",
"GlobalSSHCredential", "GlobalSSHCredential",
"DashboardCheckDefinition",
"DashboardObjectCheckOverride",
"DashboardDeviceLiveState",
"DashboardObjectMonitorPolicy",
"DashboardTagMonitorPolicy",
"DashboardMonitorRunState",
] ]

View file

@ -0,0 +1,98 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base, TimestampMixin
CHECK_STATUS_VALUES = (
"online",
"offline",
"unknown",
"ok",
"warn",
"failed",
)
MONITOR_POLICY_MODES = ("inherit", "include", "exclude")
class DashboardCheckDefinition(Base, TimestampMixin):
__tablename__ = "dashboard_check_definitions"
check_key: Mapped[str] = mapped_column(String(64), primary_key=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
interval_sec_default: Mapped[int] = mapped_column(Integer, nullable=False, default=300)
plugin_action: Mapped[str] = mapped_column(String(128), nullable=False)
categories: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=list)
timeout_sec: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
concurrency: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
class DashboardObjectCheckOverride(Base, TimestampMixin):
__tablename__ = "dashboard_object_check_overrides"
__table_args__ = (UniqueConstraint("object_id", "check_key", name="uq_dashboard_obj_check"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
object_id: Mapped[int] = mapped_column(
Integer, ForeignKey("objects.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
)
enabled_override: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
interval_sec_override: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
categories_override: Mapped[Optional[list[str]]] = mapped_column(ARRAY(String), nullable=True)
class DashboardDeviceLiveState(Base):
__tablename__ = "dashboard_device_live_state"
__table_args__ = (UniqueConstraint("device_id", "check_key", name="uq_dashboard_device_check"),)
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")
value_json: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
error: Mapped[Optional[str]] = mapped_column(Text, 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)
source: Mapped[str] = mapped_column(String(32), nullable=False, default="dashboard_monitor")
class DashboardObjectMonitorPolicy(Base, TimestampMixin):
__tablename__ = "dashboard_object_monitor_policy"
object_id: Mapped[int] = mapped_column(
Integer, ForeignKey("objects.id", ondelete="CASCADE"), primary_key=True
)
mode: Mapped[str] = mapped_column(String(16), nullable=False, default="inherit")
class DashboardTagMonitorPolicy(Base, TimestampMixin):
__tablename__ = "dashboard_tag_monitor_policy"
tag_id: Mapped[int] = mapped_column(
Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True
)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
class DashboardMonitorRunState(Base):
__tablename__ = "dashboard_monitor_run_state"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="idle")
run_type: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
scope: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_success_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
last_counts: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)

View file

@ -0,0 +1,106 @@
"""
Collects disk usage stats over SSH using df.
Designed for dashboard healthcheck loop.
"""
from app.models.device import Device
from app.models.object import Object
from app.plugins.base import ActionResult, BaseAction, Emitter
from app.services import ssh as ssh_service
_CMD = "df -P -k -x tmpfs -x devtmpfs"
class DiskUsageAction(BaseAction):
"""Collects disk usage (df) and returns normalized stats."""
name = "disk_usage"
display_name = "Disk Usage"
compatible_categories = ["main_server", "vm"]
params_schema = []
async def execute(self, device: Device, obj: Object, params: dict, emit: Emitter, db=None) -> ActionResult:
await emit(f"[{device.ip}] Checking disk usage...", "info")
result = await ssh_service.run_command(device, obj, _CMD, timeout=30, db=db)
if result.error:
await emit(f"[{device.ip}] disk usage error: {result.error}", "error")
return ActionResult(success=False, error=result.error, stdout=result.stdout, stderr=result.stderr)
if result.exit_code != 0:
return ActionResult(
success=False,
error=f"df returned non-zero exit code: {result.exit_code}",
stdout=result.stdout,
stderr=result.stderr,
exit_code=result.exit_code,
)
disks = _parse_df(result.stdout)
if not disks:
return ActionResult(
success=False,
error="No disk rows parsed from df output",
stdout=result.stdout,
stderr=result.stderr,
exit_code=result.exit_code,
)
top = max(disks, key=lambda d: d["pct"])
max_pct = top["pct"]
max_mount = top["mount"]
await emit(f"[{device.ip}] max disk usage: {max_pct}% ({max_mount})", "info")
return ActionResult(
success=True,
stdout=result.stdout,
stderr=result.stderr,
exit_code=result.exit_code,
data={
"disks": disks,
"max_pct": max_pct,
"max_mount": max_mount,
},
)
def _parse_df(output: str) -> list[dict]:
rows: list[dict] = []
for line in output.splitlines():
line = line.strip()
if not line or line.startswith("Filesystem"):
continue
parts = line.split()
if len(parts) < 6:
continue
filesystem = parts[0]
total_kb = _to_int(parts[1])
used_kb = _to_int(parts[2])
avail_kb = _to_int(parts[3])
pct_raw = parts[4].rstrip("%")
mount = parts[5]
pct = _to_int(pct_raw)
if pct is None:
continue
rows.append(
{
"filesystem": filesystem,
"total_kb": total_kb,
"used_kb": used_kb,
"avail_kb": avail_kb,
"pct": pct,
"mount": mount,
}
)
return rows
def _to_int(value: str):
try:
return int(value)
except Exception:
return None

View file

@ -26,6 +26,7 @@ from app.plugins.builtin.mikrotik.get_config import MikrotikGetConfigAction
from app.plugins.builtin.mikrotik.get_ntp import MikrotikGetNTPAction from app.plugins.builtin.mikrotik.get_ntp import MikrotikGetNTPAction
from app.plugins.builtin.mikrotik.set_ntp import MikrotikSetNTPAction from app.plugins.builtin.mikrotik.set_ntp import MikrotikSetNTPAction
from app.plugins.builtin.ssh.debian_system_info import DebianSystemInfoAction from app.plugins.builtin.ssh.debian_system_info import DebianSystemInfoAction
from app.plugins.builtin.ssh.disk_usage import DiskUsageAction
from app.plugins.builtin.ssh.get_file import GetFileAction from app.plugins.builtin.ssh.get_file import GetFileAction
from app.plugins.builtin.ssh.get_timedatectl import GetTimedatectlAction from app.plugins.builtin.ssh.get_timedatectl import GetTimedatectlAction
from app.plugins.builtin.ssh.ssh_command import SSHCommandAction from app.plugins.builtin.ssh.ssh_command import SSHCommandAction
@ -60,6 +61,7 @@ BUILTIN_ACTIONS: dict[str, BaseAction] = {
PingAction(), PingAction(),
SSHCommandAction(), SSHCommandAction(),
DebianSystemInfoAction(), DebianSystemInfoAction(),
DiskUsageAction(),
_timedatectl, _timedatectl,
GetFileAction(), GetFileAction(),
MikrotikGetConfigAction(), MikrotikGetConfigAction(),

View file

@ -0,0 +1,92 @@
from fastapi import APIRouter, Depends
from app.dependencies import get_current_user
from app.models.user import User
from app.schemas.dashboard import (
DashboardCheckDefinitionPatchItem,
DashboardChecksResponse,
DashboardHomeSummaryResponse,
DashboardMonitorStatus,
DashboardObjectOverridePatchItem,
DashboardObjectPolicyPatchItem,
DashboardPolicyResponse,
DashboardRunRequest,
DashboardRunResponse,
DashboardTagPolicyPatchItem,
)
from app.services import dashboard_monitor as dashboard_service
router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"])
@router.get("/home-summary", response_model=DashboardHomeSummaryResponse)
async def home_summary(
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_home_summary(scope)
return DashboardHomeSummaryResponse.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()
return DashboardMonitorStatus.model_validate(row)
@router.post("/monitor/run", response_model=DashboardRunResponse)
async def monitor_run(body: DashboardRunRequest, _: User = Depends(get_current_user)):
accepted, running, detail = await dashboard_service.request_manual_run(
scope=body.model_dump(exclude={"force"}),
force=body.force,
)
return DashboardRunResponse(accepted=accepted, running=running, detail=detail)
@router.get("/monitor/policy", response_model=DashboardPolicyResponse)
async def monitor_policy(_: User = Depends(get_current_user)):
object_policies, tag_policies = await dashboard_service.get_policy()
return DashboardPolicyResponse(object_policies=object_policies, tag_policies=tag_policies)
@router.patch("/monitor/policy/objects", response_model=DashboardPolicyResponse)
async def patch_object_policy(body: list[DashboardObjectPolicyPatchItem], _: User = Depends(get_current_user)):
await dashboard_service.patch_object_policy([i.model_dump() for i in body])
object_policies, tag_policies = await dashboard_service.get_policy()
return DashboardPolicyResponse(object_policies=object_policies, tag_policies=tag_policies)
@router.patch("/monitor/policy/tags", response_model=DashboardPolicyResponse)
async def patch_tag_policy(body: list[DashboardTagPolicyPatchItem], _: User = Depends(get_current_user)):
await dashboard_service.patch_tag_policy([i.model_dump() for i in body])
object_policies, tag_policies = await dashboard_service.get_policy()
return DashboardPolicyResponse(object_policies=object_policies, tag_policies=tag_policies)
@router.get("/checks", response_model=DashboardChecksResponse)
async def dashboard_checks(_: User = Depends(get_current_user)):
checks, overrides = await dashboard_service.list_checks_with_overrides()
return DashboardChecksResponse(checks=checks, object_overrides=overrides)
@router.patch("/checks", response_model=DashboardChecksResponse)
async def patch_checks(body: list[DashboardCheckDefinitionPatchItem], _: User = Depends(get_current_user)):
await dashboard_service.patch_checks([item.model_dump(exclude_none=True) for item in body])
checks, overrides = await dashboard_service.list_checks_with_overrides()
return DashboardChecksResponse(checks=checks, object_overrides=overrides)
@router.patch("/checks/object-overrides", response_model=DashboardChecksResponse)
async def patch_object_overrides(body: list[DashboardObjectOverridePatchItem], _: User = Depends(get_current_user)):
await dashboard_service.patch_object_overrides([item.model_dump(exclude_none=False) for item in body])
checks, overrides = await dashboard_service.list_checks_with_overrides()
return DashboardChecksResponse(checks=checks, object_overrides=overrides)

View file

@ -0,0 +1,139 @@
from datetime import datetime
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
MonitorPolicyMode = Literal["inherit", "include", "exclude"]
class DashboardScope(BaseModel):
city: Optional[str] = None
client: Optional[str] = None
object_ids: list[int] = Field(default_factory=list)
tag_ids: list[int] = Field(default_factory=list)
class DashboardCheckDefinitionRead(BaseModel):
check_key: str
enabled: bool
interval_sec_default: int
plugin_action: str
categories: list[str]
timeout_sec: int
concurrency: int
updated_at: datetime
model_config = {"from_attributes": True}
class DashboardCheckDefinitionPatchItem(BaseModel):
check_key: str
enabled: Optional[bool] = None
interval_sec_default: Optional[int] = None
categories: Optional[list[str]] = None
timeout_sec: Optional[int] = None
concurrency: Optional[int] = None
class DashboardObjectOverrideRead(BaseModel):
object_id: int
check_key: str
enabled_override: Optional[bool] = None
interval_sec_override: Optional[int] = None
categories_override: Optional[list[str]] = None
updated_at: datetime
model_config = {"from_attributes": True}
class DashboardObjectOverridePatchItem(BaseModel):
object_id: int
check_key: str
enabled_override: Optional[bool] = None
interval_sec_override: Optional[int] = None
categories_override: Optional[list[str]] = None
class DashboardObjectPolicyRead(BaseModel):
object_id: int
mode: MonitorPolicyMode
updated_at: datetime
model_config = {"from_attributes": True}
class DashboardObjectPolicyPatchItem(BaseModel):
object_id: int
mode: MonitorPolicyMode
class DashboardTagPolicyRead(BaseModel):
tag_id: int
enabled: bool
updated_at: datetime
model_config = {"from_attributes": True}
class DashboardTagPolicyPatchItem(BaseModel):
tag_id: int
enabled: bool
class DashboardPolicyResponse(BaseModel):
object_policies: list[DashboardObjectPolicyRead]
tag_policies: list[DashboardTagPolicyRead]
class DashboardChecksResponse(BaseModel):
checks: list[DashboardCheckDefinitionRead]
object_overrides: list[DashboardObjectOverrideRead]
class DashboardRunRequest(DashboardScope):
force: bool = True
class DashboardRunResponse(BaseModel):
accepted: bool
running: bool
detail: str
class DashboardMonitorStatus(BaseModel):
status: str
run_type: Optional[str] = None
scope: Optional[dict[str, Any]] = None
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
last_success_at: Optional[datetime] = None
last_error: Optional[str] = None
last_counts: Optional[dict[str, Any]] = None
class DashboardObjectCheckSummary(BaseModel):
status_counts: dict[str, int]
last_checked_at: Optional[datetime] = None
max_age_sec: Optional[int] = None
class DashboardObjectSummary(BaseModel):
object_id: int
object_name: str
city: Optional[str] = None
client: Optional[str] = None
allowed: bool
monitor_mode: MonitorPolicyMode
device_count: int
checks: dict[str, DashboardObjectCheckSummary]
health_score: Optional[float] = None
class DashboardHomeSummaryResponse(BaseModel):
generated_at: datetime
scope: DashboardScope
totals: dict[str, int]
checks_meta: list[DashboardCheckDefinitionRead]
objects: list[DashboardObjectSummary]
problem_objects: list[DashboardObjectSummary]

View file

@ -0,0 +1,804 @@
import asyncio
import time
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.config import settings
from app.models.dashboard import (
DashboardCheckDefinition,
DashboardDeviceLiveState,
DashboardMonitorRunState,
DashboardObjectCheckOverride,
DashboardObjectMonitorPolicy,
DashboardTagMonitorPolicy,
)
from app.models.device import Device
from app.models.object import Object
from app.models.tag import Tag
from app.plugins.registry import action_registry
from app.services.database import AsyncSessionLocal
from app.services.sse import SSEEvent, sse_manager
DASHBOARD_MONITOR_CHANNEL = "__dashboard_monitor__"
DEFAULT_CHECKS = [
{
"check_key": "ping",
"enabled": True,
"interval_sec_default": 300,
"plugin_action": "ping",
"categories": [],
"timeout_sec": 15,
"concurrency": 40,
},
{
"check_key": "disk_usage",
"enabled": True,
"interval_sec_default": 3600,
"plugin_action": "disk_usage",
"categories": ["main_server", "vm"],
"timeout_sec": 45,
"concurrency": 10,
},
]
_RUN_LOCK = asyncio.Lock()
_MANUAL_TASK: asyncio.Task | None = None
async def _noop_emit(_: str, __: str = "info") -> None:
return
async def ensure_dashboard_bootstrap() -> None:
async with AsyncSessionLocal() as db:
async with db.begin():
existing = {
c.check_key: c
for c in (
await db.execute(select(DashboardCheckDefinition))
).scalars().all()
}
for payload in DEFAULT_CHECKS:
row = existing.get(payload["check_key"])
if row is 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:
row.plugin_action = payload["plugin_action"]
if not row.categories:
row.categories = payload["categories"]
if row.timeout_sec <= 0:
row.timeout_sec = payload["timeout_sec"]
if row.concurrency <= 0:
row.concurrency = payload["concurrency"]
run_state = await db.get(DashboardMonitorRunState, 1)
if run_state is None:
db.add(DashboardMonitorRunState(id=1, status="idle"))
async def _get_run_state(db) -> DashboardMonitorRunState:
row = await db.get(DashboardMonitorRunState, 1)
if row is None:
row = DashboardMonitorRunState(id=1, status="idle")
db.add(row)
await db.flush()
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)
async def _load_objects_with_scope(db, scope: dict[str, Any] | None) -> list[Object]:
scope = scope or {}
query = (
select(Object)
.options(selectinload(Object.tags))
.where(Object.is_active == True)
.order_by(Object.city, Object.name)
)
if scope.get("city"):
query = query.where(Object.city == scope["city"])
if scope.get("client"):
query = query.where(Object.client == scope["client"])
if scope.get("object_ids"):
query = query.where(Object.id.in_(scope["object_ids"]))
if scope.get("tag_ids"):
query = query.where(Object.tags.any(Tag.id.in_(scope["tag_ids"])))
result = await db.execute(query)
return list(result.scalars().all())
def _is_object_allowed(
obj: Object,
object_modes: dict[int, str],
tag_modes: dict[int, bool],
) -> tuple[bool, str]:
mode = object_modes.get(obj.id, "inherit")
if mode == "include":
return True, mode
if mode == "exclude":
return False, mode
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
async def _build_object_policy_map(db, objects: list[Object]) -> dict[int, tuple[bool, str]]:
object_ids = [o.id for o in objects]
if not object_ids:
return {}
obj_policy_rows = (
await db.execute(
select(DashboardObjectMonitorPolicy).where(DashboardObjectMonitorPolicy.object_id.in_(object_ids))
)
).scalars().all()
object_modes = {row.object_id: row.mode for row in obj_policy_rows}
tag_ids = {t.id for obj in objects for t in obj.tags}
tag_modes: dict[int, bool] = {}
if tag_ids:
tag_rows = (
await db.execute(
select(DashboardTagMonitorPolicy).where(DashboardTagMonitorPolicy.tag_id.in_(tag_ids))
)
).scalars().all()
tag_modes = {row.tag_id: row.enabled for row in tag_rows}
return {obj.id: _is_object_allowed(obj, object_modes, tag_modes) for obj in objects}
def _pct_from_value(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if not text:
return None
cleaned = text.replace("%", "")
try:
return float(cleaned)
except ValueError:
return None
def _derive_status(check_key: str, success: bool, data: dict[str, Any]) -> tuple[str, dict[str, Any] | None]:
if check_key == "ping":
ping_ms = data.get("ping_ms")
return ("online" if success else "offline", {"ping_ms": ping_ms})
if check_key == "disk_usage":
if not success:
return "failed", None
if isinstance(data.get("max_pct"), (int, float)):
max_pct = float(data.get("max_pct"))
status = "warn" if max_pct >= 90 else "ok"
return status, data
disks = data.get("disks") or []
max_pct = 0.0
top_mount = None
for disk in disks:
pct = _pct_from_value(disk.get("pct"))
if pct is None:
continue
if pct >= max_pct:
max_pct = pct
top_mount = disk.get("mount")
status = "warn" if max_pct >= 90 else "ok"
return status, {
"max_pct": max_pct,
"max_mount": top_mount,
"disks": disks,
}
return ("ok" if success else "failed", data or None)
async def _upsert_live_state(
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:
row = (
await db.execute(
select(DashboardDeviceLiveState).where(
DashboardDeviceLiveState.device_id == device_id,
DashboardDeviceLiveState.check_key == check_key,
)
)
).scalar_one_or_none()
if row is None:
db.add(
DashboardDeviceLiveState(
device_id=device_id,
check_key=check_key,
status=status,
value_json=value_json,
error=error,
checked_at=checked_at,
duration_ms=duration_ms,
source="dashboard_monitor",
)
)
return
row.status = status
row.value_json = value_json
row.error = error
row.checked_at = checked_at
row.duration_ms = duration_ms
row.source = "dashboard_monitor"
async def _run_check_on_device(
sem: asyncio.Semaphore,
check: DashboardCheckDefinition,
device: Device,
obj: Object,
) -> tuple[bool, str, dict[str, Any] | None, str | None, datetime, int]:
async with sem:
action = action_registry.get(check.plugin_action)
now = datetime.now(timezone.utc)
if action is None:
return False, "failed", None, f"Unknown action: {check.plugin_action}", now, 0
started = time.monotonic()
try:
result = await action.execute(device, obj, {}, _noop_emit)
except Exception as exc:
duration_ms = int((time.monotonic() - started) * 1000)
return False, "failed", None, str(exc), now, duration_ms
duration_ms = int((time.monotonic() - started) * 1000)
status, value_json = _derive_status(check.check_key, result.success, result.data or {})
error = result.error or None
return result.success, status, value_json, error, datetime.now(timezone.utc), duration_ms
def _effective_check_settings(
check: DashboardCheckDefinition,
override: DashboardObjectCheckOverride | None,
) -> tuple[bool, int, list[str]]:
enabled = check.enabled
interval = check.interval_sec_default
categories = list(check.categories or [])
if override is not None:
if override.enabled_override is not None:
enabled = override.enabled_override
if override.interval_sec_override is not None:
interval = override.interval_sec_override
if override.categories_override is not None:
categories = list(override.categories_override)
return enabled, interval, categories
async def run_dashboard_monitor_cycle(
*,
run_type: str,
scope: dict[str, Any] | None = None,
force: bool = False,
) -> dict[str, Any] | None:
if _RUN_LOCK.locked():
return None
async with _RUN_LOCK:
await ensure_dashboard_bootstrap()
started_at = datetime.now(timezone.utc)
async with AsyncSessionLocal() as db:
async with db.begin():
run_state = await _get_run_state(db)
run_state.status = "running"
run_state.run_type = run_type
run_state.scope = _scope_to_dict(scope)
run_state.started_at = started_at
run_state.finished_at = None
run_state.last_error = None
counts = {
"objects_in_scope": 0,
"objects_allowed": 0,
"devices_checked": 0,
"checks_executed": 0,
"failed": 0,
"skipped_not_due": 0,
"skipped_policy": 0,
}
try:
async with AsyncSessionLocal() as db:
objects = await _load_objects_with_scope(db, scope)
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]
counts["objects_allowed"] = len(allowed_object_ids)
counts["skipped_policy"] = max(0, len(objects) - len(allowed_object_ids))
if not allowed_object_ids:
async with AsyncSessionLocal() as dbw:
async with dbw.begin():
run_state = await _get_run_state(dbw)
run_state.status = "idle"
run_state.finished_at = datetime.now(timezone.utc)
run_state.last_success_at = datetime.now(timezone.utc)
run_state.last_counts = counts
return counts
device_rows = (
await db.execute(
select(Device)
.options(
selectinload(Device.object).selectinload(Object.ssh_credentials),
selectinload(Device.object).selectinload(Object.category_credentials),
)
.where(Device.is_active == True, Device.object_id.in_(allowed_object_ids))
)
).scalars().all()
checks = (await db.execute(select(DashboardCheckDefinition))).scalars().all()
overrides = (
await db.execute(
select(DashboardObjectCheckOverride).where(
DashboardObjectCheckOverride.object_id.in_(allowed_object_ids)
)
)
).scalars().all()
override_map = {(o.object_id, o.check_key): o for o in overrides}
for check in checks:
check_devices = []
for device in device_rows:
override = override_map.get((device.object_id, check.check_key))
enabled, interval_sec, categories = _effective_check_settings(check, override)
if not enabled:
continue
if categories and device.category not in categories:
continue
check_devices.append((device, interval_sec))
if not check_devices:
continue
state_rows = (
await db.execute(
select(DashboardDeviceLiveState).where(
DashboardDeviceLiveState.check_key == check.check_key,
DashboardDeviceLiveState.device_id.in_([d.id for d, _ in check_devices]),
)
)
).scalars().all()
state_map = {row.device_id: row for row in state_rows}
due_devices: list[Device] = []
for device, interval_sec in check_devices:
if force:
due_devices.append(device)
continue
state = state_map.get(device.id)
if state is None:
due_devices.append(device)
continue
age = (started_at - state.checked_at).total_seconds()
if age >= interval_sec:
due_devices.append(device)
else:
counts["skipped_not_due"] += 1
if not due_devices:
continue
sem = asyncio.Semaphore(max(1, check.concurrency))
results = await asyncio.gather(
*[_run_check_on_device(sem, check, device, device.object) for device in due_devices],
return_exceptions=True,
)
async with AsyncSessionLocal() as dbw:
async with dbw.begin():
for device, result in zip(due_devices, results):
if isinstance(result, Exception):
counts["failed"] += 1
counts["checks_executed"] += 1
await _upsert_live_state(
dbw,
device_id=device.id,
check_key=check.check_key,
status="failed",
value_json=None,
error=str(result),
checked_at=datetime.now(timezone.utc),
duration_ms=0,
)
continue
success, status, value_json, error, checked_at, duration_ms = result
counts["checks_executed"] += 1
counts["devices_checked"] += 1
if not success:
counts["failed"] += 1
await _upsert_live_state(
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"
):
device_row = await dbw.get(Device, device.id)
if device_row is not None:
device_row.status = "online" if status == "online" else "offline"
device_row.last_ping_at = checked_at
if status == "online":
device_row.last_seen = checked_at
ping_ms = (value_json or {}).get("ping_ms")
if ping_ms is not None:
device_row.last_ping_ms = int(ping_ms)
await sse_manager.publish(
SSEEvent(
type="dashboard_monitor_update",
task_id=DASHBOARD_MONITOR_CHANNEL,
data={
"check_key": check.check_key,
"processed": len(due_devices),
"ts": datetime.now(timezone.utc).isoformat(),
},
)
)
async with AsyncSessionLocal() as db:
async with db.begin():
run_state = await _get_run_state(db)
run_state.status = "idle"
run_state.finished_at = datetime.now(timezone.utc)
run_state.last_success_at = datetime.now(timezone.utc)
run_state.last_counts = counts
await sse_manager.publish(
SSEEvent(
type="dashboard_monitor_cycle_done",
task_id=DASHBOARD_MONITOR_CHANNEL,
data={
"run_type": run_type,
"scope": _scope_to_dict(scope),
"counts": counts,
"ts": datetime.now(timezone.utc).isoformat(),
},
)
)
return counts
except Exception as exc:
async with AsyncSessionLocal() as db:
async with db.begin():
run_state = await _get_run_state(db)
run_state.status = "idle"
run_state.finished_at = datetime.now(timezone.utc)
run_state.last_error = str(exc)
run_state.last_counts = counts
await sse_manager.publish(
SSEEvent(
type="dashboard_monitor_cycle_failed",
task_id=DASHBOARD_MONITOR_CHANNEL,
data={
"run_type": run_type,
"scope": _scope_to_dict(scope),
"error": str(exc),
"ts": datetime.now(timezone.utc).isoformat(),
},
)
)
raise
async def request_manual_run(scope: dict[str, Any] | None, force: bool) -> tuple[bool, bool, str]:
global _MANUAL_TASK
if _RUN_LOCK.locked():
return False, True, "Dashboard monitor is already running"
async def _runner() -> None:
try:
await run_dashboard_monitor_cycle(run_type="manual", scope=scope, force=force)
finally:
global _MANUAL_TASK
_MANUAL_TASK = None
_MANUAL_TASK = asyncio.create_task(_runner())
return True, True, "Manual run started"
async def list_checks_with_overrides() -> tuple[list[DashboardCheckDefinition], list[DashboardObjectCheckOverride]]:
async with AsyncSessionLocal() as db:
checks = (await db.execute(select(DashboardCheckDefinition).order_by(DashboardCheckDefinition.check_key))).scalars().all()
overrides = (
await db.execute(
select(DashboardObjectCheckOverride).order_by(
DashboardObjectCheckOverride.object_id,
DashboardObjectCheckOverride.check_key,
)
)
).scalars().all()
return list(checks), list(overrides)
async def patch_checks(items: list[dict[str, Any]]) -> None:
if not items:
return
async with AsyncSessionLocal() as db:
async with db.begin():
rows = (
await db.execute(
select(DashboardCheckDefinition).where(
DashboardCheckDefinition.check_key.in_([i["check_key"] for i in items])
)
)
).scalars().all()
row_map = {r.check_key: r for r in rows}
for item in items:
row = row_map.get(item["check_key"])
if row is None:
continue
for field in ("enabled", "interval_sec_default", "categories", "timeout_sec", "concurrency"):
if field in item and item[field] is not None:
setattr(row, field, item[field])
async def patch_object_overrides(items: list[dict[str, Any]]) -> None:
async with AsyncSessionLocal() as db:
async with db.begin():
for item in items:
row = (
await db.execute(
select(DashboardObjectCheckOverride).where(
DashboardObjectCheckOverride.object_id == item["object_id"],
DashboardObjectCheckOverride.check_key == item["check_key"],
)
)
).scalar_one_or_none()
has_payload = any(
item.get(k) is not None
for k in ("enabled_override", "interval_sec_override", "categories_override")
)
if not has_payload:
if row is not None:
await db.delete(row)
continue
if row is None:
row = DashboardObjectCheckOverride(
object_id=item["object_id"],
check_key=item["check_key"],
)
db.add(row)
row.enabled_override = item.get("enabled_override")
row.interval_sec_override = item.get("interval_sec_override")
row.categories_override = item.get("categories_override")
async def get_policy() -> tuple[list[DashboardObjectMonitorPolicy], list[DashboardTagMonitorPolicy]]:
async with AsyncSessionLocal() as db:
obj = (await db.execute(select(DashboardObjectMonitorPolicy).order_by(DashboardObjectMonitorPolicy.object_id))).scalars().all()
tags = (await db.execute(select(DashboardTagMonitorPolicy).order_by(DashboardTagMonitorPolicy.tag_id))).scalars().all()
return list(obj), list(tags)
async def patch_object_policy(items: list[dict[str, Any]]) -> None:
async with AsyncSessionLocal() as db:
async with db.begin():
for item in items:
row = await db.get(DashboardObjectMonitorPolicy, item["object_id"])
if row is None:
row = DashboardObjectMonitorPolicy(object_id=item["object_id"], mode=item["mode"])
db.add(row)
else:
row.mode = item["mode"]
async def patch_tag_policy(items: list[dict[str, Any]]) -> None:
async with AsyncSessionLocal() as db:
async with db.begin():
for item in items:
row = await db.get(DashboardTagMonitorPolicy, item["tag_id"])
if row is None:
row = DashboardTagMonitorPolicy(tag_id=item["tag_id"], enabled=item["enabled"])
db.add(row)
else:
row.enabled = item["enabled"]
def _calc_health(
checks: dict[str, dict[str, int]],
*,
device_count: int,
ping_interval_sec: int | None,
ping_max_age_sec: int | None,
) -> float | None:
ping = checks.get("ping")
if not ping:
return None
total = sum(ping.values()) or device_count
if total == 0:
return None
online = ping.get("online", 0)
unknown = ping.get("unknown", 0)
offline = ping.get("offline", 0)
known = max(0, total - unknown)
availability = online / total
coverage = known / total
freshness = 0.4
if ping_interval_sec and ping_interval_sec > 0 and ping_max_age_sec is not None:
if ping_max_age_sec <= ping_interval_sec:
freshness = 1.0
elif ping_max_age_sec <= ping_interval_sec * 3:
freshness = 0.6
else:
freshness = 0.2
warn = checks.get("disk_usage", {}).get("warn", 0)
failed = checks.get("disk_usage", {}).get("failed", 0)
disk_penalty = min(25.0, warn * 1.5 + failed * 4.0)
unknown_penalty = min(15.0, (unknown / total) * 20.0)
offline_penalty = min(20.0, (offline / total) * 25.0)
base = 100.0 * (0.65 * availability + 0.20 * freshness + 0.15 * coverage)
score = base - disk_penalty - unknown_penalty - offline_penalty
return round(max(0.0, score), 1)
async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]:
now = datetime.now(timezone.utc)
scope = _scope_to_dict(scope)
async with AsyncSessionLocal() as db:
objects = await _load_objects_with_scope(db, scope)
policy_map = await _build_object_policy_map(db, objects)
object_ids = [obj.id for obj in objects]
if object_ids:
device_rows = (
await db.execute(
select(Device).where(Device.is_active == True, Device.object_id.in_(object_ids))
)
).scalars().all()
else:
device_rows = []
devices_by_object: dict[int, list[Device]] = {}
for d in device_rows:
devices_by_object.setdefault(d.object_id, []).append(d)
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
}
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"))
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,
}
)
problems = sorted(
summaries,
key=lambda x: (
x["allowed"] is False,
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"]),
"devices": len(device_rows),
}
return {
"generated_at": now,
"scope": scope,
"totals": totals,
"checks_meta": checks,
"objects": summaries,
"problem_objects": problems,
}

View file

@ -0,0 +1,41 @@
import asyncio
from app.config import settings
from app.services.dashboard_monitor import run_dashboard_monitor_cycle
_stop_event: asyncio.Event | None = None
_worker_task: asyncio.Task | None = None
async def _loop() -> None:
assert _stop_event is not None
while not _stop_event.is_set():
try:
await run_dashboard_monitor_cycle(run_type="auto", scope=None, force=False)
except Exception as exc:
print(f"[dashboard_monitor] Cycle error: {exc}")
try:
await asyncio.wait_for(_stop_event.wait(), timeout=settings.DASHBOARD_MONITOR_TICK_SECONDS)
except asyncio.TimeoutError:
pass
def start_dashboard_monitor() -> None:
global _stop_event, _worker_task
_stop_event = asyncio.Event()
_worker_task = asyncio.create_task(_loop())
print("[dashboard_monitor] Worker started")
async def stop_dashboard_monitor() -> None:
global _stop_event, _worker_task
if _stop_event:
_stop_event.set()
if _worker_task:
_worker_task.cancel()
try:
await _worker_task
except asyncio.CancelledError:
pass
print("[dashboard_monitor] Worker stopped")

View file

@ -3,6 +3,7 @@ import { AppLayout } from './components/AppLayout'
import { AdminPage } from './pages/Admin' 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 { 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'
@ -29,7 +30,8 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
> >
<Route index element={<Navigate to="/inventory/objects" replace />} /> <Route index element={<Navigate to="/home" replace />} />
<Route path="home" element={<HomePage />} />
{/* Инвентарь */} {/* Инвентарь */}
<Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} /> <Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} />

View file

@ -0,0 +1,202 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from './client'
export interface DashboardScope {
city?: string
client?: string
object_ids?: number[]
tag_ids?: number[]
}
export interface DashboardCheckDefinition {
check_key: string
enabled: boolean
interval_sec_default: number
plugin_action: string
categories: string[]
timeout_sec: number
concurrency: number
updated_at: string
}
export interface DashboardObjectOverride {
object_id: number
check_key: string
enabled_override: boolean | null
interval_sec_override: number | null
categories_override: string[] | null
updated_at: string
}
export interface DashboardMonitorStatus {
status: string
run_type: string | null
scope: Record<string, unknown> | null
started_at: string | null
finished_at: string | null
last_success_at: string | null
last_error: string | null
last_counts: Record<string, unknown> | null
}
export interface DashboardObjectPolicy {
object_id: number
mode: 'inherit' | 'include' | 'exclude'
updated_at: string
}
export interface DashboardTagPolicy {
tag_id: number
enabled: boolean
updated_at: string
}
export interface DashboardPolicyResponse {
object_policies: DashboardObjectPolicy[]
tag_policies: DashboardTagPolicy[]
}
export interface DashboardObjectCheckSummary {
status_counts: Record<string, number>
last_checked_at: string | null
max_age_sec: number | null
}
export interface DashboardObjectSummary {
object_id: number
object_name: string
city: string | null
client: string | null
allowed: boolean
monitor_mode: 'inherit' | 'include' | 'exclude'
device_count: number
checks: Record<string, DashboardObjectCheckSummary>
health_score: number | null
}
export interface DashboardHomeSummary {
generated_at: string
scope: DashboardScope
totals: Record<string, number>
checks_meta: DashboardCheckDefinition[]
objects: DashboardObjectSummary[]
problem_objects: DashboardObjectSummary[]
}
export interface DashboardChecksResponse {
checks: DashboardCheckDefinition[]
object_overrides: DashboardObjectOverride[]
}
export const useDashboardHomeSummary = (scope: DashboardScope) =>
useQuery({
queryKey: ['dashboard-home-summary', scope],
queryFn: () =>
api
.get<DashboardHomeSummary>('/api/v1/dashboard/home-summary', { params: scope })
.then((r) => r.data),
refetchInterval: 30_000,
})
export const useDashboardMonitorStatus = () =>
useQuery({
queryKey: ['dashboard-monitor-status'],
queryFn: () =>
api
.get<DashboardMonitorStatus>('/api/v1/dashboard/monitor/status')
.then((r) => r.data),
refetchInterval: 10_000,
})
export const useDashboardRunNow = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: DashboardScope & { force?: boolean }) =>
api.post('/api/v1/dashboard/monitor/run', body).then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['dashboard-monitor-status'] })
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
},
})
}
export const useDashboardPolicy = () =>
useQuery({
queryKey: ['dashboard-policy'],
queryFn: () =>
api
.get<DashboardPolicyResponse>('/api/v1/dashboard/monitor/policy')
.then((r) => r.data),
})
export const usePatchDashboardObjectPolicy = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Array<{ object_id: number; mode: 'inherit' | 'include' | 'exclude' }>) =>
api.patch('/api/v1/dashboard/monitor/policy/objects', body).then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['dashboard-policy'] })
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
},
})
}
export const usePatchDashboardTagPolicy = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Array<{ tag_id: number; enabled: boolean }>) =>
api.patch('/api/v1/dashboard/monitor/policy/tags', body).then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['dashboard-policy'] })
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
},
})
}
export const useDashboardChecks = () =>
useQuery({
queryKey: ['dashboard-checks'],
queryFn: () =>
api
.get<DashboardChecksResponse>('/api/v1/dashboard/checks')
.then((r) => r.data),
})
export const usePatchDashboardChecks = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (
body: Array<{
check_key: string
enabled?: boolean
interval_sec_default?: number
categories?: string[]
timeout_sec?: number
concurrency?: number
}>
) => api.patch('/api/v1/dashboard/checks', body).then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['dashboard-checks'] })
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
},
})
}
export const usePatchDashboardObjectOverrides = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (
body: Array<{
object_id: number
check_key: string
enabled_override?: boolean | null
interval_sec_override?: number | null
categories_override?: string[] | null
}>
) => api.patch('/api/v1/dashboard/checks/object-overrides', body).then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['dashboard-checks'] })
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
},
})
}

16
frontend/src/api/tags.ts Normal file
View file

@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query'
import { api } from './client'
export interface TagItem {
id: number
name: string
color: string
tag_type: string
created_at: string
}
export const useTags = () =>
useQuery({
queryKey: ['tags'],
queryFn: () => api.get<TagItem[]>('/api/v1/tags').then((r) => r.data),
})

View file

@ -1,4 +1,5 @@
import { import {
HomeOutlined,
AppstoreOutlined, AppstoreOutlined,
BellOutlined, BellOutlined,
ControlOutlined, ControlOutlined,
@ -38,7 +39,9 @@ export function AppLayout() {
}, },
}) })
const selectedKey = location.pathname.startsWith('/inventory/objects') const selectedKey = location.pathname.startsWith('/home')
? 'home'
: location.pathname.startsWith('/inventory/objects')
? 'inventory-objects' ? 'inventory-objects'
: location.pathname.startsWith('/work/devices') : location.pathname.startsWith('/work/devices')
? 'work-devices' ? 'work-devices'
@ -74,6 +77,13 @@ export function AppLayout() {
mode="inline" mode="inline"
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
items={[ items={[
{
key: 'home',
icon: <HomeOutlined />,
label: 'Главная',
onClick: () => navigate('/home'),
},
{ type: 'divider' },
{ {
key: 'inventory-group', key: 'inventory-group',
type: 'group', type: 'group',

View file

@ -0,0 +1,45 @@
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { useEffect, useRef } from 'react'
import { useAuthStore } from '../store/auth'
interface DashboardMonitorHandlers {
onUpdate?: (eventType: string, data: Record<string, unknown>) => void
}
const DASHBOARD_MONITOR_CHANNEL = '__dashboard_monitor__'
export function useDashboardMonitorSSE(handlers: DashboardMonitorHandlers) {
const accessToken = useAuthStore((s) => s.accessToken)
const handlersRef = useRef(handlers)
handlersRef.current = handlers
useEffect(() => {
if (!accessToken) return
const ctrl = new AbortController()
fetchEventSource(`/api/v1/events?task_id=${DASHBOARD_MONITOR_CHANNEL}`, {
headers: { Authorization: `Bearer ${accessToken}` },
signal: ctrl.signal,
onmessage(ev) {
try {
const data = JSON.parse(ev.data)
if (
ev.event === 'dashboard_monitor_update' ||
ev.event === 'dashboard_monitor_cycle_done' ||
ev.event === 'dashboard_monitor_cycle_failed'
) {
handlersRef.current.onUpdate?.(ev.event, data)
}
} catch {
// ignore parse errors
}
},
onerror(err) {
ctrl.abort()
throw err
},
})
return () => ctrl.abort()
}, [accessToken])
}

569
frontend/src/pages/Home.tsx Normal file
View file

@ -0,0 +1,569 @@
import {
Alert,
Button,
Card,
Col,
Divider,
InputNumber,
Row,
Select,
Space,
Statistic,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd'
import dayjs from 'dayjs'
import { useEffect, useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useObjects, useObjectsMeta } from '../api/objects'
import {
useDashboardChecks,
useDashboardHomeSummary,
useDashboardMonitorStatus,
useDashboardPolicy,
useDashboardRunNow,
usePatchDashboardChecks,
usePatchDashboardObjectOverrides,
usePatchDashboardObjectPolicy,
usePatchDashboardTagPolicy,
type DashboardObjectOverride,
} 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)}м`
}
export function HomePage() {
const qc = useQueryClient()
const { data: meta } = useObjectsMeta()
const { data: objects } = useObjects()
const { data: tags } = useTags()
const [city, setCity] = useState<string | undefined>()
const [client, setClient] = useState<string | undefined>()
const [objectIds, setObjectIds] = useState<number[]>([])
const [tagIds, setTagIds] = useState<number[]>([])
const scope = useMemo(
() => ({ city, client, object_ids: objectIds, tag_ids: tagIds }),
[city, client, objectIds, tagIds]
)
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'] })
},
})
const handleRunNow = async () => {
try {
await runNow.mutateAsync({ ...scope, force: true })
message.success('Мониторинг запущен')
} catch {
message.error('Не удалось запустить мониторинг')
}
}
const totals = summary?.totals ?? { objects: 0, devices: 0, allowed_objects: 0 }
const problemObjects = summary?.problem_objects ?? []
const summaryColumns = [
{
title: 'Объект',
key: 'object',
render: (_: unknown, row: any) => (
<Space>
<Typography.Text strong>{row.object_name}</Typography.Text>
{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: 'Устройств',
dataIndex: 'device_count',
key: 'device_count',
},
{
title: 'Ping',
key: 'ping',
render: (_: unknown, row: any) => {
const ping = row.checks?.ping
const cnt = ping?.status_counts ?? {}
return (
<Space direction="vertical" size={0}>
<span>online {cnt.online ?? 0} / offline {cnt.offline ?? 0} / unknown {cnt.unknown ?? 0}</span>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
age: {formatAge(ping?.max_age_sec)}
</Typography.Text>
</Space>
)
},
},
{
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',
render: (_: unknown, row: any) => {
if (row.health_score == null) return '-'
const color = row.health_score >= 80 ? 'green' : row.health_score >= 60 ? 'orange' : 'red'
return <Tag color={color}>{row.health_score}</Tag>
},
},
]
return (
<div>
<Typography.Title level={4} style={{ marginTop: 0 }}>
Главная
</Typography.Title>
<Row gutter={12} style={{ marginBottom: 12 }}>
<Col span={5}>
<Select
placeholder="Город"
allowClear
style={{ width: '100%' }}
options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))}
value={city}
onChange={setCity}
/>
</Col>
<Col span={5}>
<Select
placeholder="Клиент"
allowClear
style={{ width: '100%' }}
options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))}
value={client}
onChange={setClient}
/>
</Col>
<Col span={7}>
<Select
mode="multiple"
placeholder="Объекты"
allowClear
style={{ width: '100%' }}
value={objectIds}
onChange={setObjectIds}
options={(objects ?? []).map((o) => ({ value: o.id, label: o.name }))}
/>
</Col>
<Col span={7}>
<Select
mode="multiple"
placeholder="Теги"
allowClear
style={{ width: '100%' }}
value={tagIds}
onChange={setTagIds}
options={(tags ?? []).map((t) => ({ value: t.id, label: t.name }))}
/>
</Col>
</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?.last_success_at
? ` · успешный запуск ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}`
: ''}
</Typography.Text>
</Col>
</Row>
{monitorStatus?.last_error && (
<Alert type="error" showIcon style={{ marginBottom: 12 }} message={monitorStatus.last_error} />
)}
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card><Statistic title="Объекты" value={totals.objects} /></Card>
</Col>
<Col span={8}>
<Card><Statistic title="В мониторинге" value={totals.allowed_objects} /></Card>
</Col>
<Col span={8}>
<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 }}>
<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}
rowKey="object_id"
columns={summaryColumns}
pagination={false}
/>
</Card>
<Card title="Сводка по объектам">
<Table
loading={isLoading}
dataSource={summary?.objects ?? []}
rowKey="object_id"
columns={summaryColumns}
pagination={{ pageSize: 12 }}
/>
</Card>
</div>
)
}