648 lines
26 KiB
TypeScript
648 lines
26 KiB
TypeScript
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>
|
||
)
|
||
}
|