import { AppstoreOutlined, BarsOutlined } from '@ant-design/icons' import { useQueryClient } from '@tanstack/react-query' import { Alert, Button, Card, Col, Empty, List, Progress, Row, Select, Space, Statistic, Tag, Typography, message, } from 'antd' import dayjs from 'dayjs' import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useObjects, useObjectsMeta } from '../api/objects' import { useDashboardHomeSummary, useDashboardMonitorStatus, useDashboardRunNow, type DashboardScope, type DashboardObjectSummary, } from '../api/dashboard' import { useTags } from '../api/tags' import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE' import { ObjectStatusCard, getSeverity, SEVERITY_COLOR, SEVERITY_LABEL, SEVERITY_TAG_COLOR, type Severity, } from '../components/ObjectStatusCard' const SEVERITY_ORDER: Record = { critical: 0, degraded: 1, warn: 2, ok: 3 } function formatAge(seconds: number | null | undefined): string { if (seconds == null) return '—' if (seconds < 60) return `${seconds}с` if (seconds < 3600) return `${Math.floor(seconds / 60)}м` return `${Math.floor(seconds / 3600)}ч ${Math.floor((seconds % 3600) / 60)}м` } function scopeToSearch(scope: DashboardScope): string { const params = new URLSearchParams() if (scope.city) params.set('city', scope.city) if (scope.client) params.set('client', scope.client) for (const id of scope.object_ids ?? []) params.append('object_ids', String(id)) for (const id of scope.tag_ids ?? []) params.append('tag_ids', String(id)) const raw = params.toString() return raw ? `?${raw}` : '' } function healthColor(score: number | null | undefined): string { if (score == null) return 'default' if (score >= 85) return 'green' if (score >= 65) return 'orange' return 'red' } function runStatusColor(status: string | undefined): string { if (status === 'running') return 'processing' if (status === 'error') return 'red' if (status === 'idle') return 'blue' return 'default' } function toPct(numerator: number, denominator: number): number | null { if (denominator <= 0) return null return Math.round((numerator / denominator) * 1000) / 10 } type ViewMode = 'grid' | 'list' function getStoredViewMode(): ViewMode { try { const v = localStorage.getItem('noc-view-mode') if (v === 'grid' || v === 'list') return v } catch {} return 'grid' } const UNGROUPED_CITY = '__none__' export function HomePage() { const qc = useQueryClient() const navigate = useNavigate() const { data: meta } = useObjectsMeta() const { data: objects } = useObjects() const { data: tags } = useTags() const [city, setCity] = useState() const [client, setClient] = useState() const [objectIds, setObjectIds] = useState([]) const [tagIds, setTagIds] = useState([]) const [severityFilter, setSeverityFilter] = useState('all') const [viewMode, setViewMode] = useState(getStoredViewMode) const scope = useMemo( () => ({ city, client, object_ids: objectIds, tag_ids: tagIds }), [city, client, objectIds, tagIds] ) const { data: summary, isLoading } = useDashboardHomeSummary(scope) const { data: monitorStatus } = useDashboardMonitorStatus() const runNow = useDashboardRunNow() useDashboardMonitorSSE({ onUpdate: () => { qc.invalidateQueries({ queryKey: ['dashboard-home-summary'] }) qc.invalidateQueries({ queryKey: ['dashboard-monitor-status'] }) qc.invalidateQueries({ queryKey: ['dashboard-object-summary'] }) qc.invalidateQueries({ queryKey: ['dashboard-object-devices'] }) qc.invalidateQueries({ queryKey: ['dashboard-object-availability'] }) }, }) const handleRunNow = async () => { try { const result = await runNow.mutateAsync({ ...scope, force: true }) if (!result.accepted) { message.warning('Мониторинг уже запущен') } else { message.success('Мониторинг запущен') } } catch { message.error('Не удалось запустить мониторинг') } } const handleViewMode = (mode: ViewMode) => { setViewMode(mode) try { localStorage.setItem('noc-view-mode', mode) } catch {} } const totals = summary?.totals ?? { objects: 0, devices: 0, allowed_objects: 0, excluded_objects: 0 } const included = summary?.included_objects ?? [] const pingInterval = useMemo( () => summary?.checks_meta?.find((c) => c.check_key === 'ping')?.interval_sec_default ?? 300, [summary] ) const staleThreshold = pingInterval * 3 const rankedObjects = useMemo(() => { return included .map((obj) => ({ obj, severity: getSeverity(obj, staleThreshold) })) .sort((a, b) => { const sd = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] if (sd !== 0) return sd const aOff = a.obj.checks?.ping?.status_counts?.offline ?? 0 const bOff = b.obj.checks?.ping?.status_counts?.offline ?? 0 return bOff - aOff }) }, [included, staleThreshold]) const severityCounts = useMemo(() => { const counts: Record = { critical: 0, degraded: 0, warn: 0, ok: 0 } for (const { severity } of rankedObjects) counts[severity]++ return counts }, [rankedObjects]) const filteredObjects = useMemo(() => { if (severityFilter === 'all') return rankedObjects return rankedObjects.filter((x) => x.severity === severityFilter) }, [rankedObjects, severityFilter]) // Группировка по городу для grid-режима const cityGroups = useMemo(() => { const map = new Map>() for (const item of filteredObjects) { const key = item.obj.city ?? UNGROUPED_CITY if (!map.has(key)) map.set(key, []) map.get(key)!.push(item) } return [...map.entries()].sort(([a], [b]) => { if (a === UNGROUPED_CITY) return 1 if (b === UNGROUPED_CITY) return -1 return a.localeCompare(b, 'ru') }) }, [filteredObjects]) const offlineTotal = useMemo( () => included.reduce((acc, obj) => acc + (obj.checks?.ping?.status_counts?.offline ?? 0), 0), [included] ) const unknownTotal = useMemo( () => included.reduce((acc, obj) => acc + (obj.checks?.ping?.status_counts?.unknown ?? 0), 0), [included] ) const onlineTotal = useMemo( () => included.reduce((acc, obj) => acc + (obj.checks?.ping?.status_counts?.online ?? 0), 0), [included] ) const diskWarnTotal = useMemo( () => included.reduce((acc, obj) => acc + (obj.checks?.disk_usage?.status_counts?.warn ?? 0), 0), [included] ) const diskFailedTotal = useMemo( () => included.reduce((acc, obj) => acc + (obj.checks?.disk_usage?.status_counts?.failed ?? 0), 0), [included] ) const stalePingObjects = useMemo( () => rankedObjects.filter((x) => (x.obj.checks?.ping?.max_age_sec ?? 0) > staleThreshold), [rankedObjects, staleThreshold] ) const avgHealth = useMemo(() => { const values = included.map((x) => x.health_score).filter((v): v is number => v != null) if (!values.length) return null return Math.round(values.reduce((a, b) => a + b, 0) / values.length) }, [included]) const monitoredTotal = onlineTotal + offlineTotal + unknownTotal const availabilityPct = toPct(onlineTotal, onlineTotal + offlineTotal) const coveragePct = toPct(summary?.included_count ?? totals.allowed_objects, totals.objects) const freshnessPct = toPct( Math.max(0, included.length - stalePingObjects.length), included.length ) const signalCount = offlineTotal + unknownTotal + diskWarnTotal + diskFailedTotal const lastCounts = monitorStatus?.last_counts as Record | null const systemSeverity: Severity = severityCounts.critical > 0 ? 'critical' : severityCounts.degraded > 0 ? 'degraded' : severityCounts.warn > 0 ? 'warn' : 'ok' return (
{/* Sticky статус-строка */}
{SEVERITY_LABEL[systemSeverity]} {(['critical', 'degraded', 'warn', 'ok'] as Severity[]).map((s) => ( setSeverityFilter(severityFilter === s ? 'all' : s)} > {SEVERITY_LABEL[s]}: {severityCounts[s]} ))} {monitorStatus?.last_success_at ? `обновлено ${dayjs(monitorStatus.last_success_at).format('HH:mm:ss')}` : 'нет данных'}
{/* Заголовок */} NOC Dashboard SLA, покрытие мониторингом, инциденты {/* Фильтры */} ({ value: c, label: c }))} value={client} onChange={setClient} /> ({ value: t.id, label: t.name }))} />