допустим
This commit is contained in:
parent
484a07afc3
commit
7460a7cbb9
3 changed files with 811 additions and 300 deletions
223
frontend/src/components/ObjectStatusCard.tsx
Normal file
223
frontend/src/components/ObjectStatusCard.tsx
Normal file
|
|
@ -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<Severity, string> = {
|
||||
critical: '#ff4d4f',
|
||||
degraded: '#fa8c16',
|
||||
warn: '#fadb14',
|
||||
ok: '#52c41a',
|
||||
}
|
||||
|
||||
export const SEVERITY_LABEL: Record<Severity, string> = {
|
||||
critical: 'CRITICAL',
|
||||
degraded: 'DEGRADED',
|
||||
warn: 'WARN',
|
||||
ok: 'OK',
|
||||
}
|
||||
|
||||
export const SEVERITY_TAG_COLOR: Record<Severity, string> = {
|
||||
critical: 'red',
|
||||
degraded: 'orange',
|
||||
warn: 'gold',
|
||||
ok: 'green',
|
||||
}
|
||||
|
||||
const SEVERITY_BG: Record<Severity, string> = {
|
||||
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 <div style={{ height: 6, background: '#f0f0f0', borderRadius: 3 }} />
|
||||
|
||||
const onlinePct = (online / total) * 100
|
||||
const offlinePct = (offline / total) * 100
|
||||
const unknownPct = (unknown / total) * 100
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: 6, borderRadius: 3, overflow: 'hidden', gap: 1 }}>
|
||||
{online > 0 && (
|
||||
<div
|
||||
title={`online: ${online}`}
|
||||
style={{ width: `${onlinePct}%`, background: '#52c41a', minWidth: 2 }}
|
||||
/>
|
||||
)}
|
||||
{unknown > 0 && (
|
||||
<div
|
||||
title={`unknown: ${unknown}`}
|
||||
style={{ width: `${unknownPct}%`, background: '#fa8c16', minWidth: 2 }}
|
||||
/>
|
||||
)}
|
||||
{offline > 0 && (
|
||||
<div
|
||||
title={`offline: ${offline}`}
|
||||
style={{ width: `${offlinePct}%`, background: '#ff4d4f', minWidth: 2 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
onClick={() => 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'
|
||||
}}
|
||||
>
|
||||
{/* Заголовок */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 4 }}>
|
||||
<Typography.Text
|
||||
strong
|
||||
ellipsis={{ tooltip: obj.object_name }}
|
||||
style={{ fontSize: 13, flex: 1, lineHeight: '18px' }}
|
||||
>
|
||||
{obj.object_name}
|
||||
</Typography.Text>
|
||||
<Tag
|
||||
color={SEVERITY_TAG_COLOR[severity]}
|
||||
style={{ margin: 0, fontSize: 10, lineHeight: '16px', padding: '0 4px', flexShrink: 0 }}
|
||||
>
|
||||
{SEVERITY_LABEL[severity]}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{/* Мета */}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{obj.city && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{obj.city}</Typography.Text>
|
||||
)}
|
||||
{obj.client && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>· {obj.client}</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Статус-бар */}
|
||||
<MiniStatusBar online={online} offline={offline} unknown={unknown} />
|
||||
|
||||
{/* Счётчики */}
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{offline > 0 && (
|
||||
<Typography.Text style={{ fontSize: 11, color: '#ff4d4f' }}>
|
||||
offline: <strong>{offline}</strong>
|
||||
</Typography.Text>
|
||||
)}
|
||||
{unknown > 0 && (
|
||||
<Typography.Text style={{ fontSize: 11, color: '#fa8c16' }}>
|
||||
unknown: <strong>{unknown}</strong>
|
||||
</Typography.Text>
|
||||
)}
|
||||
{online > 0 && offline === 0 && unknown === 0 && (
|
||||
<Typography.Text style={{ fontSize: 11, color: '#52c41a' }}>
|
||||
online: <strong>{online}</strong>
|
||||
</Typography.Text>
|
||||
)}
|
||||
{(diskWarn > 0 || diskFailed > 0) && (
|
||||
<Typography.Text style={{ fontSize: 11, color: diskFailed > 0 ? '#ff4d4f' : '#fa8c16' }}>
|
||||
disk: {diskWarn}w/{diskFailed}f
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Нижняя строка */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 'auto' }}>
|
||||
<Space size={4}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{obj.device_count} уст.
|
||||
</Typography.Text>
|
||||
{obj.health_score != null && (
|
||||
<Tag
|
||||
color={obj.health_score >= 85 ? 'green' : obj.health_score >= 65 ? 'orange' : 'red'}
|
||||
style={{ margin: 0, fontSize: 10, padding: '0 4px' }}
|
||||
>
|
||||
h{obj.health_score}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
{age != null && (
|
||||
<Typography.Text
|
||||
style={{ fontSize: 11, color: isStale ? '#fa8c16' : '#8c8c8c' }}
|
||||
>
|
||||
{formatAge(age)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<Severity, number> = { 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<string | undefined>()
|
||||
const [objectIds, setObjectIds] = useState<number[]>([])
|
||||
const [tagIds, setTagIds] = useState<number[]>([])
|
||||
const [severityFilter, setSeverityFilter] = useState<Severity | 'all'>('all')
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(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<Severity, number> = { 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<string, Array<{ obj: DashboardObjectSummary; severity: Severity }>>()
|
||||
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<string, number> | 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<string, number> | null
|
||||
|
||||
const systemSeverity: Severity =
|
||||
severityCounts.critical > 0 ? 'critical' :
|
||||
severityCounts.degraded > 0 ? 'degraded' :
|
||||
severityCounts.warn > 0 ? 'warn' : 'ok'
|
||||
|
||||
return (
|
||||
<div style={{ background: '#f5f7fb', margin: -12, padding: 12, borderRadius: 12 }}>
|
||||
|
||||
{/* Sticky статус-строка */}
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
background: SEVERITY_COLOR[systemSeverity],
|
||||
borderRadius: 8,
|
||||
padding: '6px 16px',
|
||||
marginBottom: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 24,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ color: '#fff', fontSize: 13, letterSpacing: 1 }}>
|
||||
{SEVERITY_LABEL[systemSeverity]}
|
||||
</Typography.Text>
|
||||
{(['critical', 'degraded', 'warn', 'ok'] as Severity[]).map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
opacity: severityCounts[s] === 0 ? 0.5 : 1,
|
||||
cursor: 'pointer',
|
||||
fontWeight: severityFilter === s ? 700 : 400,
|
||||
textDecoration: severityFilter === s ? 'underline' : undefined,
|
||||
}}
|
||||
onClick={() => setSeverityFilter(severityFilter === s ? 'all' : s)}
|
||||
>
|
||||
{SEVERITY_LABEL[s]}: <strong>{severityCounts[s]}</strong>
|
||||
</span>
|
||||
))}
|
||||
<span style={{ color: '#fff', fontSize: 12, marginLeft: 'auto', opacity: 0.85 }}>
|
||||
{monitorStatus?.last_success_at
|
||||
? `обновлено ${dayjs(monitorStatus.last_success_at).format('HH:mm:ss')}`
|
||||
: 'нет данных'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Заголовок */}
|
||||
<Row justify="space-between" align="middle" gutter={[8, 8]} style={{ marginBottom: 12 }}>
|
||||
<Col>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
NOC Dashboard
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">SLA, покрытие мониторингом, инциденты и очередь действий</Typography.Text>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>NOC Dashboard</Typography.Title>
|
||||
<Typography.Text type="secondary">SLA, покрытие мониторингом, инциденты</Typography.Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space wrap>
|
||||
<Button onClick={() => navigate(`/home/settings${scopeToSearch(scope)}`)}>Настройки мониторинга</Button>
|
||||
<Button onClick={() => navigate(`/home/settings${scopeToSearch(scope)}`)}>
|
||||
Настройки мониторинга
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleRunNow} loading={runNow.isPending}>
|
||||
Запустить сейчас
|
||||
</Button>
|
||||
|
|
@ -215,9 +291,10 @@ export function HomePage() {
|
|||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Фильтры */}
|
||||
<Card size="small" style={{ marginBottom: 12, borderRadius: 12 }}>
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Row gutter={[8, 8]} align="middle">
|
||||
<Col xs={24} sm={12} lg={5}>
|
||||
<Select
|
||||
placeholder="Город"
|
||||
allowClear
|
||||
|
|
@ -227,7 +304,7 @@ export function HomePage() {
|
|||
onChange={setCity}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Col xs={24} sm={12} lg={5}>
|
||||
<Select
|
||||
placeholder="Клиент"
|
||||
allowClear
|
||||
|
|
@ -248,7 +325,7 @@ export function HomePage() {
|
|||
options={(objects ?? []).map((o) => ({ value: o.id, label: o.name }))}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Col xs={24} sm={12} lg={5}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Теги"
|
||||
|
|
@ -259,60 +336,190 @@ export function HomePage() {
|
|||
options={(tags ?? []).map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} lg={3}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={severityFilter}
|
||||
onChange={setSeverityFilter}
|
||||
options={[
|
||||
{ value: 'all', label: 'Все' },
|
||||
{ value: 'critical', label: 'Critical' },
|
||||
{ value: 'degraded', label: 'Degraded' },
|
||||
{ value: 'warn', label: 'Warn' },
|
||||
{ value: 'ok', label: 'OK' },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Счётчики */}
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
<Col xs={12} md={8} lg={4}><Card size="small" loading={isLoading}><Statistic title="Объектов в скоупе" value={totals.objects} /></Card></Col>
|
||||
<Col xs={12} md={8} lg={4}><Card size="small" loading={isLoading}><Statistic title="Устройств" value={totals.devices} /></Card></Col>
|
||||
<Col xs={12} md={8} lg={4}><Card size="small" loading={isLoading}><Statistic title="Инцидентных объектов" value={incidentObjects.length} /></Card></Col>
|
||||
<Col xs={12} md={8} lg={4}><Card size="small" loading={isLoading}><Statistic title="Сигналов проблем" value={signalCount} /></Card></Col>
|
||||
<Col xs={12} md={8} lg={4}><Card size="small" loading={isLoading}><Statistic title="Просроченные объекты" value={stalePingObjects.length} /></Card></Col>
|
||||
<Col xs={12} md={8} lg={4}><Card size="small" loading={isLoading}><Statistic title="Средний health" value={avgHealth ?? '—'} /></Card></Col>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
<Card size="small" loading={isLoading}><Statistic title="Объектов в скоупе" value={totals.objects} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
<Card size="small" loading={isLoading}><Statistic title="Устройств" value={totals.devices} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
<Card size="small" loading={isLoading}>
|
||||
<Statistic
|
||||
title="Проблемных объектов"
|
||||
value={severityCounts.critical + severityCounts.degraded}
|
||||
valueStyle={{ color: severityCounts.critical > 0 ? '#cf1322' : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
<Card size="small" loading={isLoading}><Statistic title="Сигналов проблем" value={signalCount} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
<Card size="small" loading={isLoading}><Statistic title="Просроченных объектов" value={stalePingObjects.length} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
<Card size="small" loading={isLoading}><Statistic title="Средний health" value={avgHealth ?? '—'} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{monitorStatus?.last_error ? <Alert type="error" showIcon style={{ marginBottom: 12 }} message={monitorStatus.last_error} /> : null}
|
||||
{monitorStatus?.last_error && (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 12 }} message={monitorStatus.last_error} />
|
||||
)}
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
{/* Основная панель объектов */}
|
||||
<Col xs={24} xl={16}>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Card title="Объекты с инцидентами" size="small" style={{ borderRadius: 12 }}>
|
||||
{incidentObjects.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Критичных объектов нет" />
|
||||
<Card
|
||||
size="small"
|
||||
style={{ borderRadius: 12 }}
|
||||
title={
|
||||
<Space>
|
||||
<span>Объекты</span>
|
||||
{severityFilter !== 'all' && (
|
||||
<Tag color={SEVERITY_TAG_COLOR[severityFilter as Severity]}>
|
||||
{SEVERITY_LABEL[severityFilter as Severity]}
|
||||
</Tag>
|
||||
)}
|
||||
<Tag>{filteredObjects.length}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<AppstoreOutlined />}
|
||||
type={viewMode === 'grid' ? 'primary' : 'default'}
|
||||
onClick={() => handleViewMode('grid')}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<BarsOutlined />}
|
||||
type={viewMode === 'list' ? 'primary' : 'default'}
|
||||
onClick={() => handleViewMode('list')}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{filteredObjects.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Нет объектов в выбранном фильтре" />
|
||||
) : viewMode === 'grid' ? (
|
||||
// Grid-режим: плитки сгруппированные по городу
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{cityGroups.map(([cityKey, items]) => (
|
||||
<div key={cityKey}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>
|
||||
{cityKey === UNGROUPED_CITY ? 'Без города' : cityKey}
|
||||
</Typography.Text>
|
||||
<Tag style={{ margin: 0 }}>{items.length}</Tag>
|
||||
{/* Мини-счётчики severity для города */}
|
||||
{(['critical', 'degraded', 'warn'] as Severity[]).map((s) => {
|
||||
const cnt = items.filter((x) => x.severity === s).length
|
||||
return cnt > 0 ? (
|
||||
<Tag key={s} color={SEVERITY_TAG_COLOR[s]} style={{ margin: 0, fontSize: 11 }}>
|
||||
{cnt}
|
||||
</Tag>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{items.map(({ obj, severity }) => (
|
||||
<ObjectStatusCard
|
||||
key={obj.object_id}
|
||||
obj={obj}
|
||||
severity={severity}
|
||||
staleThreshold={staleThreshold}
|
||||
scope={scope}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// List-режим: строки с цветной полосой
|
||||
<List
|
||||
dataSource={incidentObjects.slice(0, 12)}
|
||||
renderItem={(item) => {
|
||||
const ping = item.checks?.ping
|
||||
const disk = item.checks?.disk_usage?.status_counts ?? {}
|
||||
dataSource={filteredObjects}
|
||||
renderItem={({ obj, severity }) => {
|
||||
const ping = obj.checks?.ping
|
||||
const disk = obj.checks?.disk_usage?.status_counts ?? {}
|
||||
const offline = ping?.status_counts?.offline ?? 0
|
||||
const unknown = ping?.status_counts?.unknown ?? 0
|
||||
const online = ping?.status_counts?.online ?? 0
|
||||
const diskWarn = disk.warn ?? 0
|
||||
const diskFailed = disk.failed ?? 0
|
||||
const age = ping?.max_age_sec
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
style={{
|
||||
borderLeft: `3px solid ${SEVERITY_COLOR[severity]}`,
|
||||
paddingLeft: 10,
|
||||
background:
|
||||
severity === 'critical' ? 'rgba(255,77,79,0.04)' :
|
||||
severity === 'degraded' ? 'rgba(250,140,22,0.04)' : undefined,
|
||||
}}
|
||||
actions={[
|
||||
<Button key="open" type="link" onClick={() => navigate(`/home/objects/${item.object_id}${scopeToSearch(scope)}`)}>
|
||||
<Button
|
||||
key="open"
|
||||
type="link"
|
||||
onClick={() => navigate(`/home/objects/${obj.object_id}${scopeToSearch(scope)}`)}
|
||||
>
|
||||
Открыть
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space wrap size={8}>
|
||||
<Typography.Text strong>{item.object_name}</Typography.Text>
|
||||
<Tag>{item.city ?? '—'}</Tag>
|
||||
<Tag color="blue">{item.client ?? '—'}</Tag>
|
||||
<Tag color={healthColor(item.health_score)}>health {item.health_score ?? '—'}</Tag>
|
||||
<Space wrap size={6}>
|
||||
<Typography.Text strong>{obj.object_name}</Typography.Text>
|
||||
<Tag>{obj.city ?? '—'}</Tag>
|
||||
<Tag color="blue">{obj.client ?? '—'}</Tag>
|
||||
<Tag color={SEVERITY_TAG_COLOR[severity]}>{SEVERITY_LABEL[severity]}</Tag>
|
||||
<Tag color={healthColor(obj.health_score)}>health {obj.health_score ?? '—'}</Tag>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space split={<span>•</span>} wrap>
|
||||
<span>offline: {offline}</span>
|
||||
<span>unknown: {unknown}</span>
|
||||
<span>disk warn/failed: {diskWarn}/{diskFailed}</span>
|
||||
<span>устройств: {item.device_count}</span>
|
||||
<span>age ping: {formatAge(ping?.max_age_sec)}</span>
|
||||
<Space split={<span>·</span>} wrap size={4} style={{ fontSize: 12 }}>
|
||||
<span style={{ color: online > 0 ? '#52c41a' : undefined }}>online: {online}</span>
|
||||
{offline > 0 && <span style={{ color: '#ff4d4f' }}>offline: {offline}</span>}
|
||||
{unknown > 0 && <span style={{ color: '#fa8c16' }}>unknown: {unknown}</span>}
|
||||
{(diskWarn > 0 || diskFailed > 0) && (
|
||||
<span style={{ color: diskFailed > 0 ? '#ff4d4f' : '#fa8c16' }}>
|
||||
disk: {diskWarn}w/{diskFailed}f
|
||||
</span>
|
||||
)}
|
||||
<span>устройств: {obj.device_count}</span>
|
||||
{age != null && (
|
||||
<span style={{ color: (age ?? 0) > staleThreshold ? '#fa8c16' : undefined }}>
|
||||
age: {formatAge(age)}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
|
@ -322,33 +529,9 @@ export function HomePage() {
|
|||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Объекты с несвежими данными мониторинга" size="small" style={{ borderRadius: 12 }}>
|
||||
{stalePingObjects.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Все объекты обновляются в пределах SLA" />
|
||||
) : (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={stalePingObjects}
|
||||
renderItem={(item) => (
|
||||
<List.Item actions={[<Button key="open" type="link" onClick={() => navigate(`/home/objects/${item.obj.object_id}${scopeToSearch(scope)}`)}>Открыть</Button>]}>
|
||||
<Space direction="vertical" size={0} style={{ width: '100%' }}>
|
||||
<Row justify="space-between">
|
||||
<Typography.Text>{item.obj.object_name}</Typography.Text>
|
||||
<Typography.Text type="secondary">age: {formatAge(item.pingAge)}</Typography.Text>
|
||||
</Row>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
offline/unknown: {item.offline}/{item.unknown}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
{/* Правая колонка */}
|
||||
<Col xs={24} xl={8}>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Card title="SLA и покрытие" size="small" style={{ borderRadius: 12 }}>
|
||||
|
|
@ -358,9 +541,13 @@ export function HomePage() {
|
|||
<Typography.Text>Доступность (online)</Typography.Text>
|
||||
<Typography.Text strong>{availabilityPct != null ? `${availabilityPct}%` : '—'}</Typography.Text>
|
||||
</Row>
|
||||
<Progress percent={availabilityPct ?? 0} size="small" showInfo={false} strokeColor={availabilityPct != null && availabilityPct < 95 ? '#ff4d4f' : '#52c41a'} />
|
||||
<Progress
|
||||
percent={availabilityPct ?? 0}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor={availabilityPct != null && availabilityPct < 95 ? '#ff4d4f' : '#52c41a'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Row justify="space-between">
|
||||
<Typography.Text>Покрытие мониторингом</Typography.Text>
|
||||
|
|
@ -368,15 +555,18 @@ export function HomePage() {
|
|||
</Row>
|
||||
<Progress percent={coveragePct ?? 0} size="small" showInfo={false} strokeColor="#1677ff" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Row justify="space-between">
|
||||
<Typography.Text>Свежесть данных</Typography.Text>
|
||||
<Typography.Text strong>{freshnessPct != null ? `${freshnessPct}%` : '—'}</Typography.Text>
|
||||
</Row>
|
||||
<Progress percent={freshnessPct ?? 0} size="small" showInfo={false} strokeColor={freshnessPct != null && freshnessPct < 90 ? '#faad14' : '#52c41a'} />
|
||||
<Progress
|
||||
percent={freshnessPct ?? 0}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor={freshnessPct != null && freshnessPct < 90 ? '#faad14' : '#52c41a'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Space wrap>
|
||||
<Tag color="green">online: {onlineTotal}</Tag>
|
||||
<Tag color="red">offline: {offlineTotal}</Tag>
|
||||
|
|
@ -388,51 +578,63 @@ export function HomePage() {
|
|||
|
||||
<Card title="Состояние цикла мониторинга" size="small" style={{ borderRadius: 12 }}>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Row justify="space-between"><Typography.Text>Статус</Typography.Text><Tag color={runStatusColor(monitorStatus?.status)}>{monitorStatus?.status ?? '-'}</Tag></Row>
|
||||
<Row justify="space-between"><Typography.Text>Объекты в мониторинге</Typography.Text><Typography.Text strong>{summary?.included_count ?? totals.allowed_objects}</Typography.Text></Row>
|
||||
<Row justify="space-between"><Typography.Text>Последний успех</Typography.Text><Typography.Text strong>{monitorStatus?.last_success_at ? dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss') : '—'}</Typography.Text></Row>
|
||||
|
||||
{lastCounts ? (
|
||||
<Row justify="space-between">
|
||||
<Typography.Text>Статус</Typography.Text>
|
||||
<Tag color={runStatusColor(monitorStatus?.status)}>{monitorStatus?.status ?? '-'}</Tag>
|
||||
</Row>
|
||||
<Row justify="space-between">
|
||||
<Typography.Text>Объекты в мониторинге</Typography.Text>
|
||||
<Typography.Text strong>{summary?.included_count ?? totals.allowed_objects}</Typography.Text>
|
||||
</Row>
|
||||
<Row justify="space-between">
|
||||
<Typography.Text>Последний успех</Typography.Text>
|
||||
<Typography.Text strong>
|
||||
{monitorStatus?.last_success_at
|
||||
? dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')
|
||||
: '—'}
|
||||
</Typography.Text>
|
||||
</Row>
|
||||
{lastCounts && (
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>checks: {lastCounts.checks_executed ?? 0}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>failed: {lastCounts.failed ?? 0}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>checked devices: {lastCounts.devices_checked ?? 0}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>skipped not due: {lastCounts.skipped_not_due ?? 0}</Typography.Text>
|
||||
</Space>
|
||||
) : null}
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="Action queue" size="small" style={{ borderRadius: 12 }}>
|
||||
{actionQueue.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Критичных действий сейчас нет" />
|
||||
) : (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={actionQueue}
|
||||
renderItem={(row) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button key="open" type="link" onClick={() => navigate(`/home/objects/${row.obj.object_id}${scopeToSearch(scope)}`)}>
|
||||
Открыть
|
||||
</Button>,
|
||||
]}
|
||||
<Card title="Breakdown по severity" size="small" style={{ borderRadius: 12 }}>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
{(['critical', 'degraded', 'warn', 'ok'] as Severity[]).map((s) => (
|
||||
<div key={s}>
|
||||
<Row justify="space-between" style={{ marginBottom: 2 }}>
|
||||
<Typography.Text
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
color: severityFilter === s ? SEVERITY_COLOR[s] : undefined,
|
||||
fontWeight: severityFilter === s ? 600 : undefined,
|
||||
}}
|
||||
onClick={() => setSeverityFilter(severityFilter === s ? 'all' : s)}
|
||||
>
|
||||
<Row justify="space-between" style={{ width: '100%' }}>
|
||||
<Col>
|
||||
<Typography.Text>{row.obj.object_name}</Typography.Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Tag color="red">prio {row.score}</Tag>
|
||||
</Col>
|
||||
</Row>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.reasons.join(' • ')}
|
||||
{SEVERITY_LABEL[s]}
|
||||
</Typography.Text>
|
||||
</List.Item>
|
||||
)}
|
||||
<Typography.Text strong>{severityCounts[s]}</Typography.Text>
|
||||
</Row>
|
||||
<Progress
|
||||
percent={
|
||||
included.length > 0
|
||||
? Math.round((severityCounts[s] / included.length) * 100)
|
||||
: 0
|
||||
}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor={SEVERITY_COLOR[s]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
|
|
@ -440,4 +642,3 @@ export function HomePage() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,9 +67,7 @@ function UptimeBars({ points, compact = false }: { points: DashboardAvailability
|
|||
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
|
||||
|
|
@ -85,10 +84,18 @@ function UptimeBars({ points, compact = false }: { points: DashboardAvailability
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
</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()
|
||||
|
|
@ -97,6 +104,7 @@ export function HomeObjectDetailPage() {
|
|||
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)
|
||||
|
|
@ -115,46 +123,39 @@ export function HomeObjectDetailPage() {
|
|||
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,
|
||||
})
|
||||
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<string, DashboardDeviceNOCItem[]>()
|
||||
|
||||
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<DashboardDeviceNOCItem> = [
|
||||
{
|
||||
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) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color={statusColor(row.ping_status)}>{row.ping_status}</Tag>
|
||||
<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: 220,
|
||||
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>
|
||||
}
|
||||
|
||||
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 />
|
||||
|
|
@ -246,6 +265,12 @@ export function HomeObjectDetailPage() {
|
|||
{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>
|
||||
|
|
@ -265,46 +290,90 @@ export function HomeObjectDetailPage() {
|
|||
/>
|
||||
) : 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>
|
||||
<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 }}>
|
||||
<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 }}>
|
||||
<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 }}>
|
||||
<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>
|
||||
<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 }}>
|
||||
<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>
|
||||
<Typography.Title level={3} style={{ margin: 0, color: '#389e0d' }}>{grouped.online}</Typography.Title>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="Устройства объекта"
|
||||
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)}
|
||||
|
|
@ -313,18 +382,32 @@ export function HomeObjectDetailPage() {
|
|||
{ value: '3d', label: '3д' },
|
||||
{ value: '7d', label: '7д' },
|
||||
]}
|
||||
style={{ width: 120 }}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{!grouped.rackGroups.length ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Устройства не найдены" />
|
||||
{!filteredRackGroups.length ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={pingFilter === 'all' ? 'Устройства не найдены' : `Нет устройств со статусом "${pingFilter}"`}
|
||||
/>
|
||||
) : (
|
||||
<Collapse
|
||||
defaultActiveKey={['Без стойки']}
|
||||
items={grouped.rackGroups.map((rackGroup) => ({
|
||||
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})`,
|
||||
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}
|
||||
|
|
@ -333,9 +416,13 @@ export function HomeObjectDetailPage() {
|
|||
size="small"
|
||||
pagination={false}
|
||||
columns={columns}
|
||||
rowClassName={(row) =>
|
||||
row.ping_status === 'offline' ? 'row-offline' : row.ping_status === 'unknown' ? 'row-unknown' : ''
|
||||
}
|
||||
/>
|
||||
),
|
||||
}))}
|
||||
}
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue