utp_service/frontend/src/pages/HomeObjectDetail.tsx
2026-05-05 18:16:58 +03:00

431 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { 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>
)
}