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' && (
+ }
+ onClick={() => handleAcknowledge(row.id)}
+ loading={acknowledge.isPending}
+ >
+ Принять
+
+ )}
+ {row.status !== 'resolved' && (
+ handleResolve(row.id)}
+ >
+ }
+ loading={resolve.isPending}
+ >
+ Закрыть
+
+
+ )}
+
+ ),
+ },
+ ]
+
+ const openCount = (alerts ?? []).filter((a) => a.status === 'open').length
+
+ return (
+
+
+
+
+
+
+
+ Алерты
+
+ {openCount > 0 && (
+
+ )}
+
+
+
+
+
+
+
+
(row.status === 'open' ? 'alert-row-open' : '')}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/ObjectDetail.tsx b/frontend/src/pages/ObjectDetail.tsx
index 5f00492..d577e86 100644
--- a/frontend/src/pages/ObjectDetail.tsx
+++ b/frontend/src/pages/ObjectDetail.tsx
@@ -12,6 +12,7 @@ import {
Alert,
Breadcrumb,
Button,
+ Checkbox,
Col,
Descriptions,
Divider,
@@ -145,6 +146,7 @@ export function ObjectDetailPage() {
model: row.model ?? '',
mac: row.mac ?? '',
rack_id: row.rack_id ?? undefined,
+ monitor_enabled: row.monitor_enabled ?? false,
ssh_user_override: row.ssh_user_override ?? '',
ssh_port_override: row.ssh_port_override ?? undefined,
})
@@ -758,6 +760,10 @@ export function ObjectDetailPage() {
+
+ Мониторинг (автоматический ping)
+
+
SSH (переопределить настройки объекта)