вайб кладу фикс
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)
|
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):
|
class DashboardMonitorRunState(Base):
|
||||||
__tablename__ = "dashboard_monitor_run_state"
|
__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.dependencies import get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.dashboard import (
|
from app.schemas.dashboard import (
|
||||||
|
DashboardBaselineResponse,
|
||||||
DashboardCheckDefinitionPatchItem,
|
DashboardCheckDefinitionPatchItem,
|
||||||
DashboardChecksResponse,
|
DashboardChecksResponse,
|
||||||
DashboardHomeSummaryResponse,
|
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])
|
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()
|
checks, overrides = await dashboard_service.list_checks_with_overrides()
|
||||||
return DashboardChecksResponse(checks=checks, object_overrides=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
|
device_count: int
|
||||||
checks: dict[str, DashboardObjectCheckSummary]
|
checks: dict[str, DashboardObjectCheckSummary]
|
||||||
health_score: Optional[float] = None
|
health_score: Optional[float] = None
|
||||||
|
has_baseline: bool = False
|
||||||
|
recovered_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class DashboardHomeSummaryResponse(BaseModel):
|
class DashboardHomeSummaryResponse(BaseModel):
|
||||||
|
|
@ -167,6 +169,21 @@ class DashboardDeviceNOCItem(BaseModel):
|
||||||
disk_status: Optional[str] = None
|
disk_status: Optional[str] = None
|
||||||
disk_max_pct: Optional[float] = None
|
disk_max_pct: Optional[float] = None
|
||||||
has_problem: bool
|
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):
|
class DashboardObjectSummaryResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
@ -8,6 +9,8 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
from app.models.dashboard import (
|
from app.models.dashboard import (
|
||||||
DashboardCheckDefinition,
|
DashboardCheckDefinition,
|
||||||
DashboardDeviceCheckHistory,
|
DashboardDeviceCheckHistory,
|
||||||
|
|
@ -15,6 +18,7 @@ from app.models.dashboard import (
|
||||||
DashboardMonitorRunState,
|
DashboardMonitorRunState,
|
||||||
DashboardObjectCheckOverride,
|
DashboardObjectCheckOverride,
|
||||||
DashboardObjectMonitorPolicy,
|
DashboardObjectMonitorPolicy,
|
||||||
|
DashboardObjectStatusBaseline,
|
||||||
DashboardTagMonitorPolicy,
|
DashboardTagMonitorPolicy,
|
||||||
)
|
)
|
||||||
from app.models.device import Device
|
from app.models.device import Device
|
||||||
|
|
@ -408,6 +412,7 @@ async def run_dashboard_monitor_cycle(
|
||||||
async with _RUN_LOCK:
|
async with _RUN_LOCK:
|
||||||
await ensure_dashboard_bootstrap()
|
await ensure_dashboard_bootstrap()
|
||||||
started_at = datetime.now(timezone.utc)
|
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 AsyncSessionLocal() as db:
|
||||||
async with db.begin():
|
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]
|
allowed_object_ids = [oid for oid, (allowed, _mode, _reason) in object_policy_map.items() if allowed]
|
||||||
counts["objects_allowed"] = len(allowed_object_ids)
|
counts["objects_allowed"] = len(allowed_object_ids)
|
||||||
counts["skipped_policy"] = max(0, len(objects) - len(allowed_object_ids))
|
counts["skipped_policy"] = max(0, len(objects) - len(allowed_object_ids))
|
||||||
|
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:
|
if not allowed_object_ids:
|
||||||
async with AsyncSessionLocal() as dbw:
|
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))
|
.where(Device.is_active == True, Device.object_id.in_(allowed_object_ids))
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
logger.info("Dashboard monitor: %d devices loaded for checking", len(device_rows))
|
||||||
|
|
||||||
checks = (await db.execute(select(DashboardCheckDefinition))).scalars().all()
|
checks = (await db.execute(select(DashboardCheckDefinition))).scalars().all()
|
||||||
overrides = (
|
overrides = (
|
||||||
|
|
@ -605,6 +615,17 @@ async def run_dashboard_monitor_cycle(
|
||||||
run_state.last_success_at = datetime.now(timezone.utc)
|
run_state.last_success_at = datetime.now(timezone.utc)
|
||||||
run_state.last_counts = counts
|
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(
|
await sse_manager.publish(
|
||||||
SSEEvent(
|
SSEEvent(
|
||||||
type="dashboard_monitor_cycle_done",
|
type="dashboard_monitor_cycle_done",
|
||||||
|
|
@ -620,6 +641,8 @@ async def run_dashboard_monitor_cycle(
|
||||||
|
|
||||||
return counts
|
return counts
|
||||||
except Exception as exc:
|
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 AsyncSessionLocal() as db:
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
run_state = await _get_run_state(db)
|
run_state = await _get_run_state(db)
|
||||||
|
|
@ -808,7 +831,9 @@ def _build_check_summary(
|
||||||
obj_devices: list[Device],
|
obj_devices: list[Device],
|
||||||
checks: list[DashboardCheckDefinition],
|
checks: list[DashboardCheckDefinition],
|
||||||
state_map: dict[tuple[int, str], DashboardDeviceLiveState],
|
state_map: dict[tuple[int, str], DashboardDeviceLiveState],
|
||||||
|
baseline_map: dict[int, str] | None = None,
|
||||||
) -> dict[str, dict[str, Any]]:
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
baseline_map = baseline_map or {}
|
||||||
check_summary: dict[str, dict[str, Any]] = {}
|
check_summary: dict[str, dict[str, Any]] = {}
|
||||||
for check in checks:
|
for check in checks:
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
|
|
@ -816,10 +841,17 @@ def _build_check_summary(
|
||||||
max_age = None
|
max_age = None
|
||||||
for dev in obj_devices:
|
for dev in obj_devices:
|
||||||
state = state_map.get((dev.id, check.check_key))
|
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:
|
if state is None:
|
||||||
counts["unknown"] = counts.get("unknown", 0) + 1
|
counts["unknown"] = counts.get("unknown", 0) + 1
|
||||||
continue
|
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:
|
if last_checked is None or state.checked_at > last_checked:
|
||||||
last_checked = state.checked_at
|
last_checked = state.checked_at
|
||||||
age_sec = int((now - state.checked_at).total_seconds())
|
age_sec = int((now - state.checked_at).total_seconds())
|
||||||
|
|
@ -843,10 +875,27 @@ def _to_object_summary(
|
||||||
checks: list[DashboardCheckDefinition],
|
checks: list[DashboardCheckDefinition],
|
||||||
obj_devices: list[Device],
|
obj_devices: list[Device],
|
||||||
state_map: dict[tuple[int, str], DashboardDeviceLiveState],
|
state_map: dict[tuple[int, str], DashboardDeviceLiveState],
|
||||||
|
baseline_map: dict[int, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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}
|
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
|
health = None
|
||||||
if allowed:
|
if allowed:
|
||||||
ping_meta = check_summary.get("ping", {})
|
ping_meta = check_summary.get("ping", {})
|
||||||
|
|
@ -868,6 +917,8 @@ def _to_object_summary(
|
||||||
"device_count": len(obj_devices),
|
"device_count": len(obj_devices),
|
||||||
"checks": check_summary,
|
"checks": check_summary,
|
||||||
"health_score": health,
|
"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,
|
ping_state: DashboardDeviceLiveState | None,
|
||||||
disk_state: DashboardDeviceLiveState | None,
|
disk_state: DashboardDeviceLiveState | None,
|
||||||
ping_interval_sec: int,
|
ping_interval_sec: int,
|
||||||
|
baseline_status: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
ping_age_sec = None
|
ping_age_sec = None
|
||||||
ping_checked_at = None
|
ping_checked_at = None
|
||||||
|
|
@ -898,11 +950,27 @@ def _device_noc_item(
|
||||||
if isinstance(val, (int, float)):
|
if isinstance(val, (int, float)):
|
||||||
disk_max_pct = float(val)
|
disk_max_pct = float(val)
|
||||||
|
|
||||||
|
# Устройство считается 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 = (
|
has_problem = (
|
||||||
|
not baseline_suppressed
|
||||||
|
and (
|
||||||
ping_status == "offline"
|
ping_status == "offline"
|
||||||
or (ping_status == "unknown" and not ping_fresh)
|
or (ping_status == "unknown" and not ping_fresh)
|
||||||
or disk_status in ("warn", "failed")
|
or disk_status in ("warn", "failed")
|
||||||
)
|
)
|
||||||
|
) or recovered
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"device_id": device.id,
|
"device_id": device.id,
|
||||||
|
|
@ -924,9 +992,94 @@ def _device_noc_item(
|
||||||
"disk_status": disk_status,
|
"disk_status": disk_status,
|
||||||
"disk_max_pct": disk_max_pct,
|
"disk_max_pct": disk_max_pct,
|
||||||
"has_problem": has_problem,
|
"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]:
|
async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
await ensure_dashboard_bootstrap()
|
await ensure_dashboard_bootstrap()
|
||||||
now = datetime.now(timezone.utc)
|
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()
|
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 = []
|
summaries = []
|
||||||
for obj in objects:
|
for obj in objects:
|
||||||
allowed, mode, reason = policy_map.get(obj.id, (True, "inherit", "inherit_default"))
|
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,
|
checks=checks,
|
||||||
obj_devices=obj_devices,
|
obj_devices=obj_devices,
|
||||||
state_map=state_map,
|
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()
|
).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
|
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}
|
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 = []
|
rows = []
|
||||||
for device in device_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")),
|
ping_state=state_map.get((device.id, "ping")),
|
||||||
disk_state=state_map.get((device.id, "disk_usage")),
|
disk_state=state_map.get((device.id, "disk_usage")),
|
||||||
ping_interval_sec=ping_interval,
|
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()
|
).scalars().all()
|
||||||
|
|
||||||
|
baseline_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(DashboardObjectStatusBaseline).where(
|
||||||
|
DashboardObjectStatusBaseline.object_id == object_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
state_map: dict[tuple[int, str], DashboardDeviceLiveState] = {
|
state_map: dict[tuple[int, str], DashboardDeviceLiveState] = {
|
||||||
(row.device_id, row.check_key): row for row in state_rows
|
(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(
|
summary = _to_object_summary(
|
||||||
now=now,
|
now=now,
|
||||||
|
|
@ -1144,6 +1333,7 @@ async def get_object_summary(object_id: int, scope: dict[str, Any] | None) -> di
|
||||||
checks=checks,
|
checks=checks,
|
||||||
obj_devices=device_rows,
|
obj_devices=device_rows,
|
||||||
state_map=state_map,
|
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)
|
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")),
|
ping_state=state_map.get((device.id, "ping")),
|
||||||
disk_state=state_map.get((device.id, "disk_usage")),
|
disk_state=state_map.get((device.id, "disk_usage")),
|
||||||
ping_interval_sec=ping_interval,
|
ping_interval_sec=ping_interval,
|
||||||
|
baseline_status=baseline_map.get(device.id),
|
||||||
)
|
)
|
||||||
for device in device_rows
|
for device in device_rows
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.services.dashboard_monitor import run_dashboard_monitor_cycle
|
from app.services.dashboard_monitor import run_dashboard_monitor_cycle
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_stop_event: asyncio.Event | None = None
|
_stop_event: asyncio.Event | None = None
|
||||||
_worker_task: asyncio.Task | None = None
|
_worker_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
|
@ -13,7 +16,7 @@ async def _loop() -> None:
|
||||||
try:
|
try:
|
||||||
await run_dashboard_monitor_cycle(run_type="auto", scope=None, force=False)
|
await run_dashboard_monitor_cycle(run_type="auto", scope=None, force=False)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[dashboard_monitor] Cycle error: {exc}")
|
logger.error("[dashboard_monitor] Cycle error: %s", exc, exc_info=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(_stop_event.wait(), timeout=settings.DASHBOARD_MONITOR_TICK_SECONDS)
|
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
|
global _stop_event, _worker_task
|
||||||
_stop_event = asyncio.Event()
|
_stop_event = asyncio.Event()
|
||||||
_worker_task = asyncio.create_task(_loop())
|
_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:
|
async def stop_dashboard_monitor() -> None:
|
||||||
|
|
@ -38,4 +41,4 @@ async def stop_dashboard_monitor() -> None:
|
||||||
await _worker_task
|
await _worker_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
print("[dashboard_monitor] Worker stopped")
|
logger.info("[dashboard_monitor] Worker stopped")
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ export interface DashboardObjectSummary {
|
||||||
device_count: number
|
device_count: number
|
||||||
checks: Record<string, DashboardObjectCheckSummary>
|
checks: Record<string, DashboardObjectCheckSummary>
|
||||||
health_score: number | null
|
health_score: number | null
|
||||||
|
has_baseline: boolean
|
||||||
|
recovered_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardHomeSummary {
|
export interface DashboardHomeSummary {
|
||||||
|
|
@ -110,6 +112,9 @@ export interface DashboardDeviceNOCItem {
|
||||||
disk_status: string | null
|
disk_status: string | null
|
||||||
disk_max_pct: number | null
|
disk_max_pct: number | null
|
||||||
has_problem: boolean
|
has_problem: boolean
|
||||||
|
baseline_status: string | null
|
||||||
|
baseline_suppressed: boolean
|
||||||
|
recovered: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardObjectSummaryResponse {
|
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()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (
|
mutationFn: (
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { SyncOutlined } from '@ant-design/icons'
|
||||||
import { Space, Tag, Typography } from 'antd'
|
import { Space, Tag, Typography } from 'antd'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import type { DashboardObjectSummary, DashboardScope } from '../api/dashboard'
|
import type { DashboardObjectSummary, DashboardScope } from '../api/dashboard'
|
||||||
|
|
@ -193,6 +194,20 @@ export function ObjectStatusCard({ obj, severity, staleThreshold, scope }: Props
|
||||||
disk: {diskWarn}w/{diskFailed}f
|
disk: {diskWarn}w/{diskFailed}f
|
||||||
</Typography.Text>
|
</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>
|
</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 ReactDOM from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
dayjs.locale('ru')
|
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 { 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 type { ColumnsType } from 'antd/es/table'
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
useDashboardObjectAvailability,
|
useDashboardObjectAvailability,
|
||||||
useDashboardObjectDevices,
|
useDashboardObjectDevices,
|
||||||
useDashboardObjectSummary,
|
useDashboardObjectSummary,
|
||||||
|
useDashboardBaseline,
|
||||||
|
useSaveBaseline,
|
||||||
|
useClearBaseline,
|
||||||
type DashboardAvailabilityPoint,
|
type DashboardAvailabilityPoint,
|
||||||
type DashboardAvailabilityWindow,
|
type DashboardAvailabilityWindow,
|
||||||
type DashboardDeviceNOCItem,
|
type DashboardDeviceNOCItem,
|
||||||
|
|
@ -57,33 +59,36 @@ function healthColor(score: number | null | undefined): string {
|
||||||
return 'red'
|
return 'red'
|
||||||
}
|
}
|
||||||
|
|
||||||
function uptimeColor(point: DashboardAvailabilityPoint): string {
|
function UptimeBars({ points }: { points: DashboardAvailabilityPoint[] }) {
|
||||||
if (point.samples === 0) return '#d9d9d9'
|
|
||||||
if (point.offline > 0) return '#ff4d4f'
|
|
||||||
return '#52c41a'
|
|
||||||
}
|
|
||||||
|
|
||||||
function UptimeBars({ points, compact = false }: { points: DashboardAvailabilityPoint[]; compact?: boolean }) {
|
|
||||||
if (!points.length) {
|
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 (
|
return (
|
||||||
<div style={{ display: 'flex', gap: compact ? 1 : 2, alignItems: 'center', height: compact ? 14 : 20 }}>
|
|
||||||
{points.map((point, idx) => (
|
|
||||||
<div
|
<div
|
||||||
key={`${point.bucket_start}-${idx}`}
|
title={`${points.length} точек`}
|
||||||
title={`${dayjs(point.bucket_start).format('DD.MM HH:mm')} • ${point.availability_pct ?? '—'}%`}
|
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
width: '100%',
|
||||||
minWidth: compact ? 3 : 4,
|
height: 10,
|
||||||
height: compact ? 9 : 14,
|
borderRadius: 3,
|
||||||
borderRadius: compact ? 2 : 3,
|
background: gradient,
|
||||||
background: uptimeColor(point),
|
|
||||||
border: '1px solid rgba(0,0,0,0.08)',
|
border: '1px solid rgba(0,0,0,0.08)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,6 +114,9 @@ export function HomeObjectDetailPage() {
|
||||||
const { data: summary, isLoading: summaryLoading } = useDashboardObjectSummary(objectId, scope)
|
const { data: summary, isLoading: summaryLoading } = useDashboardObjectSummary(objectId, scope)
|
||||||
const { data: devices, isLoading: devicesLoading } = useDashboardObjectDevices(objectId, scope)
|
const { data: devices, isLoading: devicesLoading } = useDashboardObjectDevices(objectId, scope)
|
||||||
const { data: availabilityData, isLoading: availabilityLoading } = useDashboardObjectAvailability(objectId, window, undefined, true)
|
const { data: availabilityData, isLoading: availabilityLoading } = useDashboardObjectAvailability(objectId, window, undefined, true)
|
||||||
|
const { data: baseline } = useDashboardBaseline(objectId)
|
||||||
|
const saveBaseline = useSaveBaseline(objectId)
|
||||||
|
const clearBaseline = useClearBaseline(objectId)
|
||||||
|
|
||||||
useDashboardMonitorSSE({
|
useDashboardMonitorSSE({
|
||||||
onUpdate: () => {
|
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 object = summary?.object
|
||||||
|
|
||||||
const availabilityByDevice = useMemo(() => {
|
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',
|
title: 'Uptime',
|
||||||
key: 'uptime',
|
key: 'uptime',
|
||||||
|
|
@ -237,7 +285,7 @@ export function HomeObjectDetailPage() {
|
||||||
if (!availability?.points?.length) return <Typography.Text type="secondary">Нет данных</Typography.Text>
|
if (!availability?.points?.length) return <Typography.Text type="secondary">Нет данных</Typography.Text>
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||||
<UptimeBars points={availability.points} compact />
|
<UptimeBars points={availability.points} />
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{availability.availability != null ? `${availability.availability}%` : '—'}
|
{availability.availability != null ? `${availability.availability}%` : '—'}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|
@ -271,6 +319,33 @@ export function HomeObjectDetailPage() {
|
||||||
>
|
>
|
||||||
Инвентарь
|
Инвентарь
|
||||||
</Button>
|
</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>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
@ -416,9 +491,13 @@ export function HomeObjectDetailPage() {
|
||||||
size="small"
|
size="small"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowClassName={(row) =>
|
rowClassName={(row) => {
|
||||||
row.ping_status === 'offline' ? 'row-offline' : row.ping_status === 'unknown' ? 'row-unknown' : ''
|
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