390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
import { ArrowLeftOutlined } from '@ant-design/icons'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { Alert, Button, Card, Col, 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 statusColor(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 healthColor(score: number | null | undefined): string {
|
|
if (score == null) return 'default'
|
|
if (score >= 85) return 'green'
|
|
if (score >= 65) return 'orange'
|
|
return 'red'
|
|
}
|
|
|
|
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 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>
|
|
<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>
|
|
<Row justify="space-between" style={{ marginTop: compact ? 4 : 6 }}>
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
{dayjs(points[0].bucket_start).format('DD.MM HH:mm')}
|
|
</Typography.Text>
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
{dayjs(points[points.length - 1].bucket_end).format('DD.MM HH:mm')}
|
|
</Typography.Text>
|
|
</Row>
|
|
{!compact ? (
|
|
<Space size={12} style={{ marginTop: 6 }} wrap>
|
|
<Space size={4}><span style={{ width: 10, height: 10, borderRadius: 2, background: '#52c41a', display: 'inline-block' }} /><Typography.Text type="secondary" style={{ fontSize: 12 }}>online</Typography.Text></Space>
|
|
<Space size={4}><span style={{ width: 10, height: 10, borderRadius: 2, background: '#ff4d4f', display: 'inline-block' }} /><Typography.Text type="secondary" style={{ fontSize: 12 }}>offline</Typography.Text></Space>
|
|
<Space size={4}><span style={{ width: 10, height: 10, borderRadius: 2, background: '#d9d9d9', display: 'inline-block' }} /><Typography.Text type="secondary" style={{ fontSize: 12 }}>íåò äàííûõ</Typography.Text></Space>
|
|
</Space>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 { 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
|
|
let warn = 0
|
|
const byRack = new Map<string, DashboardDeviceNOCItem[]>()
|
|
|
|
for (const row of rows) {
|
|
if (row.ping_status === 'online') online += 1
|
|
|
|
if (row.disk_status === 'warn' || row.disk_status === 'failed') {
|
|
warn += 1
|
|
}
|
|
|
|
const rackName = row.rack_name && row.rack_name.trim() ? row.rack_name : 'Áåç ñòîéêè'
|
|
if (!byRack.has(rackName)) byRack.set(rackName, [])
|
|
byRack.get(rackName)?.push(row)
|
|
}
|
|
|
|
const rackGroups = Array.from(byRack.entries())
|
|
.sort((a, b) => {
|
|
if (a[0] === 'Áåç ñòîéêè') return 1
|
|
if (b[0] === 'Áåç ñòîéêè') return -1
|
|
return a[0].localeCompare(b[0])
|
|
})
|
|
.map(([rack, rowsInRack]) => ({
|
|
rack,
|
|
rows: [...rowsInRack].sort((a, b) => {
|
|
const aProblem = a.has_problem ? 0 : 1
|
|
const bProblem = b.has_problem ? 0 : 1
|
|
if (aProblem !== bProblem) return aProblem - bProblem
|
|
if (a.ping_status !== b.ping_status) {
|
|
if (a.ping_status === 'offline') return -1
|
|
if (b.ping_status === 'offline') return 1
|
|
}
|
|
return a.ip.localeCompare(b.ip)
|
|
}),
|
|
}))
|
|
|
|
return { online, warn, rackGroups }
|
|
}, [devices])
|
|
|
|
const columns: ColumnsType<DashboardDeviceNOCItem> = [
|
|
{
|
|
title: 'IP / Hostname',
|
|
key: 'identity',
|
|
render: (_, row) => (
|
|
<Space direction="vertical" size={0}>
|
|
<code>{row.ip}</code>
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
{row.hostname ?? '—'}
|
|
</Typography.Text>
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: 'Êàòåãîðèÿ',
|
|
dataIndex: 'category',
|
|
key: 'category',
|
|
width: 130,
|
|
},
|
|
{
|
|
title: 'Ping',
|
|
key: 'ping',
|
|
width: 190,
|
|
render: (_, row) => (
|
|
<Space direction="vertical" size={0}>
|
|
<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` : ''}
|
|
</Typography.Text>
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: 'Uptime',
|
|
key: 'uptime',
|
|
width: 280,
|
|
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>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
title: 'Ñâåæåñòü',
|
|
key: 'freshness',
|
|
width: 110,
|
|
render: (_, row) => <Tag color={row.ping_fresh ? 'green' : 'orange'}>{formatAge(row.ping_age_sec)}</Tag>,
|
|
},
|
|
{
|
|
title: 'Disk',
|
|
key: 'disk',
|
|
width: 140,
|
|
render: (_, row) =>
|
|
row.disk_status ? (
|
|
<Tag color={statusColor(row.disk_status)}>
|
|
{row.disk_status}
|
|
{row.disk_max_pct != null ? ` (${row.disk_max_pct}%)` : ''}
|
|
</Tag>
|
|
) : (
|
|
'—'
|
|
),
|
|
},
|
|
{
|
|
title: 'Ïðîáëåìà',
|
|
key: 'problem',
|
|
width: 100,
|
|
render: (_, row) => (row.has_problem ? <Tag color="red">Äà</Tag> : <Tag color="green">Íåò</Tag>),
|
|
},
|
|
]
|
|
|
|
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>
|
|
</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={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">Disk warn/failed</Typography.Text>
|
|
<Typography.Title level={3} style={{ margin: 0, color: '#d46b08' }}>
|
|
{grouped.warn}
|
|
</Typography.Title>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Card
|
|
title="Óñòðîéñòâà îáúåêòà"
|
|
size="small"
|
|
style={{ borderRadius: 12 }}
|
|
extra={
|
|
<Select
|
|
value={window}
|
|
onChange={(v) => setWindow(v as DashboardAvailabilityWindow)}
|
|
options={[
|
|
{ value: '24h', label: '24÷' },
|
|
{ value: '3d', label: '3ä' },
|
|
{ value: '7d', label: '7ä' },
|
|
]}
|
|
style={{ width: 120 }}
|
|
/>
|
|
}
|
|
>
|
|
{!grouped.rackGroups.length ? (
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Óñòðîéñòâà íå íàéäåíû" />
|
|
) : (
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
{grouped.rackGroups.map((rackGroup) => (
|
|
<Card
|
|
key={rackGroup.rack}
|
|
size="small"
|
|
title={`${rackGroup.rack} (${rackGroup.rows.length})`}
|
|
style={{ borderRadius: 10 }}
|
|
>
|
|
<Table<DashboardDeviceNOCItem>
|
|
loading={devicesLoading}
|
|
dataSource={rackGroup.rows}
|
|
rowKey="device_id"
|
|
size="small"
|
|
pagination={false}
|
|
scroll={{ x: 1300 }}
|
|
columns={columns}
|
|
/>
|
|
</Card>
|
|
))}
|
|
</Space>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|