utp_service/frontend/src/pages/Home.tsx

648 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Severity, number> = { 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<string | undefined>()
const [client, setClient] = useState<string | undefined>()
const [objectIds, setObjectIds] = useState<number[]>([])
const [tagIds, setTagIds] = useState<number[]>([])
const [severityFilter, setSeverityFilter] = useState<Severity | 'all'>('all')
const [viewMode, setViewMode] = useState<ViewMode>(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<Severity, number> = { 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<string, Array<{ obj: DashboardObjectSummary; severity: Severity }>>()
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<string, number> | null
const systemSeverity: Severity =
severityCounts.critical > 0 ? 'critical' :
severityCounts.degraded > 0 ? 'degraded' :
severityCounts.warn > 0 ? 'warn' : 'ok'
return (
<div style={{ background: '#f5f7fb', margin: -12, padding: 12, borderRadius: 12 }}>
{/* Sticky статус-строка */}
<div
style={{
position: 'sticky',
top: 0,
zIndex: 10,
background: SEVERITY_COLOR[systemSeverity],
borderRadius: 8,
padding: '6px 16px',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 24,
flexWrap: 'wrap',
}}
>
<Typography.Text strong style={{ color: '#fff', fontSize: 13, letterSpacing: 1 }}>
{SEVERITY_LABEL[systemSeverity]}
</Typography.Text>
{(['critical', 'degraded', 'warn', 'ok'] as Severity[]).map((s) => (
<span
key={s}
style={{
color: '#fff',
fontSize: 12,
opacity: severityCounts[s] === 0 ? 0.5 : 1,
cursor: 'pointer',
fontWeight: severityFilter === s ? 700 : 400,
textDecoration: severityFilter === s ? 'underline' : undefined,
}}
onClick={() => setSeverityFilter(severityFilter === s ? 'all' : s)}
>
{SEVERITY_LABEL[s]}: <strong>{severityCounts[s]}</strong>
</span>
))}
<span style={{ color: '#fff', fontSize: 12, marginLeft: 'auto', opacity: 0.85 }}>
{monitorStatus?.last_success_at
? `обновлено ${dayjs(monitorStatus.last_success_at).format('HH:mm:ss')}`
: 'нет данных'}
</span>
</div>
{/* Заголовок */}
<Row justify="space-between" align="middle" gutter={[8, 8]} style={{ marginBottom: 12 }}>
<Col>
<Typography.Title level={4} style={{ margin: 0 }}>NOC Dashboard</Typography.Title>
<Typography.Text type="secondary">SLA, покрытие мониторингом, инциденты</Typography.Text>
</Col>
<Col>
<Space wrap>
<Button onClick={() => navigate(`/home/settings${scopeToSearch(scope)}`)}>
Настройки мониторинга
</Button>
<Button type="primary" onClick={handleRunNow} loading={runNow.isPending}>
Запустить сейчас
</Button>
</Space>
</Col>
</Row>
{/* Фильтры */}
<Card size="small" style={{ marginBottom: 12, borderRadius: 12 }}>
<Row gutter={[8, 8]} align="middle">
<Col xs={24} sm={12} lg={5}>
<Select
placeholder="Город"
allowClear
style={{ width: '100%' }}
options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))}
value={city}
onChange={setCity}
/>
</Col>
<Col xs={24} sm={12} lg={5}>
<Select
placeholder="Клиент"
allowClear
style={{ width: '100%' }}
options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))}
value={client}
onChange={setClient}
/>
</Col>
<Col xs={24} sm={12} lg={6}>
<Select
mode="multiple"
placeholder="Объекты"
allowClear
style={{ width: '100%' }}
value={objectIds}
onChange={setObjectIds}
options={(objects ?? []).map((o) => ({ value: o.id, label: o.name }))}
/>
</Col>
<Col xs={24} sm={12} lg={5}>
<Select
mode="multiple"
placeholder="Теги"
allowClear
style={{ width: '100%' }}
value={tagIds}
onChange={setTagIds}
options={(tags ?? []).map((t) => ({ value: t.id, label: t.name }))}
/>
</Col>
<Col xs={24} sm={24} lg={3}>
<Select
style={{ width: '100%' }}
value={severityFilter}
onChange={setSeverityFilter}
options={[
{ value: 'all', label: 'Все' },
{ value: 'critical', label: 'Critical' },
{ value: 'degraded', label: 'Degraded' },
{ value: 'warn', label: 'Warn' },
{ value: 'ok', label: 'OK' },
]}
/>
</Col>
</Row>
</Card>
{/* Счётчики */}
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
<Col xs={12} md={8} lg={4}>
<Card size="small" loading={isLoading}><Statistic title="Объектов в скоупе" value={totals.objects} /></Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card size="small" loading={isLoading}><Statistic title="Устройств" value={totals.devices} /></Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card size="small" loading={isLoading}>
<Statistic
title="Проблемных объектов"
value={severityCounts.critical + severityCounts.degraded}
valueStyle={{ color: severityCounts.critical > 0 ? '#cf1322' : undefined }}
/>
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card size="small" loading={isLoading}><Statistic title="Сигналов проблем" value={signalCount} /></Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card size="small" loading={isLoading}><Statistic title="Просроченных объектов" value={stalePingObjects.length} /></Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card size="small" loading={isLoading}><Statistic title="Средний health" value={avgHealth ?? '—'} /></Card>
</Col>
</Row>
{monitorStatus?.last_error && (
<Alert type="error" showIcon style={{ marginBottom: 12 }} message={monitorStatus.last_error} />
)}
<Row gutter={[12, 12]}>
{/* Основная панель объектов */}
<Col xs={24} xl={16}>
<Card
size="small"
style={{ borderRadius: 12 }}
title={
<Space>
<span>Объекты</span>
{severityFilter !== 'all' && (
<Tag color={SEVERITY_TAG_COLOR[severityFilter as Severity]}>
{SEVERITY_LABEL[severityFilter as Severity]}
</Tag>
)}
<Tag>{filteredObjects.length}</Tag>
</Space>
}
extra={
<Space size={4}>
<Button
size="small"
icon={<AppstoreOutlined />}
type={viewMode === 'grid' ? 'primary' : 'default'}
onClick={() => handleViewMode('grid')}
/>
<Button
size="small"
icon={<BarsOutlined />}
type={viewMode === 'list' ? 'primary' : 'default'}
onClick={() => handleViewMode('list')}
/>
</Space>
}
>
{filteredObjects.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Нет объектов в выбранном фильтре" />
) : viewMode === 'grid' ? (
// Grid-режим: плитки сгруппированные по городу
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{cityGroups.map(([cityKey, items]) => (
<div key={cityKey}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 13 }}>
{cityKey === UNGROUPED_CITY ? 'Без города' : cityKey}
</Typography.Text>
<Tag style={{ margin: 0 }}>{items.length}</Tag>
{/* Мини-счётчики severity для города */}
{(['critical', 'degraded', 'warn'] as Severity[]).map((s) => {
const cnt = items.filter((x) => x.severity === s).length
return cnt > 0 ? (
<Tag key={s} color={SEVERITY_TAG_COLOR[s]} style={{ margin: 0, fontSize: 11 }}>
{cnt}
</Tag>
) : null
})}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: 8,
}}
>
{items.map(({ obj, severity }) => (
<ObjectStatusCard
key={obj.object_id}
obj={obj}
severity={severity}
staleThreshold={staleThreshold}
scope={scope}
/>
))}
</div>
</div>
))}
</div>
) : (
// List-режим: строки с цветной полосой
<List
dataSource={filteredObjects}
renderItem={({ obj, severity }) => {
const ping = obj.checks?.ping
const disk = obj.checks?.disk_usage?.status_counts ?? {}
const offline = ping?.status_counts?.offline ?? 0
const unknown = ping?.status_counts?.unknown ?? 0
const online = ping?.status_counts?.online ?? 0
const diskWarn = disk.warn ?? 0
const diskFailed = disk.failed ?? 0
const age = ping?.max_age_sec
return (
<List.Item
style={{
borderLeft: `3px solid ${SEVERITY_COLOR[severity]}`,
paddingLeft: 10,
background:
severity === 'critical' ? 'rgba(255,77,79,0.04)' :
severity === 'degraded' ? 'rgba(250,140,22,0.04)' : undefined,
}}
actions={[
<Button
key="open"
type="link"
onClick={() => navigate(`/home/objects/${obj.object_id}${scopeToSearch(scope)}`)}
>
Открыть
</Button>,
]}
>
<List.Item.Meta
title={
<Space wrap size={6}>
<Typography.Text strong>{obj.object_name}</Typography.Text>
<Tag>{obj.city ?? '—'}</Tag>
<Tag color="blue">{obj.client ?? '—'}</Tag>
<Tag color={SEVERITY_TAG_COLOR[severity]}>{SEVERITY_LABEL[severity]}</Tag>
<Tag color={healthColor(obj.health_score)}>health {obj.health_score ?? '—'}</Tag>
</Space>
}
description={
<Space split={<span>·</span>} wrap size={4} style={{ fontSize: 12 }}>
<span style={{ color: online > 0 ? '#52c41a' : undefined }}>online: {online}</span>
{offline > 0 && <span style={{ color: '#ff4d4f' }}>offline: {offline}</span>}
{unknown > 0 && <span style={{ color: '#fa8c16' }}>unknown: {unknown}</span>}
{(diskWarn > 0 || diskFailed > 0) && (
<span style={{ color: diskFailed > 0 ? '#ff4d4f' : '#fa8c16' }}>
disk: {diskWarn}w/{diskFailed}f
</span>
)}
<span>устройств: {obj.device_count}</span>
{age != null && (
<span style={{ color: (age ?? 0) > staleThreshold ? '#fa8c16' : undefined }}>
age: {formatAge(age)}
</span>
)}
</Space>
}
/>
</List.Item>
)
}}
/>
)}
</Card>
</Col>
{/* Правая колонка */}
<Col xs={24} xl={8}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Card title="SLA и покрытие" size="small" style={{ borderRadius: 12 }}>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<div>
<Row justify="space-between">
<Typography.Text>Доступность (online)</Typography.Text>
<Typography.Text strong>{availabilityPct != null ? `${availabilityPct}%` : '—'}</Typography.Text>
</Row>
<Progress
percent={availabilityPct ?? 0}
size="small"
showInfo={false}
strokeColor={availabilityPct != null && availabilityPct < 95 ? '#ff4d4f' : '#52c41a'}
/>
</div>
<div>
<Row justify="space-between">
<Typography.Text>Покрытие мониторингом</Typography.Text>
<Typography.Text strong>{coveragePct != null ? `${coveragePct}%` : '—'}</Typography.Text>
</Row>
<Progress percent={coveragePct ?? 0} size="small" showInfo={false} strokeColor="#1677ff" />
</div>
<div>
<Row justify="space-between">
<Typography.Text>Свежесть данных</Typography.Text>
<Typography.Text strong>{freshnessPct != null ? `${freshnessPct}%` : '—'}</Typography.Text>
</Row>
<Progress
percent={freshnessPct ?? 0}
size="small"
showInfo={false}
strokeColor={freshnessPct != null && freshnessPct < 90 ? '#faad14' : '#52c41a'}
/>
</div>
<Space wrap>
<Tag color="green">online: {onlineTotal}</Tag>
<Tag color="red">offline: {offlineTotal}</Tag>
<Tag color="orange">unknown: {unknownTotal}</Tag>
<Tag>по ping: {monitoredTotal}</Tag>
</Space>
</Space>
</Card>
<Card title="Состояние цикла мониторинга" size="small" style={{ borderRadius: 12 }}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Row justify="space-between">
<Typography.Text>Статус</Typography.Text>
<Tag color={runStatusColor(monitorStatus?.status)}>{monitorStatus?.status ?? '-'}</Tag>
</Row>
<Row justify="space-between">
<Typography.Text>Объекты в мониторинге</Typography.Text>
<Typography.Text strong>{summary?.included_count ?? totals.allowed_objects}</Typography.Text>
</Row>
<Row justify="space-between">
<Typography.Text>Последний успех</Typography.Text>
<Typography.Text strong>
{monitorStatus?.last_success_at
? dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')
: '—'}
</Typography.Text>
</Row>
{lastCounts && (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>checks: {lastCounts.checks_executed ?? 0}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>failed: {lastCounts.failed ?? 0}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>checked devices: {lastCounts.devices_checked ?? 0}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>skipped not due: {lastCounts.skipped_not_due ?? 0}</Typography.Text>
</Space>
)}
</Space>
</Card>
<Card title="Breakdown по severity" size="small" style={{ borderRadius: 12 }}>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
{(['critical', 'degraded', 'warn', 'ok'] as Severity[]).map((s) => (
<div key={s}>
<Row justify="space-between" style={{ marginBottom: 2 }}>
<Typography.Text
style={{
cursor: 'pointer',
color: severityFilter === s ? SEVERITY_COLOR[s] : undefined,
fontWeight: severityFilter === s ? 600 : undefined,
}}
onClick={() => setSeverityFilter(severityFilter === s ? 'all' : s)}
>
{SEVERITY_LABEL[s]}
</Typography.Text>
<Typography.Text strong>{severityCounts[s]}</Typography.Text>
</Row>
<Progress
percent={
included.length > 0
? Math.round((severityCounts[s] / included.length) * 100)
: 0
}
size="small"
showInfo={false}
strokeColor={SEVERITY_COLOR[s]}
/>
</div>
))}
</Space>
</Card>
</Space>
</Col>
</Row>
</div>
)
}