diff --git a/backend/app/services/dashboard_monitor.py b/backend/app/services/dashboard_monitor.py index 1ca83b4..ddedee4 100644 --- a/backend/app/services/dashboard_monitor.py +++ b/backend/app/services/dashboard_monitor.py @@ -472,6 +472,20 @@ async def run_dashboard_monitor_cycle( logger.info("Dashboard monitor: %d devices loaded for checking", len(device_rows)) checks = (await db.execute(select(DashboardCheckDefinition))).scalars().all() + + await sse_manager.publish( + SSEEvent( + type="dashboard_monitor_cycle_start", + task_id=DASHBOARD_MONITOR_CHANNEL, + data={ + "run_type": run_type, + "objects_allowed": len(allowed_object_ids), + "devices_total": len(device_rows), + "checks_planned": [c.check_key for c in checks if c.enabled], + "ts": datetime.now(timezone.utc).isoformat(), + }, + ) + ) overrides = ( await db.execute( select(DashboardObjectCheckOverride).where( @@ -625,6 +639,7 @@ async def run_dashboard_monitor_cycle( now=checked_at, ) + check_failed = sum(1 for r in results if isinstance(r, Exception) or (not isinstance(r, Exception) and not r[0])) await sse_manager.publish( SSEEvent( type="dashboard_monitor_update", @@ -632,6 +647,8 @@ async def run_dashboard_monitor_cycle( data={ "check_key": check.check_key, "processed": len(due_devices), + "due": len(due_devices), + "failed": check_failed, "ts": datetime.now(timezone.utc).isoformat(), }, ) diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 80f40d9..2724bb7 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -41,6 +41,12 @@ export interface DashboardMonitorStatus { last_counts: Record | null } +export interface DashboardRunResponse { + accepted: boolean + running: boolean + detail: string +} + export interface DashboardObjectPolicy { object_id: number mode: 'inherit' | 'include' | 'exclude' @@ -249,7 +255,7 @@ export const useDashboardRunNow = () => { const qc = useQueryClient() return useMutation({ mutationFn: (body: DashboardScope & { force?: boolean }) => - api.post('/api/v1/dashboard/monitor/run', body).then((r) => r.data), + api.post('/api/v1/dashboard/monitor/run', body).then((r) => r.data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['dashboard-monitor-status'] }) qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] }) diff --git a/frontend/src/hooks/useDashboardMonitorSSE.ts b/frontend/src/hooks/useDashboardMonitorSSE.ts index 48f9e9f..b41b967 100644 --- a/frontend/src/hooks/useDashboardMonitorSSE.ts +++ b/frontend/src/hooks/useDashboardMonitorSSE.ts @@ -24,6 +24,7 @@ export function useDashboardMonitorSSE(handlers: DashboardMonitorHandlers) { try { const data = JSON.parse(ev.data) if ( + ev.event === 'dashboard_monitor_cycle_start' || ev.event === 'dashboard_monitor_update' || ev.event === 'dashboard_monitor_cycle_done' || ev.event === 'dashboard_monitor_cycle_failed' diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 30ec9f8..d63be26 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -124,8 +124,12 @@ export function HomePage() { const handleRunNow = async () => { try { - await runNow.mutateAsync({ ...scope, force: true }) - message.success('Мониторинг запущен') + const result = await runNow.mutateAsync({ ...scope, force: true }) + if (!result.accepted) { + message.warning('Мониторинг уже запущен') + } else { + message.success('Мониторинг запущен') + } } catch { message.error('Не удалось запустить мониторинг') } diff --git a/frontend/src/pages/HomeSettings.tsx b/frontend/src/pages/HomeSettings.tsx index a0ef068..af478ca 100644 --- a/frontend/src/pages/HomeSettings.tsx +++ b/frontend/src/pages/HomeSettings.tsx @@ -1,12 +1,14 @@ -import { ArrowLeftOutlined } from '@ant-design/icons' +import { ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons' import { Alert, + Badge, Button, Card, Checkbox, Col, Collapse, InputNumber, + Progress, Row, Space, Switch, @@ -27,6 +29,7 @@ import { usePatchDashboardObjectPolicy, usePatchDashboardTagPolicy, } from '../api/dashboard' +import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE' import { useTags } from '../api/tags' function cityKey(city: string | null): string { @@ -39,6 +42,36 @@ function formatCheckTitle(checkKey: string): string { return checkKey } +interface CheckProgress { + processed: number + due: number + failed: number +} + +interface RunProgress { + phase: 'idle' | 'running' | 'done' | 'failed' + startedAt: string | null + objectsAllowed: number | null + devicesTotal: number | null + checksPlanned: string[] + checkProgress: Record + counts: Record | null + error: string | null + finishedAt: string | null +} + +const EMPTY_PROGRESS: RunProgress = { + phase: 'idle', + startedAt: null, + objectsAllowed: null, + devicesTotal: null, + checksPlanned: [], + checkProgress: {}, + counts: null, + error: null, + finishedAt: null, +} + export function HomeSettingsPage() { const navigate = useNavigate() @@ -54,6 +87,52 @@ export function HomeSettingsPage() { const patchChecks = usePatchDashboardChecks() const [checkIntervalsMin, setCheckIntervalsMin] = useState>({}) + const [runProgress, setRunProgress] = useState(EMPTY_PROGRESS) + + useDashboardMonitorSSE({ + onUpdate: (eventType, data) => { + if (eventType === 'dashboard_monitor_cycle_start') { + setRunProgress({ + phase: 'running', + startedAt: (data.ts as string) ?? null, + objectsAllowed: (data.objects_allowed as number) ?? null, + devicesTotal: (data.devices_total as number) ?? null, + checksPlanned: (data.checks_planned as string[]) ?? [], + checkProgress: {}, + counts: null, + error: null, + finishedAt: null, + }) + } else if (eventType === 'dashboard_monitor_update') { + const key = data.check_key as string + setRunProgress((prev) => ({ + ...prev, + checkProgress: { + ...prev.checkProgress, + [key]: { + processed: (data.processed as number) ?? 0, + due: (data.due as number) ?? 0, + failed: (data.failed as number) ?? 0, + }, + }, + })) + } else if (eventType === 'dashboard_monitor_cycle_done') { + setRunProgress((prev) => ({ + ...prev, + phase: 'done', + counts: (data.counts as Record) ?? null, + finishedAt: (data.ts as string) ?? null, + })) + } else if (eventType === 'dashboard_monitor_cycle_failed') { + setRunProgress((prev) => ({ + ...prev, + phase: 'failed', + error: (data.error as string) ?? 'Неизвестная ошибка', + finishedAt: (data.ts as string) ?? null, + })) + } + }, + }) useEffect(() => { const next: Record = {} @@ -174,8 +253,12 @@ export function HomeSettingsPage() { const handleRunNow = async () => { try { - await runNow.mutateAsync({ force: true }) - message.success('Мониторинг запущен') + const result = await runNow.mutateAsync({ force: true }) + if (!result.accepted) { + message.warning('Мониторинг уже запущен') + } else { + message.success('Мониторинг запущен') + } } catch { message.error('Не удалось запустить мониторинг') } @@ -216,6 +299,89 @@ export function HomeSettingsPage() { ) : null} + {runProgress.phase !== 'idle' && ( + + {runProgress.phase === 'running' && } + {runProgress.phase === 'done' && } + {runProgress.phase === 'failed' && } + Текущий прогон + + + } + > + + + {runProgress.objectsAllowed != null && `Объектов: ${runProgress.objectsAllowed}`} + {runProgress.devicesTotal != null && ` • Устройств: ${runProgress.devicesTotal}`} + {runProgress.startedAt && ` • Начат: ${dayjs(runProgress.startedAt).format('HH:mm:ss')}`} + {runProgress.finishedAt && ` • Завершён: ${dayjs(runProgress.finishedAt).format('HH:mm:ss')}`} + + + {runProgress.checksPlanned.map((checkKey) => { + const cp = runProgress.checkProgress[checkKey] + const due = cp?.due ?? 0 + const processed = cp?.processed ?? 0 + const failed = cp?.failed ?? 0 + const pct = due > 0 ? Math.round((processed / due) * 100) : runProgress.phase === 'done' ? 100 : 0 + return ( +
+ + + {formatCheckTitle(checkKey)} + {failed > 0 && ( + + {failed} ошибок + + )} + + + + {cp ? `${processed} / ${due}` : '—'} + + + + 0 ? 'normal' : undefined} + strokeColor={failed > 0 ? '#faad14' : undefined} + size="small" + /> +
+ ) + })} + + {runProgress.phase === 'done' && runProgress.counts && ( + + + Проверено: + {runProgress.counts.devices_checked ?? 0} + + + Ошибок: + 0 ? '#ff4d4f' : undefined }}> + {runProgress.counts.failed ?? 0} + + + + Пропущено: + {runProgress.counts.skipped_not_due ?? 0} + + + )} + + {runProgress.phase === 'failed' && runProgress.error && ( + + )} +
+
+ )} + {(checksData?.checks ?? []).length === 0 ? (