From 49771ec53d9573a2aa6dbf14f9c6099693972880 Mon Sep 17 00:00:00 2001 From: dv Date: Tue, 5 May 2026 22:57:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D0=B9=D0=B1=20=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=B4=D1=83=20=D1=84=D0=B8=D0=BA=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/0018_object_status_baseline.py | 35 +++ backend/app/models/dashboard.py | 18 ++ backend/app/routers/dashboard.py | 20 ++ backend/app/schemas/dashboard.py | 17 ++ backend/app/services/dashboard_monitor.py | 203 +++++++++++++++++- backend/app/workers/dashboard_monitor.py | 9 +- frontend/src/api/dashboard.ts | 55 ++++- frontend/src/components/ObjectStatusCard.tsx | 15 ++ frontend/src/index.css | 13 ++ frontend/src/main.tsx | 1 + frontend/src/pages/HomeObjectDetail.tsx | 141 +++++++++--- 11 files changed, 486 insertions(+), 41 deletions(-) create mode 100644 backend/alembic/versions/0018_object_status_baseline.py create mode 100644 frontend/src/index.css diff --git a/backend/alembic/versions/0018_object_status_baseline.py b/backend/alembic/versions/0018_object_status_baseline.py new file mode 100644 index 0000000..9ed8cfd --- /dev/null +++ b/backend/alembic/versions/0018_object_status_baseline.py @@ -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") diff --git a/backend/app/models/dashboard.py b/backend/app/models/dashboard.py index 474d8e3..f39830f 100644 --- a/backend/app/models/dashboard.py +++ b/backend/app/models/dashboard.py @@ -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" diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 443255f..fa555d1 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -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) diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index 332fc19..995db1b 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -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): diff --git a/backend/app/services/dashboard_monitor.py b/backend/app/services/dashboard_monitor.py index 3e5b0f7..31eab2e 100644 --- a/backend/app/services/dashboard_monitor.py +++ b/backend/app/services/dashboard_monitor.py @@ -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 ] diff --git a/backend/app/workers/dashboard_monitor.py b/backend/app/workers/dashboard_monitor.py index 8e6751e..d075ae7 100644 --- a/backend/app/workers/dashboard_monitor.py +++ b/backend/app/workers/dashboard_monitor.py @@ -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") diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 839d633..7956955 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -75,6 +75,8 @@ export interface DashboardObjectSummary { device_count: number checks: Record 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(`/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(`/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(`/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: ( diff --git a/frontend/src/components/ObjectStatusCard.tsx b/frontend/src/components/ObjectStatusCard.tsx index 9bb0a64..556edb0 100644 --- a/frontend/src/components/ObjectStatusCard.tsx +++ b/frontend/src/components/ObjectStatusCard.tsx @@ -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 )} + {obj.has_baseline && obj.recovered_count > 0 && ( + } + color="blue" + style={{ margin: 0, fontSize: 10, padding: '0 4px' }} + > + ожило: {obj.recovered_count} + + )} + {obj.has_baseline && obj.recovered_count === 0 && ( + + baseline + + )} {/* Нижняя строка */} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..3a6e153 --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f399b11..8b4644c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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') diff --git a/frontend/src/pages/HomeObjectDetail.tsx b/frontend/src/pages/HomeObjectDetail.tsx index bf860ff..52e1288 100644 --- a/frontend/src/pages/HomeObjectDetail.tsx +++ b/frontend/src/pages/HomeObjectDetail.tsx @@ -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 Нет точек в выбранном окне + return Нет данных } + + // Строим 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 ( -
- {points.map((point, idx) => ( -
- ))} -
+
) } @@ -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 ( + } color="blue" style={{ fontSize: 11 }}> + ожило + + ) + } + if (row.baseline_suppressed) { + return ( + + {row.baseline_status} + + ) + } + return null + }, + }, { title: 'Uptime', key: 'uptime', @@ -237,7 +285,7 @@ export function HomeObjectDetailPage() { if (!availability?.points?.length) return Нет данных return ( - + {availability.availability != null ? `${availability.availability}%` : '—'} @@ -271,6 +319,33 @@ export function HomeObjectDetailPage() { > Инвентарь + {baseline && baseline.count > 0 ? ( + + + + ) : ( + + )} @@ -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 '' + }} /> ), }