вайб кладу фикс
This commit is contained in:
parent
7460a7cbb9
commit
49771ec53d
11 changed files with 486 additions and 41 deletions
35
backend/alembic/versions/0018_object_status_baseline.py
Normal file
35
backend/alembic/versions/0018_object_status_baseline.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""0018 object status baseline
|
||||
|
||||
Revision ID: 0018
|
||||
Revises: 0017
|
||||
Create Date: 2026-05-05
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0018"
|
||||
down_revision = "0017"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"dashboard_object_status_baseline",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("object_id", sa.Integer(), sa.ForeignKey("objects.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("device_id", sa.Integer(), sa.ForeignKey("devices.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("baseline_status", sa.String(length=20), nullable=False),
|
||||
sa.Column("saved_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("object_id", "device_id", name="uq_baseline_object_device"),
|
||||
)
|
||||
op.create_index("ix_baseline_object_id", "dashboard_object_status_baseline", ["object_id"])
|
||||
op.create_index("ix_baseline_device_id", "dashboard_object_status_baseline", ["device_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_baseline_device_id", table_name="dashboard_object_status_baseline")
|
||||
op.drop_index("ix_baseline_object_id", table_name="dashboard_object_status_baseline")
|
||||
op.drop_table("dashboard_object_status_baseline")
|
||||
|
|
@ -102,6 +102,24 @@ class DashboardTagMonitorPolicy(Base, TimestampMixin):
|
|||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
|
||||
class DashboardObjectStatusBaseline(Base):
|
||||
"""Baseline статусов устройств объекта. Устройства с offline/unknown в baseline
|
||||
не учитываются в severity карточки, но помечаются как recovered если стали online."""
|
||||
|
||||
__tablename__ = "dashboard_object_status_baseline"
|
||||
__table_args__ = (UniqueConstraint("object_id", "device_id", name="uq_baseline_object_device"),)
|
||||
|
||||
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
|
||||
)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
baseline_status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
|
||||
class DashboardMonitorRunState(Base):
|
||||
__tablename__ = "dashboard_monitor_run_state"
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||
from app.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.dashboard import (
|
||||
DashboardBaselineResponse,
|
||||
DashboardCheckDefinitionPatchItem,
|
||||
DashboardChecksResponse,
|
||||
DashboardHomeSummaryResponse,
|
||||
|
|
@ -154,3 +155,22 @@ async def patch_object_overrides(body: list[DashboardObjectOverridePatchItem], _
|
|||
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)
|
||||
|
||||
|
||||
@router.post("/object/{object_id}/baseline", response_model=DashboardBaselineResponse)
|
||||
async def save_baseline(object_id: int, _: User = Depends(get_current_user)):
|
||||
count = await dashboard_service.save_object_baseline(object_id)
|
||||
items = await dashboard_service.get_object_baseline(object_id)
|
||||
return DashboardBaselineResponse(object_id=object_id, count=count, items=items)
|
||||
|
||||
|
||||
@router.delete("/object/{object_id}/baseline", response_model=DashboardBaselineResponse)
|
||||
async def clear_baseline(object_id: int, _: User = Depends(get_current_user)):
|
||||
await dashboard_service.clear_object_baseline(object_id)
|
||||
return DashboardBaselineResponse(object_id=object_id, count=0, items=[])
|
||||
|
||||
|
||||
@router.get("/object/{object_id}/baseline", response_model=DashboardBaselineResponse)
|
||||
async def get_baseline(object_id: int, _: User = Depends(get_current_user)):
|
||||
items = await dashboard_service.get_object_baseline(object_id)
|
||||
return DashboardBaselineResponse(object_id=object_id, count=len(items), items=items)
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@ class DashboardObjectSummary(BaseModel):
|
|||
device_count: int
|
||||
checks: dict[str, DashboardObjectCheckSummary]
|
||||
health_score: Optional[float] = None
|
||||
has_baseline: bool = False
|
||||
recovered_count: int = 0
|
||||
|
||||
|
||||
class DashboardHomeSummaryResponse(BaseModel):
|
||||
|
|
@ -167,6 +169,21 @@ class DashboardDeviceNOCItem(BaseModel):
|
|||
disk_status: Optional[str] = None
|
||||
disk_max_pct: Optional[float] = None
|
||||
has_problem: bool
|
||||
baseline_status: Optional[str] = None
|
||||
baseline_suppressed: bool = False
|
||||
recovered: bool = False
|
||||
|
||||
|
||||
class DashboardBaselineItem(BaseModel):
|
||||
device_id: int
|
||||
baseline_status: str
|
||||
saved_at: datetime
|
||||
|
||||
|
||||
class DashboardBaselineResponse(BaseModel):
|
||||
object_id: int
|
||||
count: int
|
||||
items: list[DashboardBaselineItem]
|
||||
|
||||
|
||||
class DashboardObjectSummaryResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
|
@ -8,6 +9,8 @@ from sqlalchemy import select
|
|||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.models.dashboard import (
|
||||
DashboardCheckDefinition,
|
||||
DashboardDeviceCheckHistory,
|
||||
|
|
@ -15,6 +18,7 @@ from app.models.dashboard import (
|
|||
DashboardMonitorRunState,
|
||||
DashboardObjectCheckOverride,
|
||||
DashboardObjectMonitorPolicy,
|
||||
DashboardObjectStatusBaseline,
|
||||
DashboardTagMonitorPolicy,
|
||||
)
|
||||
from app.models.device import Device
|
||||
|
|
@ -408,6 +412,7 @@ async def run_dashboard_monitor_cycle(
|
|||
async with _RUN_LOCK:
|
||||
await ensure_dashboard_bootstrap()
|
||||
started_at = datetime.now(timezone.utc)
|
||||
logger.info("Dashboard monitor cycle started (run_type=%s, force=%s, scope=%s)", run_type, force, _scope_to_dict(scope))
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
async with db.begin():
|
||||
|
|
@ -438,6 +443,10 @@ async def run_dashboard_monitor_cycle(
|
|||
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))
|
||||
logger.info(
|
||||
"Dashboard monitor: %d objects in scope, %d allowed, %d skipped by policy",
|
||||
counts["objects_in_scope"], counts["objects_allowed"], counts["skipped_policy"],
|
||||
)
|
||||
|
||||
if not allowed_object_ids:
|
||||
async with AsyncSessionLocal() as dbw:
|
||||
|
|
@ -459,6 +468,7 @@ async def run_dashboard_monitor_cycle(
|
|||
.where(Device.is_active == True, Device.object_id.in_(allowed_object_ids))
|
||||
)
|
||||
).scalars().all()
|
||||
logger.info("Dashboard monitor: %d devices loaded for checking", len(device_rows))
|
||||
|
||||
checks = (await db.execute(select(DashboardCheckDefinition))).scalars().all()
|
||||
overrides = (
|
||||
|
|
@ -605,6 +615,17 @@ async def run_dashboard_monitor_cycle(
|
|||
run_state.last_success_at = datetime.now(timezone.utc)
|
||||
run_state.last_counts = counts
|
||||
|
||||
elapsed = (datetime.now(timezone.utc) - started_at).total_seconds()
|
||||
logger.info(
|
||||
"Dashboard monitor cycle finished in %.1fs: objects=%d, devices_checked=%d, checks_executed=%d, failed=%d, skipped_not_due=%d",
|
||||
elapsed,
|
||||
counts["objects_allowed"],
|
||||
counts["devices_checked"],
|
||||
counts["checks_executed"],
|
||||
counts["failed"],
|
||||
counts["skipped_not_due"],
|
||||
)
|
||||
|
||||
await sse_manager.publish(
|
||||
SSEEvent(
|
||||
type="dashboard_monitor_cycle_done",
|
||||
|
|
@ -620,6 +641,8 @@ async def run_dashboard_monitor_cycle(
|
|||
|
||||
return counts
|
||||
except Exception as exc:
|
||||
elapsed = (datetime.now(timezone.utc) - started_at).total_seconds()
|
||||
logger.error("Dashboard monitor cycle failed after %.1fs: %s", elapsed, exc, exc_info=True)
|
||||
async with AsyncSessionLocal() as db:
|
||||
async with db.begin():
|
||||
run_state = await _get_run_state(db)
|
||||
|
|
@ -808,7 +831,9 @@ def _build_check_summary(
|
|||
obj_devices: list[Device],
|
||||
checks: list[DashboardCheckDefinition],
|
||||
state_map: dict[tuple[int, str], DashboardDeviceLiveState],
|
||||
baseline_map: dict[int, str] | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
baseline_map = baseline_map or {}
|
||||
check_summary: dict[str, dict[str, Any]] = {}
|
||||
for check in checks:
|
||||
counts: dict[str, int] = {}
|
||||
|
|
@ -816,10 +841,17 @@ def _build_check_summary(
|
|||
max_age = None
|
||||
for dev in obj_devices:
|
||||
state = state_map.get((dev.id, check.check_key))
|
||||
current_status = state.status if state is not None else "unknown"
|
||||
|
||||
# Устройство в baseline с проблемным статусом и не восстановилось — не считаем
|
||||
bl = baseline_map.get(dev.id)
|
||||
if bl in ("offline", "unknown") and current_status != "online":
|
||||
continue
|
||||
|
||||
if state is None:
|
||||
counts["unknown"] = counts.get("unknown", 0) + 1
|
||||
continue
|
||||
counts[state.status] = counts.get(state.status, 0) + 1
|
||||
counts[current_status] = counts.get(current_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())
|
||||
|
|
@ -843,10 +875,27 @@ def _to_object_summary(
|
|||
checks: list[DashboardCheckDefinition],
|
||||
obj_devices: list[Device],
|
||||
state_map: dict[tuple[int, str], DashboardDeviceLiveState],
|
||||
baseline_map: dict[int, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
check_summary = _build_check_summary(now=now, obj_devices=obj_devices, checks=checks, state_map=state_map)
|
||||
check_summary = _build_check_summary(
|
||||
now=now,
|
||||
obj_devices=obj_devices,
|
||||
checks=checks,
|
||||
state_map=state_map,
|
||||
baseline_map=baseline_map,
|
||||
)
|
||||
check_interval_map = {c.check_key: c.interval_sec_default for c in checks}
|
||||
|
||||
# Считаем recovered устройства для отображения в карточке
|
||||
baseline_map = baseline_map or {}
|
||||
recovered_count = 0
|
||||
for dev in obj_devices:
|
||||
bl = baseline_map.get(dev.id)
|
||||
if bl in ("offline", "unknown"):
|
||||
ping_state = state_map.get((dev.id, "ping"))
|
||||
if ping_state and ping_state.status == "online":
|
||||
recovered_count += 1
|
||||
|
||||
health = None
|
||||
if allowed:
|
||||
ping_meta = check_summary.get("ping", {})
|
||||
|
|
@ -868,6 +917,8 @@ def _to_object_summary(
|
|||
"device_count": len(obj_devices),
|
||||
"checks": check_summary,
|
||||
"health_score": health,
|
||||
"has_baseline": len(baseline_map) > 0,
|
||||
"recovered_count": recovered_count,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -879,6 +930,7 @@ def _device_noc_item(
|
|||
ping_state: DashboardDeviceLiveState | None,
|
||||
disk_state: DashboardDeviceLiveState | None,
|
||||
ping_interval_sec: int,
|
||||
baseline_status: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ping_age_sec = None
|
||||
ping_checked_at = None
|
||||
|
|
@ -898,12 +950,28 @@ def _device_noc_item(
|
|||
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")
|
||||
# Устройство считается recovered если в baseline было offline/unknown, а сейчас online
|
||||
recovered = (
|
||||
baseline_status in ("offline", "unknown")
|
||||
and ping_status == "online"
|
||||
)
|
||||
|
||||
# Устройство в baseline с проблемным статусом — не считается в has_problem
|
||||
# (кроме случая recovered — тогда наоборот хотим его показать)
|
||||
baseline_suppressed = (
|
||||
baseline_status in ("offline", "unknown")
|
||||
and ping_status != "online"
|
||||
)
|
||||
|
||||
has_problem = (
|
||||
not baseline_suppressed
|
||||
and (
|
||||
ping_status == "offline"
|
||||
or (ping_status == "unknown" and not ping_fresh)
|
||||
or disk_status in ("warn", "failed")
|
||||
)
|
||||
) or recovered
|
||||
|
||||
return {
|
||||
"device_id": device.id,
|
||||
"object_id": device.object_id,
|
||||
|
|
@ -924,9 +992,94 @@ def _device_noc_item(
|
|||
"disk_status": disk_status,
|
||||
"disk_max_pct": disk_max_pct,
|
||||
"has_problem": has_problem,
|
||||
"baseline_status": baseline_status,
|
||||
"baseline_suppressed": baseline_suppressed,
|
||||
"recovered": recovered,
|
||||
}
|
||||
|
||||
|
||||
async def save_object_baseline(object_id: int) -> int:
|
||||
"""Сохраняет текущие ping-статусы всех устройств объекта как baseline. Возвращает кол-во записей."""
|
||||
now = datetime.now(timezone.utc)
|
||||
async with AsyncSessionLocal() as db:
|
||||
async with db.begin():
|
||||
device_rows = (
|
||||
await db.execute(
|
||||
select(Device).where(Device.object_id == object_id, Device.is_active == True)
|
||||
)
|
||||
).scalars().all()
|
||||
if not device_rows:
|
||||
return 0
|
||||
|
||||
device_ids = [d.id for d in device_rows]
|
||||
state_rows = (
|
||||
await db.execute(
|
||||
select(DashboardDeviceLiveState).where(
|
||||
DashboardDeviceLiveState.device_id.in_(device_ids),
|
||||
DashboardDeviceLiveState.check_key == "ping",
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
status_map = {row.device_id: row.status for row in state_rows}
|
||||
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(DashboardObjectStatusBaseline).where(
|
||||
DashboardObjectStatusBaseline.object_id == object_id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
existing_map = {row.device_id: row for row in existing}
|
||||
|
||||
for device in device_rows:
|
||||
current_status = status_map.get(device.id, "unknown")
|
||||
row = existing_map.get(device.id)
|
||||
if row is None:
|
||||
db.add(DashboardObjectStatusBaseline(
|
||||
object_id=object_id,
|
||||
device_id=device.id,
|
||||
baseline_status=current_status,
|
||||
saved_at=now,
|
||||
))
|
||||
else:
|
||||
row.baseline_status = current_status
|
||||
row.saved_at = now
|
||||
|
||||
return len(device_rows)
|
||||
|
||||
|
||||
async def clear_object_baseline(object_id: int) -> int:
|
||||
"""Удаляет baseline объекта. Возвращает кол-во удалённых записей."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
async with db.begin():
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(DashboardObjectStatusBaseline).where(
|
||||
DashboardObjectStatusBaseline.object_id == object_id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
count = len(rows)
|
||||
for row in rows:
|
||||
await db.delete(row)
|
||||
return count
|
||||
|
||||
|
||||
async def get_object_baseline(object_id: int) -> list[dict[str, Any]]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(DashboardObjectStatusBaseline).where(
|
||||
DashboardObjectStatusBaseline.object_id == object_id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
return [
|
||||
{"device_id": r.device_id, "baseline_status": r.baseline_status, "saved_at": r.saved_at}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]:
|
||||
await ensure_dashboard_bootstrap()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
|
@ -966,6 +1119,22 @@ 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()
|
||||
|
||||
# Загружаем baseline для всех объектов одним запросом
|
||||
baseline_rows = []
|
||||
if object_ids:
|
||||
baseline_rows = (
|
||||
await db.execute(
|
||||
select(DashboardObjectStatusBaseline).where(
|
||||
DashboardObjectStatusBaseline.object_id.in_(object_ids)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
# baseline_map: object_id -> {device_id -> baseline_status}
|
||||
baseline_by_object: dict[int, dict[int, str]] = {}
|
||||
for row in baseline_rows:
|
||||
baseline_by_object.setdefault(row.object_id, {})[row.device_id] = row.baseline_status
|
||||
|
||||
summaries = []
|
||||
for obj in objects:
|
||||
allowed, mode, reason = policy_map.get(obj.id, (True, "inherit", "inherit_default"))
|
||||
|
|
@ -980,6 +1149,7 @@ async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]:
|
|||
checks=checks,
|
||||
obj_devices=obj_devices,
|
||||
state_map=state_map,
|
||||
baseline_map=baseline_by_object.get(obj.id),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1055,8 +1225,17 @@ async def get_object_devices(object_id: int, scope: dict[str, Any] | None) -> di
|
|||
)
|
||||
).scalars().all()
|
||||
|
||||
baseline_rows = (
|
||||
await db.execute(
|
||||
select(DashboardObjectStatusBaseline).where(
|
||||
DashboardObjectStatusBaseline.object_id == object_id
|
||||
)
|
||||
)
|
||||
).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}
|
||||
baseline_map: dict[int, str] = {row.device_id: row.baseline_status for row in baseline_rows}
|
||||
|
||||
rows = []
|
||||
for device in device_rows:
|
||||
|
|
@ -1068,6 +1247,7 @@ async def get_object_devices(object_id: int, scope: dict[str, Any] | None) -> di
|
|||
ping_state=state_map.get((device.id, "ping")),
|
||||
disk_state=state_map.get((device.id, "disk_usage")),
|
||||
ping_interval_sec=ping_interval,
|
||||
baseline_status=baseline_map.get(device.id),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1131,9 +1311,18 @@ async def get_object_summary(object_id: int, scope: dict[str, Any] | None) -> di
|
|||
)
|
||||
).scalars().all()
|
||||
|
||||
baseline_rows = (
|
||||
await db.execute(
|
||||
select(DashboardObjectStatusBaseline).where(
|
||||
DashboardObjectStatusBaseline.object_id == object_id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
state_map: dict[tuple[int, str], DashboardDeviceLiveState] = {
|
||||
(row.device_id, row.check_key): row for row in state_rows
|
||||
}
|
||||
baseline_map: dict[int, str] = {row.device_id: row.baseline_status for row in baseline_rows}
|
||||
|
||||
summary = _to_object_summary(
|
||||
now=now,
|
||||
|
|
@ -1144,6 +1333,7 @@ async def get_object_summary(object_id: int, scope: dict[str, Any] | None) -> di
|
|||
checks=checks,
|
||||
obj_devices=device_rows,
|
||||
state_map=state_map,
|
||||
baseline_map=baseline_map,
|
||||
)
|
||||
|
||||
ping_interval = next((c.interval_sec_default for c in checks if c.check_key == "ping"), 300)
|
||||
|
|
@ -1155,6 +1345,7 @@ async def get_object_summary(object_id: int, scope: dict[str, Any] | None) -> di
|
|||
ping_state=state_map.get((device.id, "ping")),
|
||||
disk_state=state_map.get((device.id, "disk_usage")),
|
||||
ping_interval_sec=ping_interval,
|
||||
baseline_status=baseline_map.get(device.id),
|
||||
)
|
||||
for device in device_rows
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
from app.services.dashboard_monitor import run_dashboard_monitor_cycle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_stop_event: asyncio.Event | None = None
|
||||
_worker_task: asyncio.Task | None = None
|
||||
|
||||
|
|
@ -13,7 +16,7 @@ async def _loop() -> None:
|
|||
try:
|
||||
await run_dashboard_monitor_cycle(run_type="auto", scope=None, force=False)
|
||||
except Exception as exc:
|
||||
print(f"[dashboard_monitor] Cycle error: {exc}")
|
||||
logger.error("[dashboard_monitor] Cycle error: %s", exc, exc_info=True)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(_stop_event.wait(), timeout=settings.DASHBOARD_MONITOR_TICK_SECONDS)
|
||||
|
|
@ -25,7 +28,7 @@ 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")
|
||||
logger.info("[dashboard_monitor] Worker started (tick=%ds)", settings.DASHBOARD_MONITOR_TICK_SECONDS)
|
||||
|
||||
|
||||
async def stop_dashboard_monitor() -> None:
|
||||
|
|
@ -38,4 +41,4 @@ async def stop_dashboard_monitor() -> None:
|
|||
await _worker_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
print("[dashboard_monitor] Worker stopped")
|
||||
logger.info("[dashboard_monitor] Worker stopped")
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ export interface DashboardObjectSummary {
|
|||
device_count: number
|
||||
checks: Record<string, DashboardObjectCheckSummary>
|
||||
health_score: number | null
|
||||
has_baseline: boolean
|
||||
recovered_count: number
|
||||
}
|
||||
|
||||
export interface DashboardHomeSummary {
|
||||
|
|
@ -110,6 +112,9 @@ export interface DashboardDeviceNOCItem {
|
|||
disk_status: string | null
|
||||
disk_max_pct: number | null
|
||||
has_problem: boolean
|
||||
baseline_status: string | null
|
||||
baseline_suppressed: boolean
|
||||
recovered: boolean
|
||||
}
|
||||
|
||||
export interface DashboardObjectSummaryResponse {
|
||||
|
|
@ -321,7 +326,55 @@ export const usePatchDashboardChecks = () => {
|
|||
})
|
||||
}
|
||||
|
||||
export const usePatchDashboardObjectOverrides = () => {
|
||||
export interface DashboardBaselineItem {
|
||||
device_id: number
|
||||
baseline_status: string
|
||||
saved_at: string
|
||||
}
|
||||
|
||||
export interface DashboardBaselineResponse {
|
||||
object_id: number
|
||||
count: number
|
||||
items: DashboardBaselineItem[]
|
||||
}
|
||||
|
||||
export const useDashboardBaseline = (objectId: number) =>
|
||||
useQuery({
|
||||
queryKey: ['dashboard-baseline', objectId],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get<DashboardBaselineResponse>(`/api/v1/dashboard/object/${objectId}/baseline`)
|
||||
.then((r) => r.data),
|
||||
enabled: Number.isFinite(objectId) && objectId > 0,
|
||||
})
|
||||
|
||||
export const useSaveBaseline = (objectId: number) => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<DashboardBaselineResponse>(`/api/v1/dashboard/object/${objectId}/baseline`).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['dashboard-baseline', objectId] })
|
||||
qc.invalidateQueries({ queryKey: ['dashboard-object-summary', objectId] })
|
||||
qc.invalidateQueries({ queryKey: ['dashboard-object-devices', objectId] })
|
||||
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useClearBaseline = (objectId: number) => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
api.delete<DashboardBaselineResponse>(`/api/v1/dashboard/object/${objectId}/baseline`).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['dashboard-baseline', objectId] })
|
||||
qc.invalidateQueries({ queryKey: ['dashboard-object-summary', objectId] })
|
||||
qc.invalidateQueries({ queryKey: ['dashboard-object-devices', objectId] })
|
||||
qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { SyncOutlined } from '@ant-design/icons'
|
||||
import { Space, Tag, Typography } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { DashboardObjectSummary, DashboardScope } from '../api/dashboard'
|
||||
|
|
@ -193,6 +194,20 @@ export function ObjectStatusCard({ obj, severity, staleThreshold, scope }: Props
|
|||
disk: {diskWarn}w/{diskFailed}f
|
||||
</Typography.Text>
|
||||
)}
|
||||
{obj.has_baseline && obj.recovered_count > 0 && (
|
||||
<Tag
|
||||
icon={<SyncOutlined spin />}
|
||||
color="blue"
|
||||
style={{ margin: 0, fontSize: 10, padding: '0 4px' }}
|
||||
>
|
||||
ожило: {obj.recovered_count}
|
||||
</Tag>
|
||||
)}
|
||||
{obj.has_baseline && obj.recovered_count === 0 && (
|
||||
<Tag color="default" style={{ margin: 0, fontSize: 10, padding: '0 4px', opacity: 0.6 }}>
|
||||
baseline
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Нижняя строка */}
|
||||
|
|
|
|||
13
frontend/src/index.css
Normal file
13
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/* Строки таблицы устройств */
|
||||
.row-offline td {
|
||||
background: rgba(255, 77, 79, 0.06) !important;
|
||||
}
|
||||
.row-unknown td {
|
||||
background: rgba(250, 140, 22, 0.06) !important;
|
||||
}
|
||||
.row-recovered td {
|
||||
background: rgba(22, 119, 255, 0.06) !important;
|
||||
}
|
||||
.row-suppressed td {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import React from 'react'
|
|||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
dayjs.locale('ru')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { ArrowLeftOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Alert, Button, Card, Col, Collapse, Empty, Row, Select, Space, Table, Tag, Typography } from 'antd'
|
||||
import { Alert, Button, Card, Col, Collapse, Empty, Popconfirm, Row, Select, Space, Table, Tag, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
useDashboardObjectAvailability,
|
||||
useDashboardObjectDevices,
|
||||
useDashboardObjectSummary,
|
||||
useDashboardBaseline,
|
||||
useSaveBaseline,
|
||||
useClearBaseline,
|
||||
type DashboardAvailabilityPoint,
|
||||
type DashboardAvailabilityWindow,
|
||||
type DashboardDeviceNOCItem,
|
||||
|
|
@ -57,33 +59,36 @@ function healthColor(score: number | null | undefined): string {
|
|||
return 'red'
|
||||
}
|
||||
|
||||
function uptimeColor(point: DashboardAvailabilityPoint): string {
|
||||
if (point.samples === 0) return '#d9d9d9'
|
||||
if (point.offline > 0) return '#ff4d4f'
|
||||
return '#52c41a'
|
||||
}
|
||||
|
||||
function UptimeBars({ points, compact = false }: { points: DashboardAvailabilityPoint[]; compact?: boolean }) {
|
||||
function UptimeBars({ points }: { points: DashboardAvailabilityPoint[] }) {
|
||||
if (!points.length) {
|
||||
return <Typography.Text type="secondary">Нет точек в выбранном окне</Typography.Text>
|
||||
return <Typography.Text type="secondary">Нет данных</Typography.Text>
|
||||
}
|
||||
|
||||
// Строим linear-gradient с резкими переходами
|
||||
const total = points.length
|
||||
const stops: string[] = []
|
||||
points.forEach((point, idx) => {
|
||||
const color =
|
||||
point.samples === 0 ? '#d9d9d9' : point.offline > 0 ? '#ff4d4f' : '#52c41a'
|
||||
const pctStart = (idx / total) * 100
|
||||
const pctEnd = ((idx + 1) / total) * 100
|
||||
stops.push(`${color} ${pctStart.toFixed(2)}%`)
|
||||
stops.push(`${color} ${pctEnd.toFixed(2)}%`)
|
||||
})
|
||||
|
||||
const gradient = `linear-gradient(to right, ${stops.join(', ')})`
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: compact ? 1 : 2, alignItems: 'center', height: compact ? 14 : 20 }}>
|
||||
{points.map((point, idx) => (
|
||||
<div
|
||||
key={`${point.bucket_start}-${idx}`}
|
||||
title={`${dayjs(point.bucket_start).format('DD.MM HH:mm')} • ${point.availability_pct ?? '—'}%`}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: compact ? 3 : 4,
|
||||
height: compact ? 9 : 14,
|
||||
borderRadius: compact ? 2 : 3,
|
||||
background: uptimeColor(point),
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
title={`${points.length} точек`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 10,
|
||||
borderRadius: 3,
|
||||
background: gradient,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -109,6 +114,9 @@ export function HomeObjectDetailPage() {
|
|||
const { data: summary, isLoading: summaryLoading } = useDashboardObjectSummary(objectId, scope)
|
||||
const { data: devices, isLoading: devicesLoading } = useDashboardObjectDevices(objectId, scope)
|
||||
const { data: availabilityData, isLoading: availabilityLoading } = useDashboardObjectAvailability(objectId, window, undefined, true)
|
||||
const { data: baseline } = useDashboardBaseline(objectId)
|
||||
const saveBaseline = useSaveBaseline(objectId)
|
||||
const clearBaseline = useClearBaseline(objectId)
|
||||
|
||||
useDashboardMonitorSSE({
|
||||
onUpdate: () => {
|
||||
|
|
@ -118,6 +126,24 @@ export function HomeObjectDetailPage() {
|
|||
},
|
||||
})
|
||||
|
||||
const handleSaveBaseline = async () => {
|
||||
try {
|
||||
await saveBaseline.mutateAsync()
|
||||
message.success('Baseline сохранён')
|
||||
} catch {
|
||||
message.error('Не удалось сохранить baseline')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearBaseline = async () => {
|
||||
try {
|
||||
await clearBaseline.mutateAsync()
|
||||
message.success('Baseline сброшен')
|
||||
} catch {
|
||||
message.error('Не удалось сбросить baseline')
|
||||
}
|
||||
}
|
||||
|
||||
const object = summary?.object
|
||||
|
||||
const availabilityByDevice = useMemo(() => {
|
||||
|
|
@ -227,6 +253,28 @@ export function HomeObjectDetailPage() {
|
|||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Baseline',
|
||||
key: 'baseline',
|
||||
width: 100,
|
||||
render: (_, row) => {
|
||||
if (row.recovered) {
|
||||
return (
|
||||
<Tag icon={<SyncOutlined spin />} color="blue" style={{ fontSize: 11 }}>
|
||||
ожило
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
if (row.baseline_suppressed) {
|
||||
return (
|
||||
<Tag color="default" style={{ fontSize: 11, opacity: 0.7 }}>
|
||||
{row.baseline_status}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Uptime',
|
||||
key: 'uptime',
|
||||
|
|
@ -237,7 +285,7 @@ export function HomeObjectDetailPage() {
|
|||
if (!availability?.points?.length) return <Typography.Text type="secondary">Нет данных</Typography.Text>
|
||||
return (
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
<UptimeBars points={availability.points} compact />
|
||||
<UptimeBars points={availability.points} />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{availability.availability != null ? `${availability.availability}%` : '—'}
|
||||
</Typography.Text>
|
||||
|
|
@ -271,6 +319,33 @@ export function HomeObjectDetailPage() {
|
|||
>
|
||||
Инвентарь
|
||||
</Button>
|
||||
{baseline && baseline.count > 0 ? (
|
||||
<Popconfirm
|
||||
title="Сбросить baseline?"
|
||||
description="Все устройства снова будут учитываться в severity."
|
||||
okText="Сбросить"
|
||||
cancelText="Отмена"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={handleClearBaseline}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
loading={clearBaseline.isPending}
|
||||
>
|
||||
Сбросить baseline ({baseline.count})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="dashed"
|
||||
loading={saveBaseline.isPending}
|
||||
onClick={handleSaveBaseline}
|
||||
>
|
||||
Запомнить статус как baseline
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -416,9 +491,13 @@ export function HomeObjectDetailPage() {
|
|||
size="small"
|
||||
pagination={false}
|
||||
columns={columns}
|
||||
rowClassName={(row) =>
|
||||
row.ping_status === 'offline' ? 'row-offline' : row.ping_status === 'unknown' ? 'row-unknown' : ''
|
||||
}
|
||||
rowClassName={(row) => {
|
||||
if (row.recovered) return 'row-recovered'
|
||||
if (row.baseline_suppressed) return 'row-suppressed'
|
||||
if (row.ping_status === 'offline') return 'row-offline'
|
||||
if (row.ping_status === 'unknown') return 'row-unknown'
|
||||
return ''
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue