этап4

This commit is contained in:
dv 2026-04-07 13:02:52 +03:00
parent 87bf93de89
commit 6b36485718
12 changed files with 899 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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() {
}
>
<Route index element={<Navigate to="/objects" replace />} />
<Route path="alerts" element={<AlertsPage />} />
<Route path="objects" element={<ObjectsPage />} />
<Route path="objects/:id" element={<ObjectDetailPage />} />
<Route path="objects/:id/snapshots" element={<SnapshotsPage />} />

View file

@ -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<AlertItem[]>('/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<AlertItem>(`/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<AlertItem>(`/api/v1/alerts/${id}/resolve`).then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts'] }),
})
}

View file

@ -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: <BellOutlined />,
label: (
<span>
Алерты
{openCount > 0 && (
<Badge count={openCount} size="small" style={{ marginLeft: 8 }} />
)}
</span>
),
onClick: () => navigate('/alerts'),
},
{
key: 'tasks',
icon: <ThunderboltOutlined />,

View file

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

View file

@ -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: <ExclamationCircleOutlined /> },
acknowledged: { label: 'Принят', color: 'warning', icon: <ClockCircleOutlined /> },
resolved: { label: 'Решён', color: 'success', icon: <CheckCircleOutlined /> },
}
function AlertStatusTag({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status] ?? { label: status, color: 'default', icon: null }
return (
<Tag color={cfg.color} icon={cfg.icon}>
{cfg.label}
</Tag>
)
}
export function AlertsPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [statusFilter, setStatusFilter] = useState<string>('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) => <AlertStatusTag status={v} />,
},
{
title: 'Устройство',
key: 'device',
render: (_: unknown, row: AlertItem) => (
<Space direction="vertical" size={0}>
<code style={{ fontSize: 13 }}>{row.device_ip ?? '—'}</code>
{row.device_hostname && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.device_hostname}
</Typography.Text>
)}
</Space>
),
},
{
title: 'Объект',
dataIndex: 'object_name',
key: 'object_name',
render: (v: string | null, row: AlertItem) =>
v ? (
<a onClick={() => navigate(`/objects/${row.object_id}`)}>{v}</a>
) : (
'—'
),
},
{
title: 'Сообщение',
dataIndex: 'message',
key: 'message',
},
{
title: 'Создан',
dataIndex: 'created_at',
key: 'created_at',
render: (v: string) => (
<Tooltip title={dayjs(v).format('DD.MM.YYYY HH:mm:ss')}>
{dayjs(v).format('DD.MM HH:mm')}
</Tooltip>
),
},
{
title: 'Принят',
key: 'acknowledged',
render: (_: unknown, row: AlertItem) =>
row.acknowledged_at ? (
<Space direction="vertical" size={0}>
<Typography.Text style={{ fontSize: 12 }}>
{dayjs(row.acknowledged_at).format('DD.MM HH:mm')}
</Typography.Text>
{row.acknowledged_by_username && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{row.acknowledged_by_username}
</Typography.Text>
)}
</Space>
) : (
'—'
),
},
{
title: '',
key: 'actions',
width: 160,
render: (_: unknown, row: AlertItem) => (
<Space>
{row.status === 'open' && (
<Button
size="small"
icon={<ClockCircleOutlined />}
onClick={() => handleAcknowledge(row.id)}
loading={acknowledge.isPending}
>
Принять
</Button>
)}
{row.status !== 'resolved' && (
<Popconfirm
title="Закрыть алерт?"
okText="Закрыть"
cancelText="Отмена"
onConfirm={() => handleResolve(row.id)}
>
<Button
size="small"
icon={<CheckCircleOutlined />}
loading={resolve.isPending}
>
Закрыть
</Button>
</Popconfirm>
)}
</Space>
),
},
]
const openCount = (alerts ?? []).filter((a) => a.status === 'open').length
return (
<div>
<Breadcrumb
style={{ marginBottom: 16 }}
items={[{ title: 'Алерты' }]}
/>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Space align="center">
<Typography.Title level={4} style={{ margin: 0 }}>
Алерты
</Typography.Title>
{openCount > 0 && (
<Badge count={openCount} color="red" />
)}
</Space>
</Col>
<Col>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: 160 }}
options={[
{ value: 'open', label: 'Открытые' },
{ value: 'acknowledged', label: 'Принятые' },
{ value: 'resolved', label: 'Закрытые' },
{ value: 'all', label: 'Все' },
]}
/>
</Col>
</Row>
<Table
dataSource={alerts ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="middle"
pagination={{ pageSize: 50 }}
rowClassName={(row) => (row.status === 'open' ? 'alert-row-open' : '')}
/>
</div>
)
}

View file

@ -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() {
</Col>
</Row>
<Form.Item name="monitor_enabled" valuePropName="checked">
<Checkbox>Мониторинг (автоматический ping)</Checkbox>
</Form.Item>
<Divider orientation="left" plain>
SSH (переопределить настройки объекта)
</Divider>