From 7460a7cbb992b58a4d7ce1d5243c9b40ffe5d604 Mon Sep 17 00:00:00 2001 From: dv Date: Tue, 5 May 2026 18:16:58 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=BF=D1=83=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/ObjectStatusCard.tsx | 223 +++++++ frontend/src/pages/Home.tsx | 595 +++++++++++++------ frontend/src/pages/HomeObjectDetail.tsx | 293 +++++---- 3 files changed, 811 insertions(+), 300 deletions(-) create mode 100644 frontend/src/components/ObjectStatusCard.tsx diff --git a/frontend/src/components/ObjectStatusCard.tsx b/frontend/src/components/ObjectStatusCard.tsx new file mode 100644 index 0000000..9bb0a64 --- /dev/null +++ b/frontend/src/components/ObjectStatusCard.tsx @@ -0,0 +1,223 @@ +import { Space, Tag, Typography } from 'antd' +import { useNavigate } from 'react-router-dom' +import type { DashboardObjectSummary, DashboardScope } from '../api/dashboard' + +export type Severity = 'critical' | 'degraded' | 'warn' | 'ok' + +export const SEVERITY_COLOR: Record = { + critical: '#ff4d4f', + degraded: '#fa8c16', + warn: '#fadb14', + ok: '#52c41a', +} + +export const SEVERITY_LABEL: Record = { + critical: 'CRITICAL', + degraded: 'DEGRADED', + warn: 'WARN', + ok: 'OK', +} + +export const SEVERITY_TAG_COLOR: Record = { + critical: 'red', + degraded: 'orange', + warn: 'gold', + ok: 'green', +} + +const SEVERITY_BG: Record = { + critical: 'rgba(255,77,79,0.06)', + degraded: 'rgba(250,140,22,0.06)', + warn: 'rgba(250,219,20,0.06)', + ok: '#fff', +} + +export function getSeverity(obj: DashboardObjectSummary, staleThreshold: number): Severity { + const ping = obj.checks?.ping?.status_counts ?? {} + const disk = obj.checks?.disk_usage?.status_counts ?? {} + const offline = ping.offline ?? 0 + const unknown = ping.unknown ?? 0 + const total = (ping.online ?? 0) + offline + unknown + const diskFailed = disk.failed ?? 0 + const diskWarn = disk.warn ?? 0 + const age = obj.checks?.ping?.max_age_sec ?? 0 + + if (offline > 0) return 'critical' + if (diskFailed > 0 || (total > 0 && unknown / total > 0.2)) return 'degraded' + if (diskWarn > 0 || age > staleThreshold) return 'warn' + return 'ok' +} + +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)}м` +} + +// Мини uptime bar из status_counts — без исторических данных, просто пропорция +function MiniStatusBar({ online, offline, unknown }: { online: number; offline: number; unknown: number }) { + const total = online + offline + unknown + if (total === 0) return
+ + const onlinePct = (online / total) * 100 + const offlinePct = (offline / total) * 100 + const unknownPct = (unknown / total) * 100 + + return ( +
+ {online > 0 && ( +
+ )} + {unknown > 0 && ( +
+ )} + {offline > 0 && ( +
+ )} +
+ ) +} + +function scopeToSearch(scope: DashboardScope): string { + const params = new URLSearchParams() + if (scope.city) params.set('city', scope.city) + if (scope.client) params.set('client', scope.client) + for (const id of scope.object_ids ?? []) params.append('object_ids', String(id)) + for (const id of scope.tag_ids ?? []) params.append('tag_ids', String(id)) + const raw = params.toString() + return raw ? `?${raw}` : '' +} + +interface Props { + obj: DashboardObjectSummary + severity: Severity + staleThreshold: number + scope: DashboardScope +} + +export function ObjectStatusCard({ obj, severity, staleThreshold, scope }: Props) { + const navigate = useNavigate() + + const ping = obj.checks?.ping?.status_counts ?? {} + const disk = obj.checks?.disk_usage?.status_counts ?? {} + const online = ping.online ?? 0 + const offline = ping.offline ?? 0 + const unknown = ping.unknown ?? 0 + const diskWarn = disk.warn ?? 0 + const diskFailed = disk.failed ?? 0 + const age = obj.checks?.ping?.max_age_sec + const isStale = (age ?? 0) > staleThreshold + + return ( +
navigate(`/home/objects/${obj.object_id}${scopeToSearch(scope)}`)} + style={{ + background: SEVERITY_BG[severity], + border: `1px solid ${SEVERITY_COLOR[severity]}`, + borderLeft: `4px solid ${SEVERITY_COLOR[severity]}`, + borderRadius: 8, + padding: '10px 12px', + cursor: 'pointer', + transition: 'box-shadow 0.15s', + display: 'flex', + flexDirection: 'column', + gap: 6, + minHeight: 110, + }} + onMouseEnter={(e) => { + ;(e.currentTarget as HTMLDivElement).style.boxShadow = `0 2px 8px ${SEVERITY_COLOR[severity]}44` + }} + onMouseLeave={(e) => { + ;(e.currentTarget as HTMLDivElement).style.boxShadow = 'none' + }} + > + {/* Заголовок */} +
+ + {obj.object_name} + + + {SEVERITY_LABEL[severity]} + +
+ + {/* Мета */} +
+ {obj.city && ( + {obj.city} + )} + {obj.client && ( + · {obj.client} + )} +
+ + {/* Статус-бар */} + + + {/* Счётчики */} +
+ {offline > 0 && ( + + offline: {offline} + + )} + {unknown > 0 && ( + + unknown: {unknown} + + )} + {online > 0 && offline === 0 && unknown === 0 && ( + + online: {online} + + )} + {(diskWarn > 0 || diskFailed > 0) && ( + 0 ? '#ff4d4f' : '#fa8c16' }}> + disk: {diskWarn}w/{diskFailed}f + + )} +
+ + {/* Нижняя строка */} +
+ + + {obj.device_count} уст. + + {obj.health_score != null && ( + = 85 ? 'green' : obj.health_score >= 65 ? 'orange' : 'red'} + style={{ margin: 0, fontSize: 10, padding: '0 4px' }} + > + h{obj.health_score} + + )} + + {age != null && ( + + {formatAge(age)} + + )} +
+
+ ) +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 2699063..30ec9f8 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,4 +1,5 @@ -import { useQueryClient } from '@tanstack/react-query' +import { AppstoreOutlined, BarsOutlined } from '@ant-design/icons' +import { useQueryClient } from '@tanstack/react-query' import { Alert, Button, @@ -19,9 +20,25 @@ import dayjs from 'dayjs' import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useObjects, useObjectsMeta } from '../api/objects' -import { useDashboardHomeSummary, useDashboardMonitorStatus, useDashboardRunNow, type DashboardScope } from '../api/dashboard' +import { + useDashboardHomeSummary, + useDashboardMonitorStatus, + useDashboardRunNow, + type DashboardScope, + type DashboardObjectSummary, +} from '../api/dashboard' import { useTags } from '../api/tags' import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE' +import { + ObjectStatusCard, + getSeverity, + SEVERITY_COLOR, + SEVERITY_LABEL, + SEVERITY_TAG_COLOR, + type Severity, +} from '../components/ObjectStatusCard' + +const SEVERITY_ORDER: Record = { critical: 0, degraded: 1, warn: 2, ok: 3 } function formatAge(seconds: number | null | undefined): string { if (seconds == null) return '—' @@ -59,6 +76,18 @@ function toPct(numerator: number, denominator: number): number | null { return Math.round((numerator / denominator) * 1000) / 10 } +type ViewMode = 'grid' | 'list' + +function getStoredViewMode(): ViewMode { + try { + const v = localStorage.getItem('noc-view-mode') + if (v === 'grid' || v === 'list') return v + } catch {} + return 'grid' +} + +const UNGROUPED_CITY = '__none__' + export function HomePage() { const qc = useQueryClient() const navigate = useNavigate() @@ -71,8 +100,13 @@ export function HomePage() { const [client, setClient] = useState() const [objectIds, setObjectIds] = useState([]) const [tagIds, setTagIds] = useState([]) + const [severityFilter, setSeverityFilter] = useState('all') + const [viewMode, setViewMode] = useState(getStoredViewMode) - const scope = useMemo(() => ({ city, client, object_ids: objectIds, tag_ids: tagIds }), [city, client, objectIds, tagIds]) + const scope = useMemo( + () => ({ city, client, object_ids: objectIds, tag_ids: tagIds }), + [city, client, objectIds, tagIds] + ) const { data: summary, isLoading } = useDashboardHomeSummary(scope) const { data: monitorStatus } = useDashboardMonitorStatus() @@ -97,12 +131,57 @@ export function HomePage() { } } + const handleViewMode = (mode: ViewMode) => { + setViewMode(mode) + try { localStorage.setItem('noc-view-mode', mode) } catch {} + } + const totals = summary?.totals ?? { objects: 0, devices: 0, allowed_objects: 0, excluded_objects: 0 } const included = summary?.included_objects ?? [] - const pingInterval = useMemo(() => { - return summary?.checks_meta?.find((c) => c.check_key === 'ping')?.interval_sec_default ?? 300 - }, [summary]) + const pingInterval = useMemo( + () => summary?.checks_meta?.find((c) => c.check_key === 'ping')?.interval_sec_default ?? 300, + [summary] + ) + const staleThreshold = pingInterval * 3 + + const rankedObjects = useMemo(() => { + return included + .map((obj) => ({ obj, severity: getSeverity(obj, staleThreshold) })) + .sort((a, b) => { + const sd = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] + if (sd !== 0) return sd + const aOff = a.obj.checks?.ping?.status_counts?.offline ?? 0 + const bOff = b.obj.checks?.ping?.status_counts?.offline ?? 0 + return bOff - aOff + }) + }, [included, staleThreshold]) + + const severityCounts = useMemo(() => { + const counts: Record = { critical: 0, degraded: 0, warn: 0, ok: 0 } + for (const { severity } of rankedObjects) counts[severity]++ + return counts + }, [rankedObjects]) + + const filteredObjects = useMemo(() => { + if (severityFilter === 'all') return rankedObjects + return rankedObjects.filter((x) => x.severity === severityFilter) + }, [rankedObjects, severityFilter]) + + // Группировка по городу для grid-режима + const cityGroups = useMemo(() => { + const map = new Map>() + for (const item of filteredObjects) { + const key = item.obj.city ?? UNGROUPED_CITY + if (!map.has(key)) map.set(key, []) + map.get(key)!.push(item) + } + return [...map.entries()].sort(([a], [b]) => { + if (a === UNGROUPED_CITY) return 1 + if (b === UNGROUPED_CITY) return -1 + return a.localeCompare(b, 'ru') + }) + }, [filteredObjects]) const offlineTotal = useMemo( () => included.reduce((acc, obj) => acc + (obj.checks?.ping?.status_counts?.offline ?? 0), 0), @@ -112,6 +191,10 @@ export function HomePage() { () => included.reduce((acc, obj) => acc + (obj.checks?.ping?.status_counts?.unknown ?? 0), 0), [included] ) + const onlineTotal = useMemo( + () => included.reduce((acc, obj) => acc + (obj.checks?.ping?.status_counts?.online ?? 0), 0), + [included] + ) const diskWarnTotal = useMemo( () => included.reduce((acc, obj) => acc + (obj.checks?.disk_usage?.status_counts?.warn ?? 0), 0), [included] @@ -120,94 +203,87 @@ export function HomePage() { () => included.reduce((acc, obj) => acc + (obj.checks?.disk_usage?.status_counts?.failed ?? 0), 0), [included] ) - const onlineTotal = useMemo( - () => included.reduce((acc, obj) => acc + (obj.checks?.ping?.status_counts?.online ?? 0), 0), - [included] + const stalePingObjects = useMemo( + () => rankedObjects.filter((x) => (x.obj.checks?.ping?.max_age_sec ?? 0) > staleThreshold), + [rankedObjects, staleThreshold] ) - - const staleThreshold = pingInterval * 3 - - const stalePingObjects = useMemo(() => { - return included - .map((obj) => { - const pingAge = obj.checks?.ping?.max_age_sec ?? 0 - const offline = obj.checks?.ping?.status_counts?.offline ?? 0 - const unknown = obj.checks?.ping?.status_counts?.unknown ?? 0 - return { obj, pingAge, offline, unknown } - }) - .filter((x) => x.pingAge > staleThreshold) - .sort((a, b) => b.pingAge - a.pingAge) - .slice(0, 10) - }, [included, staleThreshold]) - const avgHealth = useMemo(() => { const values = included.map((x) => x.health_score).filter((v): v is number => v != null) if (!values.length) return null return Math.round(values.reduce((a, b) => a + b, 0) / values.length) }, [included]) - const incidentObjects = useMemo(() => { - return [...(summary?.problem_objects ?? [])].sort((a, b) => { - const aOffline = a.checks?.ping?.status_counts?.offline ?? 0 - const bOffline = b.checks?.ping?.status_counts?.offline ?? 0 - if (aOffline !== bOffline) return bOffline - aOffline - - const aDiskFail = a.checks?.disk_usage?.status_counts?.failed ?? 0 - const bDiskFail = b.checks?.disk_usage?.status_counts?.failed ?? 0 - if (aDiskFail !== bDiskFail) return bDiskFail - aDiskFail - - return (a.health_score ?? 100) - (b.health_score ?? 100) - }) - }, [summary]) - - const actionQueue = useMemo(() => { - return included - .map((obj) => { - const ping = obj.checks?.ping?.status_counts ?? {} - const offline = ping.offline ?? 0 - const unknown = ping.unknown ?? 0 - const disk = obj.checks?.disk_usage?.status_counts ?? {} - const diskWarn = disk.warn ?? 0 - const diskFailed = disk.failed ?? 0 - const agePing = obj.checks?.ping?.max_age_sec ?? 0 - - let score = offline * 6 + unknown * 3 + diskFailed * 4 + diskWarn * 2 - if (agePing > pingInterval * 3) score += 2 - if (agePing > pingInterval * 6) score += 3 - - const reasons: string[] = [] - if (offline > 0) reasons.push(`offline: ${offline}`) - if (unknown > 0) reasons.push(`unknown: ${unknown}`) - if (diskFailed > 0 || diskWarn > 0) reasons.push(`disk warn/failed: ${diskWarn}/${diskFailed}`) - if (agePing > 0) reasons.push(`age ping: ${formatAge(agePing)}`) - - return { obj, score, reasons } - }) - .filter((x) => x.score > 0) - .sort((a, b) => b.score - a.score) - .slice(0, 10) - }, [included, pingInterval]) - - const lastCounts = monitorStatus?.last_counts as Record | null - const monitoredTotal = onlineTotal + offlineTotal + unknownTotal const availabilityPct = toPct(onlineTotal, onlineTotal + offlineTotal) const coveragePct = toPct(summary?.included_count ?? totals.allowed_objects, totals.objects) - const freshnessPct = toPct(Math.max(0, included.length - stalePingObjects.length), included.length) + const freshnessPct = toPct( + Math.max(0, included.length - stalePingObjects.length), + included.length + ) const signalCount = offlineTotal + unknownTotal + diskWarnTotal + diskFailedTotal + const lastCounts = monitorStatus?.last_counts as Record | null + + const systemSeverity: Severity = + severityCounts.critical > 0 ? 'critical' : + severityCounts.degraded > 0 ? 'degraded' : + severityCounts.warn > 0 ? 'warn' : 'ok' return (
+ + {/* Sticky статус-строка */} +
+ + {SEVERITY_LABEL[systemSeverity]} + + {(['critical', 'degraded', 'warn', 'ok'] as Severity[]).map((s) => ( + setSeverityFilter(severityFilter === s ? 'all' : s)} + > + {SEVERITY_LABEL[s]}: {severityCounts[s]} + + ))} + + {monitorStatus?.last_success_at + ? `обновлено ${dayjs(monitorStatus.last_success_at).format('HH:mm:ss')}` + : 'нет данных'} + +
+ + {/* Заголовок */} - - NOC Dashboard - - SLA, покрытие мониторингом, инциденты и очередь действий + NOC Dashboard + SLA, покрытие мониторингом, инциденты - + @@ -215,9 +291,10 @@ export function HomePage() { + {/* Фильтры */} - - + + ({ value: o.id, label: o.name }))} /> - + + + {/* Счётчики */} - - - - - - + + + + + + + + + 0 ? '#cf1322' : undefined }} + /> + + + + + + + + + + + - {monitorStatus?.last_error ? : null} + {monitorStatus?.last_error && ( + + )} + {/* Основная панель объектов */} - - - {incidentObjects.length === 0 ? ( - - ) : ( - { - const ping = item.checks?.ping - const disk = item.checks?.disk_usage?.status_counts ?? {} - const offline = ping?.status_counts?.offline ?? 0 - const unknown = ping?.status_counts?.unknown ?? 0 - const diskWarn = disk.warn ?? 0 - const diskFailed = disk.failed ?? 0 - return ( - navigate(`/home/objects/${item.object_id}${scopeToSearch(scope)}`)}> - Открыть - , - ]} - > - - {item.object_name} - {item.city ?? '—'} - {item.client ?? '—'} - health {item.health_score ?? '—'} - - } - description={ - •} wrap> - offline: {offline} - unknown: {unknown} - disk warn/failed: {diskWarn}/{diskFailed} - устройств: {item.device_count} - age ping: {formatAge(ping?.max_age_sec)} - - } - /> - - ) - }} - /> - )} - - - - {stalePingObjects.length === 0 ? ( - - ) : ( - + Объекты + {severityFilter !== 'all' && ( + + {SEVERITY_LABEL[severityFilter as Severity]} + + )} + {filteredObjects.length} + + } + extra={ + + ]}> - - - {item.obj.object_name} - age: {formatAge(item.pingAge)} - - - offline/unknown: {item.offline}/{item.unknown} - - - - )} + icon={} + type={viewMode === 'grid' ? 'primary' : 'default'} + onClick={() => handleViewMode('grid')} /> - )} - - + , + ]} + > + + {obj.object_name} + {obj.city ?? '—'} + {obj.client ?? '—'} + {SEVERITY_LABEL[severity]} + health {obj.health_score ?? '—'} + + } + description={ + ·} wrap size={4} style={{ fontSize: 12 }}> + 0 ? '#52c41a' : undefined }}>online: {online} + {offline > 0 && offline: {offline}} + {unknown > 0 && unknown: {unknown}} + {(diskWarn > 0 || diskFailed > 0) && ( + 0 ? '#ff4d4f' : '#fa8c16' }}> + disk: {diskWarn}w/{diskFailed}f + + )} + устройств: {obj.device_count} + {age != null && ( + staleThreshold ? '#fa8c16' : undefined }}> + age: {formatAge(age)} + + )} + + } + /> + + ) + }} + /> + )} + + {/* Правая колонка */} @@ -358,9 +541,13 @@ export function HomePage() { Доступность (online) {availabilityPct != null ? `${availabilityPct}%` : '—'} - +
-
Покрытие мониторингом @@ -368,15 +555,18 @@ export function HomePage() {
-
Свежесть данных {freshnessPct != null ? `${freshnessPct}%` : '—'} - +
- online: {onlineTotal} offline: {offlineTotal} @@ -388,51 +578,63 @@ export function HomePage() { - Статус{monitorStatus?.status ?? '-'} - Объекты в мониторинге{summary?.included_count ?? totals.allowed_objects} - Последний успех{monitorStatus?.last_success_at ? dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss') : '—'} - - {lastCounts ? ( + + Статус + {monitorStatus?.status ?? '-'} + + + Объекты в мониторинге + {summary?.included_count ?? totals.allowed_objects} + + + Последний успех + + {monitorStatus?.last_success_at + ? dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss') + : '—'} + + + {lastCounts && ( checks: {lastCounts.checks_executed ?? 0} failed: {lastCounts.failed ?? 0} checked devices: {lastCounts.devices_checked ?? 0} skipped not due: {lastCounts.skipped_not_due ?? 0} - ) : null} + )} - - {actionQueue.length === 0 ? ( - - ) : ( - ( - navigate(`/home/objects/${row.obj.object_id}${scopeToSearch(scope)}`)}> - Открыть - , - ]} - > - - - {row.obj.object_name} - - - prio {row.score} - - - - {row.reasons.join(' • ')} + + + {(['critical', 'degraded', 'warn', 'ok'] as Severity[]).map((s) => ( +
+ + setSeverityFilter(severityFilter === s ? 'all' : s)} + > + {SEVERITY_LABEL[s]} - - )} - /> - )} + {severityCounts[s]} + + 0 + ? Math.round((severityCounts[s] / included.length) * 100) + : 0 + } + size="small" + showInfo={false} + strokeColor={SEVERITY_COLOR[s]} + /> +
+ ))} +
@@ -440,4 +642,3 @@ export function HomePage() {
) } - diff --git a/frontend/src/pages/HomeObjectDetail.tsx b/frontend/src/pages/HomeObjectDetail.tsx index f306c34..bf860ff 100644 --- a/frontend/src/pages/HomeObjectDetail.tsx +++ b/frontend/src/pages/HomeObjectDetail.tsx @@ -31,24 +31,25 @@ 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 statusColor(status: string | null | undefined): string { +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' @@ -66,29 +67,35 @@ function UptimeBars({ points, compact = false }: { points: DashboardAvailability if (!points.length) { return Нет точек в выбранном окне } - return ( -
-
- {points.map((point, idx) => ( -
- ))} -
+
+ {points.map((point, idx) => ( +
+ ))}
) } +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() @@ -97,6 +104,7 @@ export function HomeObjectDetailPage() { const [search] = useSearchParams() const scope = useMemo(() => parseScope(search), [search]) const [window, setWindow] = useState('24h') + const [pingFilter, setPingFilter] = useState('all') const { data: summary, isLoading: summaryLoading } = useDashboardObjectSummary(objectId, scope) const { data: devices, isLoading: devicesLoading } = useDashboardObjectDevices(objectId, scope) @@ -115,46 +123,39 @@ export function HomeObjectDetailPage() { const availabilityByDevice = useMemo(() => { const map = new Map() for (const row of availabilityData?.devices ?? []) { - map.set(row.device_id, { - points: row.series ?? [], - availability: row.availability_pct, - }) + 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 offline = 0 - let unknown = 0 + let online = 0, offline = 0, unknown = 0 const byRack = new Map() for (const row of rows) { - if (row.ping_status === 'online') online += 1 - if (row.ping_status === 'offline') offline += 1 - if (row.ping_status === 'unknown') unknown += 1 + if (row.ping_status === 'online') online++ + if (row.ping_status === 'offline') offline++ + if (row.ping_status === 'unknown') unknown++ - const rackName = row.rack_name && row.rack_name.trim() ? row.rack_name : 'Без стойки' + const rackName = row.rack_name?.trim() || 'Без стойки' if (!byRack.has(rackName)) byRack.set(rackName, []) - byRack.get(rackName)?.push(row) + 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]) + .sort(([a], [b]) => { + if (a === 'Без стойки') return -1 + if (b === 'Без стойки') return 1 + return a.localeCompare(b) }) .map(([rack, rowsInRack]) => ({ rack, rows: [...rowsInRack].sort((a, b) => { - if (a.ping_status !== b.ping_status) { - if (a.ping_status === 'offline') return -1 - if (b.ping_status === 'offline') return 1 - if (a.ping_status === 'unknown') return -1 - if (b.ping_status === 'unknown') return 1 - } + 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) }), })) @@ -162,6 +163,14 @@ export function HomeObjectDetailPage() { 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 = [ { title: 'IP / Hostname', @@ -180,42 +189,52 @@ export function HomeObjectDetailPage() { title: 'Категория', dataIndex: 'category', key: 'category', - width: 100, + width: 110, ellipsis: true, }, { title: 'Роль', key: 'role', - width: 90, + width: 80, render: (_, row) => row.role ?? '—', }, { title: 'Ping', key: 'ping', - width: 140, + width: 130, render: (_, row) => ( - {row.ping_status} + {row.ping_status} {row.last_ping_ms != null ? `${row.last_ping_ms}ms` : '—'} ), }, + { + title: 'Disk', + key: 'disk', + width: 90, + render: (_, row) => { + if (!row.disk_status) return + return ( + + {row.disk_status} + {row.disk_max_pct != null && ( + {row.disk_max_pct}% + )} + + ) + }, + }, { title: 'Uptime', key: 'uptime', - width: 220, + width: 200, render: (_, row) => { const availability = availabilityByDevice.get(Number(row.device_id)) - - if (availabilityLoading) { - return Загрузка... - } - if (!availability?.points?.length) { - return Нет данных - } - + if (availabilityLoading) return Загрузка... + if (!availability?.points?.length) return Нет данных return ( @@ -246,6 +265,12 @@ export function HomeObjectDetailPage() { {object?.allowed ? В мониторинге : Вне мониторинга} health {object?.health_score ?? '-'} окно {window} + @@ -265,77 +290,139 @@ export function HomeObjectDetailPage() { /> ) : null} + {/* Счётчики */} Устройств - - {summary?.totals.devices ?? 0} - + {summary?.totals.devices ?? 0} - + setPingFilter(pingFilter === 'unknown' ? 'all' : 'unknown')} + > Unknown - + 0 ? '#fa8c16' : undefined }}> {grouped.unknown} - + setPingFilter(pingFilter === 'offline' ? 'all' : 'offline')} + > Offline - - {grouped.offline} - + {grouped.offline} - + setPingFilter(pingFilter === 'online' ? 'all' : 'online')} + > Online - - {grouped.online} - + {grouped.online} + + Устройства объекта + {pingFilter !== 'all' && ( + 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} + + )} + + + } size="small" style={{ borderRadius: 12 }} extra={ - setWindow(v as DashboardAvailabilityWindow)} + options={[ + { value: '24h', label: '24ч' }, + { value: '3d', label: '3д' }, + { value: '7d', label: '7д' }, + ]} + style={{ width: 80 }} + /> + } > - {!grouped.rackGroups.length ? ( - + {!filteredRackGroups.length ? ( + ) : ( ({ - key: rackGroup.rack, - label: `${rackGroup.rack} (${rackGroup.rows.length})`, - children: ( - - loading={devicesLoading} - dataSource={rackGroup.rows} - rowKey="device_id" - size="small" - pagination={false} - columns={columns} - /> - ), - }))} + 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: ( + + {rackGroup.rack} + {rackGroup.rows.length} + {offlineCount > 0 && offline: {offlineCount}} + {unknownCount > 0 && unknown: {unknownCount}} + + ), + children: ( + + 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' : '' + } + /> + ), + } + })} /> )}