допустим

This commit is contained in:
dv 2026-05-05 18:16:58 +03:00
parent 484a07afc3
commit 7460a7cbb9
3 changed files with 811 additions and 300 deletions

View 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>
)
}

View file

@ -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>
)
}

View file

@ -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>