вайб кладу фикс

This commit is contained in:
dv 2026-05-05 22:57:14 +03:00
parent 7460a7cbb9
commit 49771ec53d
11 changed files with 486 additions and 41 deletions

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

View file

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

View file

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

View file

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

View file

@ -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,12 +950,28 @@ def _device_noc_item(
if isinstance(val, (int, float)): if isinstance(val, (int, float)):
disk_max_pct = float(val) disk_max_pct = float(val)
has_problem = ( # Устройство считается recovered если в baseline было offline/unknown, а сейчас online
ping_status == "offline" recovered = (
or (ping_status == "unknown" and not ping_fresh) baseline_status in ("offline", "unknown")
or disk_status in ("warn", "failed") 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 { return {
"device_id": device.id, "device_id": device.id,
"object_id": device.object_id, "object_id": device.object_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
] ]

View file

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

View file

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

View file

@ -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
View 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;
}

View file

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

View file

@ -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 }}> <div
{points.map((point, idx) => ( title={`${points.length} точек`}
<div style={{
key={`${point.bucket_start}-${idx}`} width: '100%',
title={`${dayjs(point.bucket_start).format('DD.MM HH:mm')}${point.availability_pct ?? '—'}%`} height: 10,
style={{ borderRadius: 3,
flex: 1, background: gradient,
minWidth: compact ? 3 : 4, border: '1px solid rgba(0,0,0,0.08)',
height: compact ? 9 : 14, }}
borderRadius: compact ? 2 : 3, />
background: uptimeColor(point),
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 ''
}}
/> />
), ),
} }