правка страницы вида
This commit is contained in:
parent
51ea278339
commit
5cb48d4883
3 changed files with 579 additions and 214 deletions
|
|
@ -220,7 +220,10 @@ export const useDashboardObjectAvailability = (
|
|||
params: { window, device_ids: deviceIds },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
enabled: Number.isFinite(objectId) && objectId > 0,
|
||||
enabled:
|
||||
Number.isFinite(objectId) &&
|
||||
objectId > 0 &&
|
||||
(deviceIds == null || deviceIds.length > 0),
|
||||
refetchInterval: 45_000,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
import { Alert, Button, Card, Col, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
List,
|
||||
Progress,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
|
@ -22,7 +38,7 @@ function reasonLabel(reason: string): string {
|
|||
inherit_default: 'Наследование по умолчанию',
|
||||
tag_policy_enabled: 'Включен политикой тегов',
|
||||
tag_policy_disabled: 'Исключен политикой тегов',
|
||||
scope_filtered: 'Вне текущего scope',
|
||||
scope_filtered: 'Вне текущего фильтра',
|
||||
}
|
||||
return map[reason] ?? reason
|
||||
}
|
||||
|
|
@ -37,6 +53,13 @@ function scopeToSearch(scope: DashboardScope): string {
|
|||
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'
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -80,68 +103,83 @@ export function HomePage() {
|
|||
|
||||
const totals = summary?.totals ?? { objects: 0, devices: 0, allowed_objects: 0, excluded_objects: 0 }
|
||||
|
||||
const columns = [
|
||||
const pingTotals = useMemo(() => {
|
||||
let online = 0
|
||||
let offline = 0
|
||||
let unknown = 0
|
||||
for (const obj of summary?.included_objects ?? []) {
|
||||
const cnt = obj.checks?.ping?.status_counts ?? {}
|
||||
online += cnt.online ?? 0
|
||||
offline += cnt.offline ?? 0
|
||||
unknown += cnt.unknown ?? 0
|
||||
}
|
||||
const all = online + offline + unknown
|
||||
return { online, offline, unknown, all }
|
||||
}, [summary])
|
||||
|
||||
const topIncidents = useMemo(() => {
|
||||
return [...(summary?.problem_objects ?? [])]
|
||||
.sort((a, b) => {
|
||||
const ah = a.health_score ?? 100
|
||||
const bh = b.health_score ?? 100
|
||||
if (ah !== bh) return ah - bh
|
||||
return (b.device_count ?? 0) - (a.device_count ?? 0)
|
||||
})
|
||||
.slice(0, 6)
|
||||
}, [summary])
|
||||
|
||||
const objectColumns = [
|
||||
{
|
||||
title: 'Объект',
|
||||
key: 'object',
|
||||
key: 'object_name',
|
||||
render: (_: unknown, row: any) => (
|
||||
<Space>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ padding: 0 }}
|
||||
style={{ padding: 0, textAlign: 'left' }}
|
||||
onClick={() => navigate(`/home/objects/${row.object_id}${scopeToSearch(scope)}`)}
|
||||
>
|
||||
{row.object_name}
|
||||
</Button>
|
||||
{row.city && <Tag>{row.city}</Tag>}
|
||||
{row.client && <Tag color="blue">{row.client}</Tag>}
|
||||
<Space size={6} wrap>
|
||||
{row.city ? <Tag>{row.city}</Tag> : null}
|
||||
{row.client ? <Tag color="blue">{row.client}</Tag> : null}
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Причина',
|
||||
key: 'reason',
|
||||
render: (_: unknown, row: any) => <Typography.Text>{reasonLabel(row.inclusion_reason)}</Typography.Text>,
|
||||
render: (_: unknown, row: any) => (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{reasonLabel(row.inclusion_reason)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Устройств',
|
||||
dataIndex: 'device_count',
|
||||
key: 'device_count',
|
||||
},
|
||||
{
|
||||
title: 'Ping',
|
||||
key: 'ping',
|
||||
render: (_: unknown, row: any) => {
|
||||
const ping = row.checks?.ping
|
||||
const cnt = ping?.status_counts ?? {}
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<span>online {cnt.online ?? 0} / offline {cnt.offline ?? 0} / unknown {cnt.unknown ?? 0}</span>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
age: {formatAge(ping?.max_age_sec)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: 'Health',
|
||||
key: 'health',
|
||||
render: (_: unknown, row: any) => {
|
||||
if (row.health_score == null) return '-'
|
||||
const color = row.health_score >= 80 ? 'green' : row.health_score >= 60 ? 'orange' : 'red'
|
||||
return <Tag color={color}>{row.health_score}</Tag>
|
||||
},
|
||||
width: 90,
|
||||
render: (_: unknown, row: any) =>
|
||||
row.health_score == null ? '-' : <Tag color={healthColor(row.health_score)}>{row.health_score}</Tag>,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ background: '#f4f6fb', margin: -12, padding: 12, borderRadius: 12 }}>
|
||||
<Row justify="space-between" align="middle" gutter={[8, 8]} style={{ marginBottom: 12 }}>
|
||||
<Col>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
NOC Дешборд
|
||||
Light NOC Dashboard
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">Оперативный обзор доступности и инцидентов</Typography.Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space wrap>
|
||||
|
|
@ -153,117 +191,200 @@ export function HomePage() {
|
|||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<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={6}>
|
||||
<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={6}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Теги"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
value={tagIds}
|
||||
onChange={setTagIds}
|
||||
options={(tags ?? []).map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Typography.Text type="secondary">
|
||||
Статус: {monitorStatus?.status ?? '-'}
|
||||
{monitorStatus?.last_success_at
|
||||
? ` · успешный запуск ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}`
|
||||
: ''}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{monitorStatus?.last_error && (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 12 }} message={monitorStatus.last_error} />
|
||||
)}
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="Объекты" value={totals.objects} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="В мониторинге" value={summary?.included_count ?? totals.allowed_objects} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="Вне мониторинга" value={summary?.excluded_count ?? totals.excluded_objects} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="Устройства" value={totals.devices} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="Проблемные объекты" style={{ marginBottom: 16 }}>
|
||||
<Table
|
||||
size="small"
|
||||
dataSource={summary?.problem_objects ?? []}
|
||||
rowKey="object_id"
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
loading={isLoading}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
<Card size="small" style={{ marginBottom: 12, borderRadius: 12 }}>
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<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={6}>
|
||||
<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={6}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Теги"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
value={tagIds}
|
||||
onChange={setTagIds}
|
||||
options={(tags ?? []).map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card size="small" style={{ borderRadius: 12, height: '100%' }}>
|
||||
<Typography.Text type="secondary">Состояние мониторинга</Typography.Text>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<Tag color={monitorStatus?.status === 'running' ? 'processing' : monitorStatus?.status === 'error' ? 'red' : 'blue'}>
|
||||
{monitorStatus?.status ?? '-'}
|
||||
</Tag>
|
||||
<Typography.Text style={{ marginLeft: 6 }}>
|
||||
{monitorStatus?.last_success_at
|
||||
? `Последний успех: ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}`
|
||||
: 'Нет успешных запусков'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{monitorStatus?.last_error ? (
|
||||
<Alert type="error" showIcon style={{ marginTop: 10 }} message={monitorStatus.last_error} />
|
||||
) : null}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={16}>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={12} md={6}>
|
||||
<Card size="small" style={{ borderRadius: 12 }} loading={isLoading}>
|
||||
<Statistic title="Объекты" value={totals.objects} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card size="small" style={{ borderRadius: 12 }} loading={isLoading}>
|
||||
<Statistic title="В мониторинге" value={summary?.included_count ?? totals.allowed_objects} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card size="small" style={{ borderRadius: 12 }} loading={isLoading}>
|
||||
<Statistic title="Вне мониторинга" value={summary?.excluded_count ?? totals.excluded_objects} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card size="small" style={{ borderRadius: 12 }} loading={isLoading}>
|
||||
<Statistic title="Устройства" value={totals.devices} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
size="small"
|
||||
title="Ping status (в мониторинге)"
|
||||
style={{ borderRadius: 12, height: '100%' }}
|
||||
loading={isLoading}
|
||||
>
|
||||
{pingTotals.all === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Нет данных" />
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={8}>
|
||||
<div>
|
||||
<Typography.Text type="secondary">Online</Typography.Text>
|
||||
<Progress percent={Math.round((pingTotals.online / pingTotals.all) * 100)} status="success" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">Offline</Typography.Text>
|
||||
<Progress percent={Math.round((pingTotals.offline / pingTotals.all) * 100)} strokeColor="#cf1322" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">Unknown</Typography.Text>
|
||||
<Progress percent={Math.round((pingTotals.unknown / pingTotals.all) * 100)} strokeColor="#8c8c8c" />
|
||||
</div>
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
size="small"
|
||||
title="Приоритетные инциденты"
|
||||
style={{ borderRadius: 12, height: '100%' }}
|
||||
loading={isLoading}
|
||||
>
|
||||
{topIncidents.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Критичных объектов нет" />
|
||||
) : (
|
||||
<List
|
||||
dataSource={topIncidents}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button
|
||||
key="open"
|
||||
type="link"
|
||||
onClick={() => navigate(`/home/objects/${item.object_id}${scopeToSearch(scope)}`)}
|
||||
>
|
||||
Открыть
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space wrap size={8}>
|
||||
<span>{item.object_name}</span>
|
||||
<Tag color={healthColor(item.health_score)}>health {item.health_score ?? '-'}</Tag>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space split={<span>•</span>} wrap>
|
||||
<span>{item.city ?? '—'}</span>
|
||||
<span>{item.client ?? '—'}</span>
|
||||
<span>{item.device_count} устройств</span>
|
||||
<span>Ping age: {formatAge(item.checks?.ping?.max_age_sec)}</span>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} xl={12}>
|
||||
<Card title="В мониторинге" style={{ marginBottom: 16 }}>
|
||||
<Card title={`В мониторинге (${summary?.included_count ?? 0})`} size="small" style={{ borderRadius: 12 }}>
|
||||
<Table
|
||||
size="small"
|
||||
dataSource={summary?.included_objects ?? []}
|
||||
rowKey="object_id"
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 8 }}
|
||||
columns={objectColumns}
|
||||
loading={isLoading}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{ pageSize: 6 }}
|
||||
scroll={{ x: 800 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} xl={12}>
|
||||
<Card title="Вне мониторинга" style={{ marginBottom: 16 }}>
|
||||
<Card title={`Вне мониторинга (${summary?.excluded_count ?? 0})`} size="small" style={{ borderRadius: 12 }}>
|
||||
<Table
|
||||
size="small"
|
||||
dataSource={summary?.excluded_objects ?? []}
|
||||
rowKey="object_id"
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 8 }}
|
||||
columns={objectColumns}
|
||||
loading={isLoading}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{ pageSize: 6 }}
|
||||
scroll={{ x: 800 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Card, Col, Row, Select, Space, Table, Tag, Typography } from 'antd'
|
||||
import { Alert, Button, Card, Col, Empty, Row, Select, Space, Table, Tag, Typography } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
|
@ -8,8 +8,10 @@ import {
|
|||
useDashboardObjectAvailability,
|
||||
useDashboardObjectDevices,
|
||||
useDashboardObjectSummary,
|
||||
type DashboardAvailabilityInterval,
|
||||
type DashboardAvailabilityPoint,
|
||||
type DashboardAvailabilityWindow,
|
||||
type DashboardDeviceAvailability,
|
||||
type DashboardScope,
|
||||
} from '../api/dashboard'
|
||||
import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE'
|
||||
|
|
@ -21,7 +23,7 @@ function reasonLabel(reason: string): string {
|
|||
inherit_default: 'Наследование по умолчанию',
|
||||
tag_policy_enabled: 'Включен политикой тегов',
|
||||
tag_policy_disabled: 'Исключен политикой тегов',
|
||||
scope_filtered: 'Вне текущего scope',
|
||||
scope_filtered: 'Вне текущего фильтра',
|
||||
}
|
||||
return map[reason] ?? reason
|
||||
}
|
||||
|
|
@ -29,45 +31,244 @@ function reasonLabel(reason: string): string {
|
|||
function parseScope(params: URLSearchParams): DashboardScope {
|
||||
const city = params.get('city') ?? undefined
|
||||
const client = params.get('client') ?? undefined
|
||||
const object_ids = params.getAll('object_ids').map(Number).filter((v) => Number.isFinite(v))
|
||||
const tag_ids = params.getAll('tag_ids').map(Number).filter((v) => Number.isFinite(v))
|
||||
const object_ids = params
|
||||
.getAll('object_ids')
|
||||
.map(Number)
|
||||
.filter((v) => Number.isFinite(v))
|
||||
const tag_ids = params
|
||||
.getAll('tag_ids')
|
||||
.map(Number)
|
||||
.filter((v) => Number.isFinite(v))
|
||||
return { city, client, object_ids, tag_ids }
|
||||
}
|
||||
|
||||
function AvailabilityChart({ points }: { points: DashboardAvailabilityPoint[] }) {
|
||||
const viewW = 800
|
||||
const viewH = 180
|
||||
const pad = 20
|
||||
function statusColor(status: string | null | undefined): string {
|
||||
if (status === 'online') return 'green'
|
||||
if (status === 'offline') return 'red'
|
||||
if (status === 'warn') return 'orange'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const coords = useMemo(() => {
|
||||
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 formatDuration(sec: number): string {
|
||||
if (sec < 60) return `${sec}с`
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}м ${sec % 60}с`
|
||||
return `${Math.floor(sec / 3600)}ч ${Math.floor((sec % 3600) / 60)}м`
|
||||
}
|
||||
|
||||
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 AvailabilityMiniChart({ points }: { points: DashboardAvailabilityPoint[] }) {
|
||||
const viewW = 860
|
||||
const viewH = 150
|
||||
const padX = 14
|
||||
const padY = 12
|
||||
|
||||
const polyline = useMemo(() => {
|
||||
if (!points.length) return ''
|
||||
return points
|
||||
.map((p, idx) => {
|
||||
const x = pad + (idx / Math.max(1, points.length - 1)) * (viewW - pad * 2)
|
||||
const x = padX + (idx / Math.max(1, points.length - 1)) * (viewW - padX * 2)
|
||||
const val = p.availability_pct ?? 0
|
||||
const y = pad + ((100 - val) / 100) * (viewH - pad * 2)
|
||||
const y = padY + ((100 - val) / 100) * (viewH - padY * 2)
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(' ')
|
||||
}, [points])
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${viewW} ${viewH}`} style={{ width: '100%', height: 220 }}>
|
||||
<rect x={0} y={0} width={viewW} height={viewH} fill="#0f1720" rx={12} />
|
||||
<svg viewBox={`0 0 ${viewW} ${viewH}`} style={{ width: '100%', height: 160, display: 'block' }}>
|
||||
<rect x={0} y={0} width={viewW} height={viewH} rx={10} fill="#f8fafc" />
|
||||
{[0, 25, 50, 75, 100].map((v) => {
|
||||
const y = pad + ((100 - v) / 100) * (viewH - pad * 2)
|
||||
const y = padY + ((100 - v) / 100) * (viewH - padY * 2)
|
||||
return (
|
||||
<g key={v}>
|
||||
<line x1={pad} y1={y} x2={viewW - pad} y2={y} stroke="#2b394b" strokeWidth={1} />
|
||||
<text x={6} y={y + 4} fill="#98aac1" fontSize={10}>{v}%</text>
|
||||
<line x1={padX} y1={y} x2={viewW - padX} y2={y} stroke="#d9e2ef" strokeWidth={1} />
|
||||
<text x={2} y={y + 4} fill="#8da1b8" fontSize={9}>
|
||||
{v}%
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{coords && <polyline fill="none" stroke="#37c47a" strokeWidth={2.5} points={coords} />}
|
||||
{polyline ? <polyline points={polyline} fill="none" stroke="#1677ff" strokeWidth={2.4} /> : null}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusTimeline({
|
||||
intervals,
|
||||
startAt,
|
||||
endAt,
|
||||
}: {
|
||||
intervals: DashboardAvailabilityInterval[]
|
||||
startAt: string
|
||||
endAt: string
|
||||
}) {
|
||||
const start = dayjs(startAt).valueOf()
|
||||
const end = dayjs(endAt).valueOf()
|
||||
const range = Math.max(1, end - start)
|
||||
|
||||
const bars = intervals
|
||||
.filter((i) => i.status === 'online' || i.status === 'offline')
|
||||
.map((i, idx) => {
|
||||
const from = Math.max(dayjs(i.started_at).valueOf(), start)
|
||||
const to = Math.min(dayjs(i.ended_at).valueOf(), end)
|
||||
const left = ((from - start) / range) * 100
|
||||
const width = Math.max(0.8, ((to - from) / range) * 100)
|
||||
return {
|
||||
key: `${i.status}-${i.started_at}-${idx}`,
|
||||
left,
|
||||
width,
|
||||
status: i.status,
|
||||
duration: i.duration_sec,
|
||||
}
|
||||
})
|
||||
|
||||
if (!bars.length) {
|
||||
return <Typography.Text type="secondary">Недостаточно интервалов для отображения</Typography.Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: 18,
|
||||
borderRadius: 8,
|
||||
background: '#eef2f8',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #dbe4f0',
|
||||
}}
|
||||
>
|
||||
{bars.map((bar) => (
|
||||
<div
|
||||
key={bar.key}
|
||||
title={`${bar.status} • ${formatDuration(bar.duration)}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${bar.left}%`,
|
||||
width: `${bar.width}%`,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
background: bar.status === 'online' ? '#52c41a' : '#f5222d',
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Row justify="space-between" style={{ marginTop: 4 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{dayjs(startAt).format('DD.MM HH:mm')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{dayjs(endAt).format('DD.MM HH:mm')}
|
||||
</Typography.Text>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceExpandedRow({
|
||||
data,
|
||||
isLoading,
|
||||
startAt,
|
||||
endAt,
|
||||
}: {
|
||||
data?: DashboardDeviceAvailability
|
||||
isLoading: boolean
|
||||
startAt?: string
|
||||
endAt?: string
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return <Typography.Text type="secondary">Загрузка истории доступности...</Typography.Text>
|
||||
}
|
||||
if (!data) {
|
||||
return <Typography.Text type="secondary">Недостаточно данных по устройству в выбранном окне</Typography.Text>
|
||||
}
|
||||
|
||||
const recentSwitches = [...(data.intervals ?? [])]
|
||||
.filter((i) => i.status === 'online' || i.status === 'offline')
|
||||
.sort((a, b) => dayjs(b.started_at).valueOf() - dayjs(a.started_at).valueOf())
|
||||
.slice(0, 5)
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card size="small" bordered>
|
||||
<Typography.Text type="secondary">Availability</Typography.Text>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{data.availability_pct != null ? `${data.availability_pct}%` : '—'}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">Samples: {data.samples}</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card size="small" bordered>
|
||||
<Typography.Text type="secondary">Последний статус</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Tag color={statusColor(data.last_status)}>{data.last_status ?? 'unknown'}</Tag>
|
||||
</div>
|
||||
<Typography.Text type="secondary">
|
||||
{data.last_checked_at ? dayjs(data.last_checked_at).format('DD.MM HH:mm:ss') : '—'}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card size="small" bordered>
|
||||
<Typography.Text type="secondary">Последний ping</Typography.Text>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{data.last_ping_ms != null ? `${data.last_ping_ms} ms` : '—'}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">Online: {data.online} • Offline: {data.offline}</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card size="small" title="Линия доступности" bordered>
|
||||
{data.series.length ? (
|
||||
<AvailabilityMiniChart points={data.series} />
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Нет точек для графика" />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="Таймлайн online/offline" bordered>
|
||||
{startAt && endAt ? (
|
||||
<StatusTimeline intervals={data.intervals} startAt={startAt} endAt={endAt} />
|
||||
) : (
|
||||
<Typography.Text type="secondary">Диапазон окна недоступен</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="Последние переключения" bordered>
|
||||
{recentSwitches.length ? (
|
||||
<Space wrap>
|
||||
{recentSwitches.map((sw, idx) => (
|
||||
<Tag key={`${sw.started_at}-${idx}`} color={sw.status === 'online' ? 'green' : 'red'}>
|
||||
{sw.status} {dayjs(sw.started_at).format('DD.MM HH:mm')} ({formatDuration(sw.duration_sec)})
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">Переключений пока нет</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeObjectDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
|
@ -76,10 +277,15 @@ export function HomeObjectDetailPage() {
|
|||
const [search] = useSearchParams()
|
||||
const scope = useMemo(() => parseScope(search), [search])
|
||||
const [window, setWindow] = useState<DashboardAvailabilityWindow>('24h')
|
||||
const [expandedKeys, setExpandedKeys] = useState<number[]>([])
|
||||
|
||||
const { data: summary, isLoading: summaryLoading } = useDashboardObjectSummary(objectId, scope)
|
||||
const { data: devices, isLoading: devicesLoading } = useDashboardObjectDevices(objectId, scope)
|
||||
const { data: availability, isLoading: availLoading } = useDashboardObjectAvailability(objectId, window)
|
||||
const {
|
||||
data: expandedAvailability,
|
||||
isLoading: expandedAvailLoading,
|
||||
isFetching: expandedAvailFetching,
|
||||
} = useDashboardObjectAvailability(objectId, window, expandedKeys.length ? expandedKeys : undefined)
|
||||
|
||||
useDashboardMonitorSSE({
|
||||
onUpdate: () => {
|
||||
|
|
@ -91,8 +297,27 @@ export function HomeObjectDetailPage() {
|
|||
|
||||
const object = summary?.object
|
||||
|
||||
const availabilityByDevice = useMemo(() => {
|
||||
const map = new Map<number, DashboardDeviceAvailability>()
|
||||
for (const row of expandedAvailability?.devices ?? []) {
|
||||
map.set(row.device_id, row)
|
||||
}
|
||||
return map
|
||||
}, [expandedAvailability])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const rows = devices?.devices ?? []
|
||||
let online = 0
|
||||
let offline = 0
|
||||
for (const row of rows) {
|
||||
if (row.ping_status === 'online') online += 1
|
||||
else if (row.ping_status === 'offline') offline += 1
|
||||
}
|
||||
return { online, offline }
|
||||
}, [devices])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ background: '#f4f6fb', margin: -12, padding: 12, borderRadius: 12 }}>
|
||||
<Row justify="space-between" align="middle" gutter={[8, 8]} style={{ marginBottom: 12 }}>
|
||||
<Col>
|
||||
<Space wrap>
|
||||
|
|
@ -107,55 +332,66 @@ export function HomeObjectDetailPage() {
|
|||
<Col>
|
||||
<Space wrap>
|
||||
{object?.allowed ? <Tag color="green">В мониторинге</Tag> : <Tag color="red">Вне мониторинга</Tag>}
|
||||
{object?.health_score != null && (
|
||||
<Tag color={object.health_score >= 80 ? 'green' : object.health_score >= 60 ? 'orange' : 'red'}>
|
||||
Health {object.health_score}
|
||||
</Tag>
|
||||
)}
|
||||
<Tag color={healthColor(object?.health_score)}>health {object?.health_score ?? '-'}</Tag>
|
||||
<Tag color="blue">окно {window}</Tag>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{object && (
|
||||
{object ? (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
{reasonLabel(object.inclusion_reason)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{availability?.insufficient_data && (
|
||||
{expandedAvailability?.insufficient_data ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="Недостаточно данных для графика доступности в выбранном окне"
|
||||
message="По части устройств данных пока недостаточно. Это нормально для новых объектов."
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card loading={summaryLoading}>
|
||||
<Col xs={12} md={6}>
|
||||
<Card size="small" loading={summaryLoading} style={{ borderRadius: 12 }}>
|
||||
<Typography.Text type="secondary">Устройств</Typography.Text>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>{summary?.totals.devices ?? 0}</Typography.Title>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card loading={summaryLoading}>
|
||||
<Typography.Text type="secondary">Проблемных</Typography.Text>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>{summary?.totals.problem_devices ?? 0}</Typography.Title>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card loading={availLoading}>
|
||||
<Typography.Text type="secondary">Доступность ({window})</Typography.Text>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
{availability?.overall_availability_pct != null ? `${availability.overall_availability_pct}%` : '—'}
|
||||
{summary?.totals.devices ?? 0}
|
||||
</Typography.Title>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card size="small" loading={summaryLoading} style={{ borderRadius: 12 }}>
|
||||
<Typography.Text type="secondary">Проблемных</Typography.Text>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
{summary?.totals.problem_devices ?? 0}
|
||||
</Typography.Title>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card size="small" loading={devicesLoading} style={{ borderRadius: 12 }}>
|
||||
<Typography.Text type="secondary">Online</Typography.Text>
|
||||
<Typography.Title level={3} style={{ margin: 0, color: '#389e0d' }}>
|
||||
{grouped.online}
|
||||
</Typography.Title>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card size="small" loading={devicesLoading} style={{ borderRadius: 12 }}>
|
||||
<Typography.Text type="secondary">Offline</Typography.Text>
|
||||
<Typography.Title level={3} style={{ margin: 0, color: '#cf1322' }}>
|
||||
{grouped.offline}
|
||||
</Typography.Title>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="График доступности по Ping"
|
||||
title="Устройства объекта"
|
||||
size="small"
|
||||
style={{ borderRadius: 12 }}
|
||||
extra={
|
||||
<Select
|
||||
value={window}
|
||||
|
|
@ -165,22 +401,38 @@ export function HomeObjectDetailPage() {
|
|||
{ value: '3d', label: '3д' },
|
||||
{ value: '7d', label: '7д' },
|
||||
]}
|
||||
style={{ width: 100 }}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<AvailabilityChart points={availability?.overall_series ?? []} />
|
||||
</Card>
|
||||
|
||||
<Card title="Статус устройств" style={{ marginBottom: 16 }}>
|
||||
<Table
|
||||
loading={devicesLoading}
|
||||
dataSource={devices?.devices ?? []}
|
||||
rowKey="device_id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 20 }}
|
||||
scroll={{ x: 1000 }}
|
||||
scroll={{ x: 1100 }}
|
||||
expandable={{
|
||||
expandedRowKeys: expandedKeys,
|
||||
onExpand: (expanded, row: any) => {
|
||||
const key = Number(row.device_id)
|
||||
setExpandedKeys((prev) => {
|
||||
if (expanded) {
|
||||
if (prev.includes(key)) return prev
|
||||
return [...prev, key]
|
||||
}
|
||||
return prev.filter((x) => x !== key)
|
||||
})
|
||||
},
|
||||
expandedRowRender: (row: any) => (
|
||||
<DeviceExpandedRow
|
||||
data={availabilityByDevice.get(Number(row.device_id))}
|
||||
isLoading={expandedAvailLoading || expandedAvailFetching}
|
||||
startAt={expandedAvailability?.start_at}
|
||||
endAt={expandedAvailability?.end_at}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: 'IP / Hostname',
|
||||
|
|
@ -188,66 +440,55 @@ export function HomeObjectDetailPage() {
|
|||
render: (_: unknown, row: any) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<code>{row.ip}</code>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.hostname ?? '—'}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.hostname ?? '—'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: 'Категория', dataIndex: 'category', key: 'category' },
|
||||
{ title: 'Категория', dataIndex: 'category', key: 'category', width: 120 },
|
||||
{
|
||||
title: 'Ping',
|
||||
key: 'ping',
|
||||
width: 190,
|
||||
render: (_: unknown, row: any) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color={row.ping_status === 'online' ? 'green' : row.ping_status === 'offline' ? 'red' : 'default'}>
|
||||
{row.ping_status}
|
||||
</Tag>
|
||||
<Tag color={statusColor(row.ping_status)}>{row.ping_status}</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.ping_checked_at ? dayjs(row.ping_checked_at).format('DD.MM HH:mm:ss') : '—'}
|
||||
{row.last_ping_ms != null ? ` · ${row.last_ping_ms}ms` : ''}
|
||||
{row.last_ping_ms != null ? ` • ${row.last_ping_ms}ms` : ''}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Freshness',
|
||||
key: 'freshness',
|
||||
width: 110,
|
||||
render: (_: unknown, row: any) => (
|
||||
<Tag color={row.ping_fresh ? 'green' : 'orange'}>{formatAge(row.ping_age_sec)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Disk',
|
||||
key: 'disk',
|
||||
width: 130,
|
||||
render: (_: unknown, row: any) =>
|
||||
row.disk_status ? (
|
||||
<Tag color={row.disk_status === 'ok' ? 'green' : row.disk_status === 'warn' ? 'orange' : 'red'}>
|
||||
{row.disk_status}{row.disk_max_pct != null ? ` (${row.disk_max_pct}%)` : ''}
|
||||
<Tag color={statusColor(row.disk_status)}>
|
||||
{row.disk_status}
|
||||
{row.disk_max_pct != null ? ` (${row.disk_max_pct}%)` : ''}
|
||||
</Tag>
|
||||
) : '—',
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Проблема',
|
||||
key: 'problem',
|
||||
render: (_: unknown, row: any) => row.has_problem ? <Tag color="red">Да</Tag> : <Tag color="green">Нет</Tag>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Per-device доступность (выборка)">
|
||||
<Table
|
||||
loading={availLoading}
|
||||
dataSource={availability?.devices ?? []}
|
||||
rowKey="device_id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 10 }}
|
||||
scroll={{ x: 900 }}
|
||||
columns={[
|
||||
{ title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> },
|
||||
{ title: 'Hostname', dataIndex: 'hostname', key: 'hostname', render: (v: string | null) => v ?? '—' },
|
||||
{
|
||||
title: 'Availability',
|
||||
key: 'availability_pct',
|
||||
render: (_: unknown, row: any) => row.availability_pct != null ? `${row.availability_pct}%` : '—',
|
||||
},
|
||||
{ title: 'Samples', dataIndex: 'samples', key: 'samples' },
|
||||
{
|
||||
title: 'Последний статус',
|
||||
key: 'last_status',
|
||||
render: (_: unknown, row: any) => row.last_status ? <Tag>{row.last_status}</Tag> : '—',
|
||||
width: 100,
|
||||
render: (_: unknown, row: any) =>
|
||||
row.has_problem ? <Tag color="red">Да</Tag> : <Tag color="green">Нет</Tag>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue