From 6b3648571860d26ef6794753741d83292f9e4cb7 Mon Sep 17 00:00:00 2001 From: dv Date: Tue, 7 Apr 2026 13:02:52 +0300 Subject: [PATCH] =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/alembic/versions/0006_alerts.py | 47 +++++ backend/app/main.py | 6 +- backend/app/models/alert.py | 35 ++++ backend/app/routers/alerts.py | 158 ++++++++++++++++ backend/app/schemas/alert.py | 21 +++ backend/app/workers/monitor.py | 216 +++++++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/api/alerts.ts | 52 ++++++ frontend/src/components/AppLayout.tsx | 46 ++++- frontend/src/hooks/useMonitorSSE.ts | 75 ++++++++ frontend/src/pages/Alerts.tsx | 239 ++++++++++++++++++++++++ frontend/src/pages/ObjectDetail.tsx | 6 + 12 files changed, 899 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/0006_alerts.py create mode 100644 backend/app/models/alert.py create mode 100644 backend/app/routers/alerts.py create mode 100644 backend/app/schemas/alert.py create mode 100644 backend/app/workers/monitor.py create mode 100644 frontend/src/api/alerts.ts create mode 100644 frontend/src/hooks/useMonitorSSE.ts create mode 100644 frontend/src/pages/Alerts.tsx diff --git a/backend/alembic/versions/0006_alerts.py b/backend/alembic/versions/0006_alerts.py new file mode 100644 index 0000000..8085cd1 --- /dev/null +++ b/backend/alembic/versions/0006_alerts.py @@ -0,0 +1,47 @@ +"""0006 alerts + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-04-07 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0006" +down_revision = "0005" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "alerts", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "device_id", + sa.Integer(), + sa.ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("status", sa.String(20), nullable=False, server_default="open"), + sa.Column("message", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("acknowledged_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "acknowledged_by", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_alerts_device_status", "alerts", ["device_id", "status"]) + op.create_index("ix_alerts_status_created", "alerts", ["status", "created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_alerts_status_created", table_name="alerts") + op.drop_index("ix_alerts_device_status", table_name="alerts") + op.drop_table("alerts") diff --git a/backend/app/main.py b/backend/app/main.py index cc9483b..bae006d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,8 @@ from fastapi.openapi.utils import get_openapi from app.middleware.auth import AuthMiddleware from app.plugins.loader import load_plugins -from app.routers import auth, devices, events, objects, racks, snapshots, tags, tasks, zones +from app.routers import alerts, auth, devices, events, objects, racks, snapshots, tags, tasks, zones +from app.workers.monitor import start_monitor, stop_monitor from app.workers.startup import recover_stale_tasks @@ -14,7 +15,9 @@ from app.workers.startup import recover_stale_tasks async def lifespan(app: FastAPI): load_plugins() await recover_stale_tasks() + start_monitor() yield + await stop_monitor() def create_app() -> FastAPI: @@ -42,6 +45,7 @@ def create_app() -> FastAPI: app.include_router(racks.router) app.include_router(zones.router) app.include_router(tags.router) + app.include_router(alerts.router) app.include_router(snapshots.router) app.include_router(tasks.router) app.include_router(events.router) diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py new file mode 100644 index 0000000..064e510 --- /dev/null +++ b/backend/app/models/alert.py @@ -0,0 +1,35 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base + + +class Alert(Base): + __tablename__ = "alerts" + + id: Mapped[int] = mapped_column(Integer(), autoincrement=True, primary_key=True) + device_id: Mapped[int] = mapped_column( + Integer(), ForeignKey("devices.id", ondelete="CASCADE"), nullable=False, index=True + ) + # "open" | "acknowledged" | "resolved" + status: Mapped[str] = mapped_column(String(20), nullable=False, default="open") + message: Mapped[str] = mapped_column(Text(), nullable=False) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + acknowledged_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + acknowledged_by: Mapped[Optional[int]] = mapped_column( + Integer(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + resolved_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + device: Mapped["Device"] = relationship("Device") + acknowledger: Mapped[Optional["User"]] = relationship( + "User", foreign_keys=[acknowledged_by] + ) diff --git a/backend/app/routers/alerts.py b/backend/app/routers/alerts.py new file mode 100644 index 0000000..761210f --- /dev/null +++ b/backend/app/routers/alerts.py @@ -0,0 +1,158 @@ +from datetime import datetime, timezone +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.dependencies import get_current_user, get_db +from app.models.alert import Alert +from app.models.device import Device +from app.models.user import User +from app.schemas.alert import AlertRead +from app.services.sse import SSEEvent, sse_manager + +router = APIRouter(prefix="/api/v1/alerts", tags=["alerts"]) + +# SSE channel for all monitor/alert events +MONITOR_CHANNEL = "__monitor__" + + +def _to_read(alert: Alert) -> AlertRead: + return AlertRead( + id=alert.id, + device_id=alert.device_id, + device_ip=alert.device.ip if alert.device else None, + device_hostname=alert.device.hostname if alert.device else None, + object_id=alert.device.object_id if alert.device else None, + object_name=( + alert.device.object.name + if alert.device and alert.device.object + else None + ), + status=alert.status, + message=alert.message, + created_at=alert.created_at, + acknowledged_at=alert.acknowledged_at, + acknowledged_by_username=( + alert.acknowledger.username if alert.acknowledger else None + ), + resolved_at=alert.resolved_at, + ) + + +async def _load_alert(db: AsyncSession, alert_id: int) -> Alert: + result = await db.execute( + select(Alert) + .options( + selectinload(Alert.device).selectinload(Device.object), + selectinload(Alert.acknowledger), + ) + .where(Alert.id == alert_id) + ) + alert = result.scalar_one_or_none() + if alert is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert not found") + return alert + + +@router.get("", response_model=list[AlertRead]) +async def list_alerts( + alert_status: Optional[str] = Query(None, alias="status"), + object_id: Optional[int] = Query(None), + device_id: Optional[int] = Query(None), + limit: int = Query(100, ge=1, le=500), + skip: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + query = ( + select(Alert) + .options( + selectinload(Alert.device).selectinload(Device.object), + selectinload(Alert.acknowledger), + ) + .order_by(Alert.created_at.desc()) + .offset(skip) + .limit(limit) + ) + + if alert_status and alert_status != "all": + query = query.where(Alert.status == alert_status) + if device_id is not None: + query = query.where(Alert.device_id == device_id) + if object_id is not None: + query = query.join(Device, Alert.device_id == Device.id).where( + Device.object_id == object_id + ) + + result = await db.execute(query) + return [_to_read(a) for a in result.scalars().all()] + + +@router.post("/{alert_id}/acknowledge", response_model=AlertRead) +async def acknowledge_alert( + alert_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + alert = await _load_alert(db, alert_id) + if alert.status not in ("open",): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot acknowledge alert with status '{alert.status}'", + ) + alert.status = "acknowledged" + alert.acknowledged_at = datetime.now(timezone.utc) + alert.acknowledged_by = current_user.id + await db.flush() + await db.refresh(alert) + # Reload relationships after flush + alert = await _load_alert(db, alert_id) + result = _to_read(alert) + + await sse_manager.publish( + SSEEvent( + type="alert_updated", + task_id=MONITOR_CHANNEL, + data={ + "alert_id": alert_id, + "status": "acknowledged", + "device_id": alert.device_id, + }, + ) + ) + return result + + +@router.post("/{alert_id}/resolve", response_model=AlertRead) +async def resolve_alert( + alert_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + alert = await _load_alert(db, alert_id) + if alert.status == "resolved": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Alert is already resolved", + ) + alert.status = "resolved" + alert.resolved_at = datetime.now(timezone.utc) + await db.flush() + alert = await _load_alert(db, alert_id) + result = _to_read(alert) + + await sse_manager.publish( + SSEEvent( + type="alert_updated", + task_id=MONITOR_CHANNEL, + data={ + "alert_id": alert_id, + "status": "resolved", + "device_id": alert.device_id, + }, + ) + ) + return result diff --git a/backend/app/schemas/alert.py b/backend/app/schemas/alert.py new file mode 100644 index 0000000..3b1de2c --- /dev/null +++ b/backend/app/schemas/alert.py @@ -0,0 +1,21 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class AlertRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + device_id: int + device_ip: Optional[str] = None + device_hostname: Optional[str] = None + object_id: Optional[int] = None + object_name: Optional[str] = None + status: str + message: str + created_at: datetime + acknowledged_at: Optional[datetime] = None + acknowledged_by_username: Optional[str] = None + resolved_at: Optional[datetime] = None diff --git a/backend/app/workers/monitor.py b/backend/app/workers/monitor.py new file mode 100644 index 0000000..f84a61e --- /dev/null +++ b/backend/app/workers/monitor.py @@ -0,0 +1,216 @@ +""" +MonitorWorker — periodically pings all monitor-enabled devices and manages alerts. + +Flow (every MONITOR_INTERVAL seconds): + 1. Load all monitor_enabled=True devices from DB + 2. Ping each device concurrently (semaphore-limited) + 3. For each result: + - Update device.status / last_seen / last_ping_ms + - If offline and no open alert → create alert + SSE push + - If online and open alerts exist → auto-resolve + SSE push + 4. Publish SSE device_status event (frontend updates badge in real-time) + +SSE channel: task_id="__monitor__" (shared channel for all monitor events) +""" + +import asyncio +import time +from datetime import datetime, timezone + +from sqlalchemy import select, update + +from app.models.alert import Alert +from app.models.device import Device +from app.services.database import AsyncSessionLocal +from app.services.sse import SSEEvent, sse_manager + +# ── Configuration ────────────────────────────────────────────────────────────── +MONITOR_INTERVAL = 60 # seconds between full check cycles +PING_TIMEOUT = 3 # seconds per ping attempt +PING_CONCURRENCY = 30 # max simultaneous pings + +MONITOR_CHANNEL = "__monitor__" + +# ── Lifecycle ────────────────────────────────────────────────────────────────── +_stop_event: asyncio.Event | None = None +_worker_task: asyncio.Task | None = None + + +def start_monitor() -> None: + global _stop_event, _worker_task + _stop_event = asyncio.Event() + _worker_task = asyncio.create_task(_monitor_loop()) + print("[monitor] Worker started") + + +async def stop_monitor() -> None: + global _stop_event, _worker_task + if _stop_event: + _stop_event.set() + if _worker_task: + _worker_task.cancel() + try: + await _worker_task + except asyncio.CancelledError: + pass + print("[monitor] Worker stopped") + + +# ── Main loop ────────────────────────────────────────────────────────────────── +async def _monitor_loop() -> None: + assert _stop_event is not None + while not _stop_event.is_set(): + try: + await _run_cycle() + except Exception as exc: + print(f"[monitor] Cycle error: {exc}") + # Wait MONITOR_INTERVAL or until stop is requested + try: + await asyncio.wait_for(_stop_event.wait(), timeout=MONITOR_INTERVAL) + except asyncio.TimeoutError: + pass # normal — interval elapsed + + +async def _run_cycle() -> None: + """Load monitor-enabled devices and check each one.""" + async with AsyncSessionLocal() as db: + result = await db.execute( + select(Device.id, Device.ip, Device.object_id) + .where(Device.monitor_enabled == True, Device.is_active == True) + ) + rows = result.all() + + if not rows: + return + + sem = asyncio.Semaphore(PING_CONCURRENCY) + await asyncio.gather( + *[_check_device(row.id, row.ip, row.object_id, sem) for row in rows], + return_exceptions=True, + ) + + +# ── Per-device check ─────────────────────────────────────────────────────────── +async def _check_device( + device_id: int, + ip: str, + object_id: int, + sem: asyncio.Semaphore, +) -> None: + async with sem: + success, ping_ms = await _ping(ip) + + now = datetime.now(timezone.utc) + new_status = "online" if success else "offline" + new_alert_id: int | None = None + resolved_count = 0 + + async with AsyncSessionLocal() as db: + async with db.begin(): + device = await db.get(Device, device_id) + if device is None: + return + + device.status = new_status + if success: + device.last_seen = now + device.last_ping_ms = ping_ms + + if not success: + # Create alert only if none is already open/acknowledged + existing = await db.execute( + select(Alert) + .where( + Alert.device_id == device_id, + Alert.status.in_(["open", "acknowledged"]), + ) + .limit(1) + ) + if existing.scalar_one_or_none() is None: + alert = Alert( + device_id=device_id, + status="open", + message=f"Устройство {ip} недоступно (ping failed)", + created_at=now, + ) + db.add(alert) + await db.flush() + new_alert_id = alert.id + else: + # Auto-resolve any open / acknowledged alerts + open_alerts_result = await db.execute( + select(Alert).where( + Alert.device_id == device_id, + Alert.status.in_(["open", "acknowledged"]), + ) + ) + open_alerts = open_alerts_result.scalars().all() + for a in open_alerts: + a.status = "resolved" + a.resolved_at = now + resolved_count += 1 + + # ── SSE publish (after DB commit) ───────────────────────────────────────── + await sse_manager.publish( + SSEEvent( + type="device_status", + task_id=MONITOR_CHANNEL, + data={ + "device_id": device_id, + "object_id": object_id, + "ip": ip, + "status": new_status, + "ping_ms": ping_ms if success else None, + "ts": now.isoformat(), + }, + ) + ) + + if new_alert_id is not None: + await sse_manager.publish( + SSEEvent( + type="alert_created", + task_id=MONITOR_CHANNEL, + data={ + "alert_id": new_alert_id, + "device_id": device_id, + "object_id": object_id, + "ip": ip, + }, + ) + ) + + if resolved_count > 0: + await sse_manager.publish( + SSEEvent( + type="alert_updated", + task_id=MONITOR_CHANNEL, + data={ + "device_id": device_id, + "object_id": object_id, + "resolved_count": resolved_count, + }, + ) + ) + + +# ── ICMP ping helper ─────────────────────────────────────────────────────────── +async def _ping(ip: str) -> tuple[bool, int]: + """ + Returns (success, latency_ms). + Uses system `ping` via subprocess — works in Linux containers. + """ + start = time.monotonic() + try: + proc = await asyncio.create_subprocess_exec( + "ping", "-c", "1", "-W", str(PING_TIMEOUT), ip, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await asyncio.wait_for(proc.wait(), timeout=PING_TIMEOUT + 2) + ms = int((time.monotonic() - start) * 1000) + return proc.returncode == 0, ms + except asyncio.TimeoutError: + return False, 0 + except Exception: + return False, 0 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 269b747..43550cb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { AppLayout } from './components/AppLayout' +import { AlertsPage } from './pages/Alerts' import { LoginPage } from './pages/Login' import { ObjectDetailPage } from './pages/ObjectDetail' import { ObjectsPage } from './pages/Objects' @@ -26,6 +27,7 @@ export default function App() { } > } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/alerts.ts b/frontend/src/api/alerts.ts new file mode 100644 index 0000000..2307df0 --- /dev/null +++ b/frontend/src/api/alerts.ts @@ -0,0 +1,52 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { api } from './client' + +export interface AlertItem { + id: number + device_id: number + device_ip: string | null + device_hostname: string | null + object_id: number | null + object_name: string | null + status: 'open' | 'acknowledged' | 'resolved' + message: string + created_at: string + acknowledged_at: string | null + acknowledged_by_username: string | null + resolved_at: string | null +} + +export const useAlerts = (params?: { + status?: string + object_id?: number + device_id?: number + limit?: number + skip?: number + refetchInterval?: number +}) => { + const { refetchInterval, ...queryParams } = params ?? {} + return useQuery({ + queryKey: ['alerts', queryParams], + queryFn: () => + api.get('/api/v1/alerts', { params: queryParams }).then((r) => r.data), + refetchInterval: refetchInterval ?? false, + }) +} + +export const useAcknowledgeAlert = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => + api.post(`/api/v1/alerts/${id}/acknowledge`).then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts'] }), + }) +} + +export const useResolveAlert = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => + api.post(`/api/v1/alerts/${id}/resolve`).then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts'] }), + }) +} diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index af6ffb0..22847ed 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -1,21 +1,48 @@ import { ApartmentOutlined, + BellOutlined, LogoutOutlined, ThunderboltOutlined, } from '@ant-design/icons' -import { Layout, Menu, Typography } from 'antd' +import { Badge, Layout, Menu, Typography } from 'antd' import { useNavigate, useLocation, Outlet } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' import { useAuthStore } from '../store/auth' import { api } from '../api/client' +import { useAlerts } from '../api/alerts' +import { useMonitorSSE } from '../hooks/useMonitorSSE' -const { Sider, Content, Header } = Layout +const { Sider, Content } = Layout export function AppLayout() { const navigate = useNavigate() const location = useLocation() + const qc = useQueryClient() const { username, refreshToken, clearTokens } = useAuthStore() - const selectedKey = location.pathname.startsWith('/tasks') ? 'tasks' : 'objects' + // Open alert count for sidebar badge — refresh every 60s + const { data: openAlerts } = useAlerts({ + status: 'open', + limit: 500, + refetchInterval: 60_000, + }) + const openCount = openAlerts?.length ?? 0 + + // Global monitor SSE — invalidates caches when monitor events arrive + useMonitorSSE({ + onAlertCreated: () => qc.invalidateQueries({ queryKey: ['alerts'] }), + onAlertUpdated: () => qc.invalidateQueries({ queryKey: ['alerts'] }), + onDeviceStatus: (ev) => { + // Invalidate devices for the affected object so status badge updates live + qc.invalidateQueries({ queryKey: ['devices', ev.object_id] }) + }, + }) + + const selectedKey = location.pathname.startsWith('/tasks') + ? 'tasks' + : location.pathname.startsWith('/alerts') + ? 'alerts' + : 'objects' const handleLogout = async () => { try { @@ -45,6 +72,19 @@ export function AppLayout() { label: 'Объекты', onClick: () => navigate('/objects'), }, + { + key: 'alerts', + icon: , + label: ( + + Алерты + {openCount > 0 && ( + + )} + + ), + onClick: () => navigate('/alerts'), + }, { key: 'tasks', icon: , diff --git a/frontend/src/hooks/useMonitorSSE.ts b/frontend/src/hooks/useMonitorSSE.ts new file mode 100644 index 0000000..2a1822a --- /dev/null +++ b/frontend/src/hooks/useMonitorSSE.ts @@ -0,0 +1,75 @@ +/** + * useMonitorSSE — subscribes to the global monitor SSE channel ("__monitor__"). + * + * The backend publishes three event types on this channel: + * device_status — { device_id, object_id, ip, status, ping_ms, ts } + * alert_created — { alert_id, device_id, object_id, ip } + * alert_updated — { alert_id?, device_id, resolved_count? } + */ + +import { fetchEventSource } from '@microsoft/fetch-event-source' +import { useEffect, useRef } from 'react' +import { useAuthStore } from '../store/auth' + +export interface DeviceStatusEvent { + device_id: number + object_id: number + ip: string + status: 'online' | 'offline' + ping_ms: number | null + ts: string +} + +export interface AlertCreatedEvent { + alert_id: number + device_id: number + object_id: number + ip: string +} + +export interface AlertUpdatedEvent { + alert_id?: number + device_id: number + status?: string + resolved_count?: number +} + +interface MonitorHandlers { + onDeviceStatus?: (data: DeviceStatusEvent) => void + onAlertCreated?: (data: AlertCreatedEvent) => void + onAlertUpdated?: (data: AlertUpdatedEvent) => void +} + +const MONITOR_CHANNEL = '__monitor__' + +export function useMonitorSSE(handlers: MonitorHandlers) { + const accessToken = useAuthStore((s) => s.accessToken) + const handlersRef = useRef(handlers) + handlersRef.current = handlers + + useEffect(() => { + if (!accessToken) return + const ctrl = new AbortController() + + fetchEventSource(`/api/v1/events?task_id=${MONITOR_CHANNEL}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: ctrl.signal, + onmessage(ev) { + try { + const data = JSON.parse(ev.data) + if (ev.event === 'device_status') handlersRef.current.onDeviceStatus?.(data) + else if (ev.event === 'alert_created') handlersRef.current.onAlertCreated?.(data) + else if (ev.event === 'alert_updated') handlersRef.current.onAlertUpdated?.(data) + } catch { + // ignore parse errors + } + }, + onerror() { + // fetchEventSource retries automatically; abort only on explicit error + ctrl.abort() + }, + }) + + return () => ctrl.abort() + }, [accessToken]) +} diff --git a/frontend/src/pages/Alerts.tsx b/frontend/src/pages/Alerts.tsx new file mode 100644 index 0000000..0468d4c --- /dev/null +++ b/frontend/src/pages/Alerts.tsx @@ -0,0 +1,239 @@ +import { + CheckCircleOutlined, + ClockCircleOutlined, + ExclamationCircleOutlined, +} from '@ant-design/icons' +import { + Badge, + Breadcrumb, + Button, + Col, + Popconfirm, + Row, + Select, + Space, + Table, + Tag, + Tooltip, + Typography, + message, +} from 'antd' +import { useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { + useAlerts, + useAcknowledgeAlert, + useResolveAlert, + type AlertItem, +} from '../api/alerts' +import { useMonitorSSE } from '../hooks/useMonitorSSE' +import dayjs from 'dayjs' + +const STATUS_CONFIG: Record< + string, + { label: string; color: string; icon: React.ReactNode } +> = { + open: { label: 'Открыт', color: 'error', icon: }, + acknowledged: { label: 'Принят', color: 'warning', icon: }, + resolved: { label: 'Решён', color: 'success', icon: }, +} + +function AlertStatusTag({ status }: { status: string }) { + const cfg = STATUS_CONFIG[status] ?? { label: status, color: 'default', icon: null } + return ( + + {cfg.label} + + ) +} + +export function AlertsPage() { + const navigate = useNavigate() + const qc = useQueryClient() + const [statusFilter, setStatusFilter] = useState('open') + + const { data: alerts, isLoading } = useAlerts({ + status: statusFilter === 'all' ? undefined : statusFilter, + limit: 200, + refetchInterval: 60_000, + }) + + const acknowledge = useAcknowledgeAlert() + const resolve = useResolveAlert() + + // Live updates via monitor SSE + useMonitorSSE({ + onAlertCreated: () => qc.invalidateQueries({ queryKey: ['alerts'] }), + onAlertUpdated: () => qc.invalidateQueries({ queryKey: ['alerts'] }), + }) + + const handleAcknowledge = async (id: number) => { + try { + await acknowledge.mutateAsync(id) + message.success('Алерт принят') + } catch { + message.error('Ошибка') + } + } + + const handleResolve = async (id: number) => { + try { + await resolve.mutateAsync(id) + message.success('Алерт закрыт') + } catch { + message.error('Ошибка') + } + } + + const columns = [ + { + title: 'Статус', + dataIndex: 'status', + key: 'status', + width: 130, + render: (v: string) => , + }, + { + title: 'Устройство', + key: 'device', + render: (_: unknown, row: AlertItem) => ( + + {row.device_ip ?? '—'} + {row.device_hostname && ( + + {row.device_hostname} + + )} + + ), + }, + { + title: 'Объект', + dataIndex: 'object_name', + key: 'object_name', + render: (v: string | null, row: AlertItem) => + v ? ( + navigate(`/objects/${row.object_id}`)}>{v} + ) : ( + '—' + ), + }, + { + title: 'Сообщение', + dataIndex: 'message', + key: 'message', + }, + { + title: 'Создан', + dataIndex: 'created_at', + key: 'created_at', + render: (v: string) => ( + + {dayjs(v).format('DD.MM HH:mm')} + + ), + }, + { + title: 'Принят', + key: 'acknowledged', + render: (_: unknown, row: AlertItem) => + row.acknowledged_at ? ( + + + {dayjs(row.acknowledged_at).format('DD.MM HH:mm')} + + {row.acknowledged_by_username && ( + + {row.acknowledged_by_username} + + )} + + ) : ( + '—' + ), + }, + { + title: '', + key: 'actions', + width: 160, + render: (_: unknown, row: AlertItem) => ( + + {row.status === 'open' && ( + + )} + {row.status !== 'resolved' && ( + handleResolve(row.id)} + > + + + )} + + ), + }, + ] + + const openCount = (alerts ?? []).filter((a) => a.status === 'open').length + + return ( +
+ + + + + + + Алерты + + {openCount > 0 && ( + + )} + + + +