diff --git a/backend/app/routers/alerts.py b/backend/app/routers/alerts.py index 761210f..0c34a59 100644 --- a/backend/app/routers/alerts.py +++ b/backend/app/routers/alerts.py @@ -91,6 +91,42 @@ async def list_alerts( return [_to_read(a) for a in result.scalars().all()] +@router.post("/acknowledge-by-object", response_model=list[AlertRead]) +async def acknowledge_by_object( + object_id: int = Query(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(Alert) + .join(Device, Alert.device_id == Device.id) + .where(Device.object_id == object_id, Alert.status == "open") + ) + alerts = result.scalars().all() + now = datetime.now(timezone.utc) + for alert in alerts: + alert.status = "acknowledged" + alert.acknowledged_at = now + alert.acknowledged_by = current_user.id + await db.flush() + + alert_ids = [a.id for a in alerts] + loaded = [] + for aid in alert_ids: + loaded.append(await _load_alert(db, aid)) + + for alert in loaded: + 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 [_to_read(a) for a in loaded] + + @router.post("/{alert_id}/acknowledge", response_model=AlertRead) async def acknowledge_alert( alert_id: int, diff --git a/backend/app/services/dashboard_monitor.py b/backend/app/services/dashboard_monitor.py index c0a1d2d..58ec9bb 100644 --- a/backend/app/services/dashboard_monitor.py +++ b/backend/app/services/dashboard_monitor.py @@ -549,6 +549,19 @@ async def run_dashboard_monitor_cycle( ", ".join(ips[:10]) + ("…" if len(ips) > 10 else ""), ) + # Загружаем предыдущие ping-статусы до запуска — нужны для retry-логики + pre_ping_status_map: dict[int, str] = {} + if check.check_key == "ping": + pre_state_rows = ( + await db.execute( + select(DashboardDeviceLiveState).where( + DashboardDeviceLiveState.check_key == "ping", + DashboardDeviceLiveState.device_id.in_([d.id for d in due_devices]), + ) + ) + ).scalars().all() + pre_ping_status_map = {row.device_id: row.status for row in pre_state_rows} + sem = asyncio.Semaphore(max(1, check.concurrency)) tasks = [ asyncio.ensure_future(_run_check_on_device(sem, check, device, device.object)) @@ -589,6 +602,78 @@ async def run_dashboard_monitor_cycle( ) ) + # Retry для offline-устройств (только ping): + # срабатывает только если устройство было online (переход online→offline) + if check.check_key == "ping": + offline_devices = [ + r[0] for r in results + if r[2] == "offline" and pre_ping_status_map.get(r[0].id) == "online" + ] + for retry_num in range(1, 3): + if not offline_devices: + break + logger.info( + "Dashboard monitor [ping] retry %d/2: %d offline устройств, ждём 60 сек", + retry_num, len(offline_devices), + ) + await sse_manager.publish( + SSEEvent( + type="dashboard_monitor_update", + task_id=DASHBOARD_MONITOR_CHANNEL, + data={ + "check_key": check.check_key, + "processed": completed_count, + "due": len(due_devices), + "failed": failed_count, + "retry": retry_num, + "retry_pending": len(offline_devices), + "ts": datetime.now(timezone.utc).isoformat(), + }, + ) + ) + await asyncio.sleep(60) + + retry_tasks = [ + asyncio.ensure_future(_run_check_on_device(sem, check, device, device.object)) + for device in offline_devices + ] + retry_results = [] + for coro in asyncio.as_completed(retry_tasks): + r = await coro + retry_results.append(r) + logger.debug( + "Dashboard monitor [ping] retry %d %s: %s", + retry_num, r[0].ip, r[2], + ) + + # Обновляем results: заменяем результат устройства если оно восстановилось + retry_map = {r[0].id: r for r in retry_results} + results = [ + retry_map.get(r[0].id, r) for r in results + ] + offline_devices = [r[0] for r in retry_results if r[2] == "offline"] + recovered = len(retry_results) - len(offline_devices) + failed_count = sum(1 for r in results if not r[1]) + logger.info( + "Dashboard monitor [ping] retry %d/2 done: восстановилось %d, всё ещё offline %d", + retry_num, recovered, len(offline_devices), + ) + await sse_manager.publish( + SSEEvent( + type="dashboard_monitor_update", + task_id=DASHBOARD_MONITOR_CHANNEL, + data={ + "check_key": check.check_key, + "processed": len(due_devices), + "due": len(due_devices), + "failed": failed_count, + "retry": retry_num, + "retry_recovered": recovered, + "ts": datetime.now(timezone.utc).isoformat(), + }, + ) + ) + async with AsyncSessionLocal() as dbw: async with dbw.begin(): # Загружаем предыдущие статусы и baseline только для ping diff --git a/frontend/src/api/alerts.ts b/frontend/src/api/alerts.ts index 2307df0..f4de42a 100644 --- a/frontend/src/api/alerts.ts +++ b/frontend/src/api/alerts.ts @@ -50,3 +50,16 @@ export const useResolveAlert = () => { onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts'] }), }) } + +export const useAcknowledgeByObject = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (objectId: number) => + api + .post(`/api/v1/alerts/acknowledge-by-object`, null, { + params: { object_id: objectId }, + }) + .then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts'] }), + }) +} diff --git a/frontend/src/pages/Alerts.tsx b/frontend/src/pages/Alerts.tsx index 0468d4c..54f1c0e 100644 --- a/frontend/src/pages/Alerts.tsx +++ b/frontend/src/pages/Alerts.tsx @@ -18,13 +18,14 @@ import { Typography, message, } from 'antd' -import { useState } from 'react' +import { useState, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { useAlerts, useAcknowledgeAlert, useResolveAlert, + useAcknowledgeByObject, type AlertItem, } from '../api/alerts' import { useMonitorSSE } from '../hooks/useMonitorSSE' @@ -48,6 +49,12 @@ function AlertStatusTag({ status }: { status: string }) { ) } +interface ObjectGroup { + object_id: number | null + object_name: string | null + alerts: AlertItem[] +} + export function AlertsPage() { const navigate = useNavigate() const qc = useQueryClient() @@ -61,13 +68,28 @@ export function AlertsPage() { const acknowledge = useAcknowledgeAlert() const resolve = useResolveAlert() + const acknowledgeByObject = useAcknowledgeByObject() - // Live updates via monitor SSE useMonitorSSE({ onAlertCreated: () => qc.invalidateQueries({ queryKey: ['alerts'] }), onAlertUpdated: () => qc.invalidateQueries({ queryKey: ['alerts'] }), }) + const groups = useMemo(() => { + if (!alerts) return [] + const map = new Map() + for (const a of alerts) { + const key = a.object_id ?? null + if (!map.has(key)) { + map.set(key, { object_id: key, object_name: a.object_name, alerts: [] }) + } + map.get(key)!.alerts.push(a) + } + return Array.from(map.values()).sort((a, b) => + (a.object_name ?? '').localeCompare(b.object_name ?? '', 'ru') + ) + }, [alerts]) + const handleAcknowledge = async (id: number) => { try { await acknowledge.mutateAsync(id) @@ -86,6 +108,15 @@ export function AlertsPage() { } } + const handleAcknowledgeByObject = async (objectId: number) => { + try { + await acknowledgeByObject.mutateAsync(objectId) + message.success('Все алерты объекта приняты') + } catch { + message.error('Ошибка') + } + } + const columns = [ { title: 'Статус', @@ -108,17 +139,6 @@ export function AlertsPage() { ), }, - { - title: 'Объект', - dataIndex: 'object_name', - key: 'object_name', - render: (v: string | null, row: AlertItem) => - v ? ( - navigate(`/objects/${row.object_id}`)}>{v} - ) : ( - '—' - ), - }, { title: 'Сообщение', dataIndex: 'message', @@ -128,6 +148,7 @@ export function AlertsPage() { title: 'Создан', dataIndex: 'created_at', key: 'created_at', + width: 110, render: (v: string) => ( {dayjs(v).format('DD.MM HH:mm')} @@ -137,6 +158,7 @@ export function AlertsPage() { { title: 'Принят', key: 'acknowledged', + width: 130, render: (_: unknown, row: AlertItem) => row.acknowledged_at ? ( @@ -205,9 +227,7 @@ export function AlertsPage() { Алерты - {openCount > 0 && ( - - )} + {openCount > 0 && } @@ -225,15 +245,60 @@ export function AlertsPage() { - (row.status === 'open' ? 'alert-row-open' : '')} - /> + {isLoading ? ( +
+ ) : ( + groups.map((group) => { + const openInGroup = group.alerts.filter((a) => a.status === 'open').length + return ( +
+ +
+ + {group.object_id ? ( + navigate(`/work/objects/${group.object_id}`)} + > + {group.object_name ?? `Объект #${group.object_id}`} + + ) : ( + Без объекта + )} + + + + {openInGroup > 0 && group.object_id != null && ( + + handleAcknowledgeByObject(group.object_id!)} + > + + + + )} + +
(row.status === 'open' ? 'alert-row-open' : '')} + /> + + ) + }) + )} ) }