431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||
import { useQueryClient } from '@tanstack/react-query'
|
||
import { Alert, Button, Card, Col, Collapse, Empty, Row, Select, Space, Table, Tag, Typography } from 'antd'
|
||
import type { ColumnsType } from 'antd/es/table'
|
||
import dayjs from 'dayjs'
|
||
import { useMemo, useState } from 'react'
|
||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||
import {
|
||
useDashboardObjectAvailability,
|
||
useDashboardObjectDevices,
|
||
useDashboardObjectSummary,
|
||
type DashboardAvailabilityPoint,
|
||
type DashboardAvailabilityWindow,
|
||
type DashboardDeviceNOCItem,
|
||
type DashboardScope,
|
||
} from '../api/dashboard'
|
||
import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE'
|
||
|
||
function reasonLabel(reason: string): string {
|
||
const map: Record<string, string> = {
|
||
object_include_policy: 'Включен политикой объекта',
|
||
object_exclude_policy: 'Исключен политикой объекта',
|
||
inherit_default: 'Наследование по умолчанию',
|
||
tag_policy_enabled: 'Включен политикой тегов',
|
||
tag_policy_disabled: 'Исключен политикой тегов',
|
||
scope_filtered: 'Вне текущего фильтра',
|
||
}
|
||
return map[reason] ?? reason
|
||
}
|
||
|
||
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))
|
||
return { city, client, object_ids, tag_ids }
|
||
}
|
||
|
||
function pingStatusColor(status: string | null | undefined): string {
|
||
if (status === 'online') return 'green'
|
||
if (status === 'offline') return 'red'
|
||
if (status === 'warn' || status === 'failed') return 'orange'
|
||
return 'default'
|
||
}
|
||
|
||
function diskStatusColor(status: string | null | undefined): string {
|
||
if (status === 'ok') return 'green'
|
||
if (status === 'warn') return 'orange'
|
||
if (status === 'failed') return 'red'
|
||
return 'default'
|
||
}
|
||
|
||
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 uptimeColor(point: DashboardAvailabilityPoint): string {
|
||
if (point.samples === 0) return '#d9d9d9'
|
||
if (point.offline > 0) return '#ff4d4f'
|
||
return '#52c41a'
|
||
}
|
||
|
||
function UptimeBars({ points, compact = false }: { points: DashboardAvailabilityPoint[]; compact?: boolean }) {
|
||
if (!points.length) {
|
||
return <Typography.Text type="secondary">Нет точек в выбранном окне</Typography.Text>
|
||
}
|
||
return (
|
||
<div style={{ display: 'flex', gap: compact ? 1 : 2, alignItems: 'center', height: compact ? 14 : 20 }}>
|
||
{points.map((point, idx) => (
|
||
<div
|
||
key={`${point.bucket_start}-${idx}`}
|
||
title={`${dayjs(point.bucket_start).format('DD.MM HH:mm')} • ${point.availability_pct ?? '—'}%`}
|
||
style={{
|
||
flex: 1,
|
||
minWidth: compact ? 3 : 4,
|
||
height: compact ? 9 : 14,
|
||
borderRadius: compact ? 2 : 3,
|
||
background: uptimeColor(point),
|
||
border: '1px solid rgba(0,0,0,0.08)',
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type PingFilter = 'all' | 'offline' | 'unknown' | 'online'
|
||
|
||
const PING_FILTER_OPTIONS: { value: PingFilter; label: string; color?: string }[] = [
|
||
{ value: 'all', label: 'Все' },
|
||
{ value: 'offline', label: 'Offline', color: '#ff4d4f' },
|
||
{ value: 'unknown', label: 'Unknown', color: '#fa8c16' },
|
||
{ value: 'online', label: 'Online', color: '#52c41a' },
|
||
]
|
||
|
||
export function HomeObjectDetailPage() {
|
||
const navigate = useNavigate()
|
||
const qc = useQueryClient()
|
||
const { id } = useParams<{ id: string }>()
|
||
const objectId = Number(id)
|
||
const [search] = useSearchParams()
|
||
const scope = useMemo(() => parseScope(search), [search])
|
||
const [window, setWindow] = useState<DashboardAvailabilityWindow>('24h')
|
||
const [pingFilter, setPingFilter] = useState<PingFilter>('all')
|
||
|
||
const { data: summary, isLoading: summaryLoading } = useDashboardObjectSummary(objectId, scope)
|
||
const { data: devices, isLoading: devicesLoading } = useDashboardObjectDevices(objectId, scope)
|
||
const { data: availabilityData, isLoading: availabilityLoading } = useDashboardObjectAvailability(objectId, window, undefined, true)
|
||
|
||
useDashboardMonitorSSE({
|
||
onUpdate: () => {
|
||
qc.invalidateQueries({ queryKey: ['dashboard-object-summary', objectId] })
|
||
qc.invalidateQueries({ queryKey: ['dashboard-object-devices', objectId] })
|
||
qc.invalidateQueries({ queryKey: ['dashboard-object-availability', objectId] })
|
||
},
|
||
})
|
||
|
||
const object = summary?.object
|
||
|
||
const availabilityByDevice = useMemo(() => {
|
||
const map = new Map<number, { points: DashboardAvailabilityPoint[]; availability: number | null }>()
|
||
for (const row of availabilityData?.devices ?? []) {
|
||
map.set(row.device_id, { points: row.series ?? [], availability: row.availability_pct })
|
||
}
|
||
return map
|
||
}, [availabilityData])
|
||
|
||
const grouped = useMemo(() => {
|
||
const rows = devices?.devices ?? []
|
||
let online = 0, offline = 0, unknown = 0
|
||
const byRack = new Map<string, DashboardDeviceNOCItem[]>()
|
||
|
||
for (const row of rows) {
|
||
if (row.ping_status === 'online') online++
|
||
if (row.ping_status === 'offline') offline++
|
||
if (row.ping_status === 'unknown') unknown++
|
||
|
||
const rackName = row.rack_name?.trim() || 'Без стойки'
|
||
if (!byRack.has(rackName)) byRack.set(rackName, [])
|
||
byRack.get(rackName)!.push(row)
|
||
}
|
||
|
||
const rackGroups = Array.from(byRack.entries())
|
||
.sort(([a], [b]) => {
|
||
if (a === 'Без стойки') return -1
|
||
if (b === 'Без стойки') return 1
|
||
return a.localeCompare(b)
|
||
})
|
||
.map(([rack, rowsInRack]) => ({
|
||
rack,
|
||
rows: [...rowsInRack].sort((a, b) => {
|
||
const order = { offline: 0, unknown: 1, online: 2 }
|
||
const ao = order[a.ping_status as keyof typeof order] ?? 3
|
||
const bo = order[b.ping_status as keyof typeof order] ?? 3
|
||
if (ao !== bo) return ao - bo
|
||
return a.ip.localeCompare(b.ip)
|
||
}),
|
||
}))
|
||
|
||
return { online, offline, unknown, rackGroups }
|
||
}, [devices])
|
||
|
||
// Применяем фильтр по ping статусу к каждой стойке
|
||
const filteredRackGroups = useMemo(() => {
|
||
if (pingFilter === 'all') return grouped.rackGroups
|
||
return grouped.rackGroups
|
||
.map((rg) => ({ ...rg, rows: rg.rows.filter((r) => r.ping_status === pingFilter) }))
|
||
.filter((rg) => rg.rows.length > 0)
|
||
}, [grouped.rackGroups, pingFilter])
|
||
|
||
const columns: ColumnsType<DashboardDeviceNOCItem> = [
|
||
{
|
||
title: 'IP / Hostname',
|
||
key: 'identity',
|
||
width: 190,
|
||
render: (_, row) => (
|
||
<Space direction="vertical" size={0} style={{ maxWidth: 170 }}>
|
||
<code>{row.ip}</code>
|
||
<Typography.Text type="secondary" ellipsis={{ tooltip: row.hostname ?? '—' }} style={{ fontSize: 12, maxWidth: 170 }}>
|
||
{row.hostname ?? '—'}
|
||
</Typography.Text>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: 'Категория',
|
||
dataIndex: 'category',
|
||
key: 'category',
|
||
width: 110,
|
||
ellipsis: true,
|
||
},
|
||
{
|
||
title: 'Роль',
|
||
key: 'role',
|
||
width: 80,
|
||
render: (_, row) => row.role ?? '—',
|
||
},
|
||
{
|
||
title: 'Ping',
|
||
key: 'ping',
|
||
width: 130,
|
||
render: (_, row) => (
|
||
<Space direction="vertical" size={0}>
|
||
<Tag color={pingStatusColor(row.ping_status)}>{row.ping_status}</Tag>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
{row.last_ping_ms != null ? `${row.last_ping_ms}ms` : '—'}
|
||
</Typography.Text>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: 'Disk',
|
||
key: 'disk',
|
||
width: 90,
|
||
render: (_, row) => {
|
||
if (!row.disk_status) return <Typography.Text type="secondary">—</Typography.Text>
|
||
return (
|
||
<Space direction="vertical" size={0}>
|
||
<Tag color={diskStatusColor(row.disk_status)}>{row.disk_status}</Tag>
|
||
{row.disk_max_pct != null && (
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.disk_max_pct}%</Typography.Text>
|
||
)}
|
||
</Space>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: 'Uptime',
|
||
key: 'uptime',
|
||
width: 200,
|
||
render: (_, row) => {
|
||
const availability = availabilityByDevice.get(Number(row.device_id))
|
||
if (availabilityLoading) return <Typography.Text type="secondary">Загрузка...</Typography.Text>
|
||
if (!availability?.points?.length) return <Typography.Text type="secondary">Нет данных</Typography.Text>
|
||
return (
|
||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||
<UptimeBars points={availability.points} compact />
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
{availability.availability != null ? `${availability.availability}%` : '—'}
|
||
</Typography.Text>
|
||
</Space>
|
||
)
|
||
},
|
||
},
|
||
]
|
||
|
||
return (
|
||
<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>
|
||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/home')}>
|
||
Назад
|
||
</Button>
|
||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||
{object?.object_name ?? `Объект #${objectId}`}
|
||
</Typography.Title>
|
||
</Space>
|
||
</Col>
|
||
<Col>
|
||
<Space wrap>
|
||
{object?.allowed ? <Tag color="green">В мониторинге</Tag> : <Tag color="red">Вне мониторинга</Tag>}
|
||
<Tag color={healthColor(object?.health_score)}>health {object?.health_score ?? '-'}</Tag>
|
||
<Tag color="blue">окно {window}</Tag>
|
||
<Button
|
||
size="small"
|
||
onClick={() => navigate(`/inventory/objects/${objectId}`)}
|
||
>
|
||
Инвентарь
|
||
</Button>
|
||
</Space>
|
||
</Col>
|
||
</Row>
|
||
|
||
{object ? (
|
||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||
{reasonLabel(object.inclusion_reason)}
|
||
</Typography.Text>
|
||
) : null}
|
||
|
||
{availabilityData?.insufficient_data ? (
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
style={{ marginBottom: 12 }}
|
||
message="По части устройств данных пока недостаточно. Для новых объектов это нормально."
|
||
/>
|
||
) : null}
|
||
|
||
{/* Счётчики */}
|
||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||
<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={12} md={6}>
|
||
<Card
|
||
size="small"
|
||
loading={devicesLoading}
|
||
style={{ borderRadius: 12, cursor: 'pointer', outline: pingFilter === 'unknown' ? '2px solid #fa8c16' : undefined }}
|
||
onClick={() => setPingFilter(pingFilter === 'unknown' ? 'all' : 'unknown')}
|
||
>
|
||
<Typography.Text type="secondary">Unknown</Typography.Text>
|
||
<Typography.Title level={3} style={{ margin: 0, color: grouped.unknown > 0 ? '#fa8c16' : undefined }}>
|
||
{grouped.unknown}
|
||
</Typography.Title>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={12} md={6}>
|
||
<Card
|
||
size="small"
|
||
loading={devicesLoading}
|
||
style={{ borderRadius: 12, cursor: 'pointer', outline: pingFilter === 'offline' ? '2px solid #cf1322' : undefined }}
|
||
onClick={() => setPingFilter(pingFilter === 'offline' ? 'all' : 'offline')}
|
||
>
|
||
<Typography.Text type="secondary">Offline</Typography.Text>
|
||
<Typography.Title level={3} style={{ margin: 0, color: '#cf1322' }}>{grouped.offline}</Typography.Title>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={12} md={6}>
|
||
<Card
|
||
size="small"
|
||
loading={devicesLoading}
|
||
style={{ borderRadius: 12, cursor: 'pointer', outline: pingFilter === 'online' ? '2px solid #389e0d' : undefined }}
|
||
onClick={() => setPingFilter(pingFilter === 'online' ? 'all' : 'online')}
|
||
>
|
||
<Typography.Text type="secondary">Online</Typography.Text>
|
||
<Typography.Title level={3} style={{ margin: 0, color: '#389e0d' }}>{grouped.online}</Typography.Title>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Card
|
||
title={
|
||
<Row justify="space-between" align="middle" style={{ width: '100%' }}>
|
||
<Space>
|
||
<span>Устройства объекта</span>
|
||
{pingFilter !== 'all' && (
|
||
<Tag
|
||
color={PING_FILTER_OPTIONS.find((o) => o.value === pingFilter)?.color ? undefined : undefined}
|
||
style={{ background: PING_FILTER_OPTIONS.find((o) => o.value === pingFilter)?.color, color: '#fff', border: 'none' }}
|
||
closable
|
||
onClose={() => setPingFilter('all')}
|
||
>
|
||
{PING_FILTER_OPTIONS.find((o) => o.value === pingFilter)?.label}
|
||
</Tag>
|
||
)}
|
||
</Space>
|
||
</Row>
|
||
}
|
||
size="small"
|
||
style={{ borderRadius: 12 }}
|
||
extra={
|
||
<Space>
|
||
<Space size={4}>
|
||
{PING_FILTER_OPTIONS.map((opt) => (
|
||
<Button
|
||
key={opt.value}
|
||
size="small"
|
||
type={pingFilter === opt.value ? 'primary' : 'default'}
|
||
style={
|
||
pingFilter === opt.value && opt.color
|
||
? { background: opt.color, borderColor: opt.color }
|
||
: undefined
|
||
}
|
||
onClick={() => setPingFilter(opt.value)}
|
||
>
|
||
{opt.label}
|
||
</Button>
|
||
))}
|
||
</Space>
|
||
<Select
|
||
value={window}
|
||
onChange={(v) => setWindow(v as DashboardAvailabilityWindow)}
|
||
options={[
|
||
{ value: '24h', label: '24ч' },
|
||
{ value: '3d', label: '3д' },
|
||
{ value: '7d', label: '7д' },
|
||
]}
|
||
style={{ width: 80 }}
|
||
/>
|
||
</Space>
|
||
}
|
||
>
|
||
{!filteredRackGroups.length ? (
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description={pingFilter === 'all' ? 'Устройства не найдены' : `Нет устройств со статусом "${pingFilter}"`}
|
||
/>
|
||
) : (
|
||
<Collapse
|
||
defaultActiveKey={filteredRackGroups.map((rg) => rg.rack)}
|
||
items={filteredRackGroups.map((rackGroup) => {
|
||
const offlineCount = rackGroup.rows.filter((r) => r.ping_status === 'offline').length
|
||
const unknownCount = rackGroup.rows.filter((r) => r.ping_status === 'unknown').length
|
||
return {
|
||
key: rackGroup.rack,
|
||
label: (
|
||
<Space>
|
||
<span>{rackGroup.rack}</span>
|
||
<Tag>{rackGroup.rows.length}</Tag>
|
||
{offlineCount > 0 && <Tag color="red">offline: {offlineCount}</Tag>}
|
||
{unknownCount > 0 && <Tag color="orange">unknown: {unknownCount}</Tag>}
|
||
</Space>
|
||
),
|
||
children: (
|
||
<Table<DashboardDeviceNOCItem>
|
||
loading={devicesLoading}
|
||
dataSource={rackGroup.rows}
|
||
rowKey="device_id"
|
||
size="small"
|
||
pagination={false}
|
||
columns={columns}
|
||
rowClassName={(row) =>
|
||
row.ping_status === 'offline' ? 'row-offline' : row.ping_status === 'unknown' ? 'row-unknown' : ''
|
||
}
|
||
/>
|
||
),
|
||
}
|
||
})}
|
||
/>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|