From 5cb48d4883cbb37049a6874342c841b9cfe41e40 Mon Sep 17 00:00:00 2001 From: dv Date: Tue, 5 May 2026 12:48:42 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20=D0=B2=D0=B8=D0=B4?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/dashboard.ts | 5 +- frontend/src/pages/Home.tsx | 371 ++++++++++++++------- frontend/src/pages/HomeObjectDetail.tsx | 417 +++++++++++++++++++----- 3 files changed, 579 insertions(+), 214 deletions(-) diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 9d31852..683b8c5 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -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, }) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 109dad1..fafe598 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -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) => ( - + - {row.city && {row.city}} - {row.client && {row.client}} + + {row.city ? {row.city} : null} + {row.client ? {row.client} : null} + ), }, { title: 'Причина', key: 'reason', - render: (_: unknown, row: any) => {reasonLabel(row.inclusion_reason)}, + render: (_: unknown, row: any) => ( + + {reasonLabel(row.inclusion_reason)} + + ), }, { 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 ( - - online {cnt.online ?? 0} / offline {cnt.offline ?? 0} / unknown {cnt.unknown ?? 0} - - age: {formatAge(ping?.max_age_sec)} - - - ) - }, + 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 {row.health_score} - }, + width: 90, + render: (_: unknown, row: any) => + row.health_score == null ? '-' : {row.health_score}, }, ] return ( -
+
- NOC Дешборд + Light NOC Dashboard + Оперативный обзор доступности и инцидентов @@ -153,117 +191,200 @@ export function HomePage() { - - - ({ value: c, label: c }))} - value={client} - onChange={setClient} - /> - - - ({ value: t.id, label: t.name }))} - /> - - - - - - - Статус: {monitorStatus?.status ?? '-'} - {monitorStatus?.last_success_at - ? ` · успешный запуск ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}` - : ''} - - - - - {monitorStatus?.last_error && ( - - )} - - - - - - - - - - - - - - - - - - + + + + ({ value: c, label: c }))} + value={client} + onChange={setClient} + /> + + + ({ value: t.id, label: t.name }))} + /> + + + + + + Состояние мониторинга +
+ + {monitorStatus?.status ?? '-'} + + + {monitorStatus?.last_success_at + ? `Последний успех: ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}` + : 'Нет успешных запусков'} + +
+ {monitorStatus?.last_error ? ( + + ) : null} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {pingTotals.all === 0 ? ( + + ) : ( + +
+ Online + +
+
+ Offline + +
+
+ Unknown + +
+
+ )} +
+ + + + + {topIncidents.length === 0 ? ( + + ) : ( + ( + navigate(`/home/objects/${item.object_id}${scopeToSearch(scope)}`)} + > + Открыть + , + ]} + > + + {item.object_name} + health {item.health_score ?? '-'} + + } + description={ + •} wrap> + {item.city ?? '—'} + {item.client ?? '—'} + {item.device_count} устройств + Ping age: {formatAge(item.checks?.ping?.max_age_sec)} + + } + /> + + )} + /> + )} + + + + - +
- +
diff --git a/frontend/src/pages/HomeObjectDetail.tsx b/frontend/src/pages/HomeObjectDetail.tsx index 40dd9ad..6af7856 100644 --- a/frontend/src/pages/HomeObjectDetail.tsx +++ b/frontend/src/pages/HomeObjectDetail.tsx @@ -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 ( - - + + {[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 ( - - {v}% + + + {v}% + ) })} - {coords && } + {polyline ? : null} ) } +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 Недостаточно интервалов для отображения + } + + return ( +
+
+ {bars.map((bar) => ( +
+ ))} +
+ + + {dayjs(startAt).format('DD.MM HH:mm')} + + + {dayjs(endAt).format('DD.MM HH:mm')} + + +
+ ) +} + +function DeviceExpandedRow({ + data, + isLoading, + startAt, + endAt, +}: { + data?: DashboardDeviceAvailability + isLoading: boolean + startAt?: string + endAt?: string +}) { + if (isLoading) { + return Загрузка истории доступности... + } + if (!data) { + return Недостаточно данных по устройству в выбранном окне + } + + 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 ( + + +
+ + Availability + + {data.availability_pct != null ? `${data.availability_pct}%` : '—'} + + Samples: {data.samples} + + + + + Последний статус +
+ {data.last_status ?? 'unknown'} +
+ + {data.last_checked_at ? dayjs(data.last_checked_at).format('DD.MM HH:mm:ss') : '—'} + +
+ + + + Последний ping + + {data.last_ping_ms != null ? `${data.last_ping_ms} ms` : '—'} + + Online: {data.online} • Offline: {data.offline} + + + + + + {data.series.length ? ( + + ) : ( + + )} + + + + {startAt && endAt ? ( + + ) : ( + Диапазон окна недоступен + )} + + + + {recentSwitches.length ? ( + + {recentSwitches.map((sw, idx) => ( + + {sw.status} {dayjs(sw.started_at).format('DD.MM HH:mm')} ({formatDuration(sw.duration_sec)}) + + ))} + + ) : ( + Переключений пока нет + )} + + + ) +} + 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('24h') + const [expandedKeys, setExpandedKeys] = useState([]) 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() + 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 ( -
+
@@ -107,55 +332,66 @@ export function HomeObjectDetailPage() { {object?.allowed ? В мониторинге : Вне мониторинга} - {object?.health_score != null && ( - = 80 ? 'green' : object.health_score >= 60 ? 'orange' : 'red'}> - Health {object.health_score} - - )} + health {object?.health_score ?? '-'} + окно {window} - {object && ( + {object ? ( {reasonLabel(object.inclusion_reason)} - )} + ) : null} - {availability?.insufficient_data && ( + {expandedAvailability?.insufficient_data ? ( - )} + ) : null} - - + + Устройств - {summary?.totals.devices ?? 0} - - - - - Проблемных - {summary?.totals.problem_devices ?? 0} - - - - - Доступность ({window}) - {availability?.overall_availability_pct != null ? `${availability.overall_availability_pct}%` : '—'} + {summary?.totals.devices ?? 0} + + + + + + Проблемных + + {summary?.totals.problem_devices ?? 0} + + + + + + Online + + {grouped.online} + + + + + + Offline + + {grouped.offline} } - style={{ marginBottom: 16 }} > - - - -
{ + 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) => ( + + ), + }} columns={[ { title: 'IP / Hostname', @@ -188,66 +440,55 @@ export function HomeObjectDetailPage() { render: (_: unknown, row: any) => ( {row.ip} - {row.hostname ?? '—'} + + {row.hostname ?? '—'} + ), }, - { title: 'Категория', dataIndex: 'category', key: 'category' }, + { title: 'Категория', dataIndex: 'category', key: 'category', width: 120 }, { title: 'Ping', key: 'ping', + width: 190, render: (_: unknown, row: any) => ( - - {row.ping_status} - + {row.ping_status} {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` : ''} ), }, + { + title: 'Freshness', + key: 'freshness', + width: 110, + render: (_: unknown, row: any) => ( + {formatAge(row.ping_age_sec)} + ), + }, { title: 'Disk', key: 'disk', + width: 130, render: (_: unknown, row: any) => row.disk_status ? ( - - {row.disk_status}{row.disk_max_pct != null ? ` (${row.disk_max_pct}%)` : ''} + + {row.disk_status} + {row.disk_max_pct != null ? ` (${row.disk_max_pct}%)` : ''} - ) : '—', + ) : ( + '—' + ), }, { title: 'Проблема', key: 'problem', - render: (_: unknown, row: any) => row.has_problem ? Да : Нет, - }, - ]} - /> - - - -
{v} }, - { 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 ? {row.last_status} : '—', + width: 100, + render: (_: unknown, row: any) => + row.has_problem ? Да : Нет, }, ]} />