правка страницы вида

This commit is contained in:
dv 2026-05-05 12:48:42 +03:00
parent 51ea278339
commit 5cb48d4883
3 changed files with 579 additions and 214 deletions

View file

@ -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,
})

View file

@ -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,7 +191,8 @@ export function HomePage() {
</Col>
</Row>
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
<Card size="small" style={{ marginBottom: 12, borderRadius: 12 }}>
<Row gutter={[8, 8]}>
<Col xs={24} sm={12} lg={6}>
<Select
placeholder="Город"
@ -197,73 +236,155 @@ export function HomePage() {
/>
</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>
<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>

View file

@ -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>,
},
]}
/>