+
- Light NOC Dashboard
+ NOC Dashboard
- Оперативный обзор доступности и инцидентов
+ SLA, ,
-
+
@@ -195,7 +219,7 @@ export function HomePage() {
diff --git a/frontend/src/pages/HomeObjectDetail.tsx b/frontend/src/pages/HomeObjectDetail.tsx
index 6af7856..69acba5 100644
--- a/frontend/src/pages/HomeObjectDetail.tsx
+++ b/frontend/src/pages/HomeObjectDetail.tsx
@@ -1,29 +1,29 @@
import { ArrowLeftOutlined } from '@ant-design/icons'
+import { useQueryClient } from '@tanstack/react-query'
import { Alert, Button, Card, Col, Empty, Row, Select, Space, Table, Tag, Typography } from 'antd'
+import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { useMemo, useState } from 'react'
-import { useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import {
useDashboardObjectAvailability,
useDashboardObjectDevices,
useDashboardObjectSummary,
- type DashboardAvailabilityInterval,
type DashboardAvailabilityPoint,
type DashboardAvailabilityWindow,
- type DashboardDeviceAvailability,
+ type DashboardDeviceNOCItem,
type DashboardScope,
} from '../api/dashboard'
import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE'
function reasonLabel(reason: string): string {
const map: Record
= {
- object_include_policy: 'Включен политикой объекта',
- object_exclude_policy: 'Исключен политикой объекта',
- inherit_default: 'Наследование по умолчанию',
- tag_policy_enabled: 'Включен политикой тегов',
- tag_policy_disabled: 'Исключен политикой тегов',
- scope_filtered: 'Вне текущего фильтра',
+ object_include_policy: ' ',
+ object_exclude_policy: ' ',
+ inherit_default: ' ',
+ tag_policy_enabled: ' ',
+ tag_policy_disabled: ' ',
+ scope_filtered: ' ',
}
return map[reason] ?? reason
}
@@ -45,7 +45,7 @@ function parseScope(params: URLSearchParams): DashboardScope {
function statusColor(status: string | null | undefined): string {
if (status === 'online') return 'green'
if (status === 'offline') return 'red'
- if (status === 'warn') return 'orange'
+ if (status === 'warn' || status === 'failed') return 'orange'
return 'default'
}
@@ -56,219 +56,61 @@ function healthColor(score: number | null | undefined): string {
return 'red'
}
-function formatDuration(sec: number): string {
- if (sec < 60) return `${sec}с`
- if (sec < 3600) return `${Math.floor(sec / 60)}м ${sec % 60}с`
- return `${Math.floor(sec / 3600)}ч ${Math.floor((sec % 3600) / 60)}м`
-}
-
function formatAge(seconds: number | null | undefined): string {
if (seconds == null) return '-'
- if (seconds < 60) return `${seconds}с`
- if (seconds < 3600) return `${Math.floor(seconds / 60)}м`
- return `${Math.floor(seconds / 3600)}ч ${Math.floor((seconds % 3600) / 60)}м`
+ if (seconds < 60) return `${seconds}`
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}`
+ return `${Math.floor(seconds / 3600)} ${Math.floor((seconds % 3600) / 60)}`
}
-function AvailabilityMiniChart({ points }: { points: DashboardAvailabilityPoint[] }) {
- const viewW = 860
- const viewH = 150
- const padX = 14
- const padY = 12
-
- const polyline = useMemo(() => {
- if (!points.length) return ''
- return points
- .map((p, idx) => {
- const x = padX + (idx / Math.max(1, points.length - 1)) * (viewW - padX * 2)
- const val = p.availability_pct ?? 0
- const y = padY + ((100 - val) / 100) * (viewH - padY * 2)
- return `${x},${y}`
- })
- .join(' ')
- }, [points])
-
- return (
-
- )
+function uptimeColor(point: DashboardAvailabilityPoint): string {
+ if (point.samples === 0) return '#d9d9d9'
+ if (point.offline > 0) return '#ff4d4f'
+ return '#52c41a'
}
-function StatusTimeline({
- intervals,
- startAt,
- endAt,
-}: {
- intervals: DashboardAvailabilityInterval[]
- startAt: string
- endAt: string
-}) {
- const start = dayjs(startAt).valueOf()
- const end = dayjs(endAt).valueOf()
- const range = Math.max(1, end - start)
-
- const bars = intervals
- .filter((i) => i.status === 'online' || i.status === 'offline')
- .map((i, idx) => {
- const from = Math.max(dayjs(i.started_at).valueOf(), start)
- const to = Math.min(dayjs(i.ended_at).valueOf(), end)
- const left = ((from - start) / range) * 100
- const width = Math.max(0.8, ((to - from) / range) * 100)
- return {
- key: `${i.status}-${i.started_at}-${idx}`,
- left,
- width,
- status: i.status,
- duration: i.duration_sec,
- }
- })
-
- if (!bars.length) {
- return Недостаточно интервалов для отображения
+function UptimeBars({ points, compact = false }: { points: DashboardAvailabilityPoint[]; compact?: boolean }) {
+ if (!points.length) {
+ return
}
return (
-
-
- {bars.map((bar) => (
+
+
+ {points.map((point, idx) => (
))}
-
+
- {dayjs(startAt).format('DD.MM HH:mm')}
+ {dayjs(points[0].bucket_start).format('DD.MM HH:mm')}
- {dayjs(endAt).format('DD.MM HH:mm')}
+ {dayjs(points[points.length - 1].bucket_end).format('DD.MM HH:mm')}
+ {!compact ? (
+
+ online
+ offline
+
+
+ ) : null}
)
}
-function DeviceExpandedRow({
- data,
- isLoading,
- startAt,
- endAt,
-}: {
- data?: DashboardDeviceAvailability
- isLoading: boolean
- startAt?: string
- endAt?: string
-}) {
- if (isLoading) {
- return
Загрузка истории доступности...
- }
- if (!data) {
- return
Недостаточно данных по устройству в выбранном окне
- }
-
- const recentSwitches = [...(data.intervals ?? [])]
- .filter((i) => i.status === 'online' || i.status === 'offline')
- .sort((a, b) => dayjs(b.started_at).valueOf() - dayjs(a.started_at).valueOf())
- .slice(0, 5)
-
- return (
-
-
-
-
- Availability
-
- {data.availability_pct != null ? `${data.availability_pct}%` : '—'}
-
- Samples: {data.samples}
-
-
-
-
- Последний статус
-
- {data.last_status ?? 'unknown'}
-
-
- {data.last_checked_at ? dayjs(data.last_checked_at).format('DD.MM HH:mm:ss') : '—'}
-
-
-
-
-
- Последний ping
-
- {data.last_ping_ms != null ? `${data.last_ping_ms} ms` : '—'}
-
- Online: {data.online} • Offline: {data.offline}
-
-
-
-
-
- {data.series.length ? (
-
- ) : (
-
- )}
-
-
-
- {startAt && endAt ? (
-
- ) : (
- Диапазон окна недоступен
- )}
-
-
-
- {recentSwitches.length ? (
-
- {recentSwitches.map((sw, idx) => (
-
- {sw.status} {dayjs(sw.started_at).format('DD.MM HH:mm')} ({formatDuration(sw.duration_sec)})
-
- ))}
-
- ) : (
- Переключений пока нет
- )}
-
-
- )
-}
-
export function HomeObjectDetailPage() {
const navigate = useNavigate()
const qc = useQueryClient()
@@ -277,15 +119,10 @@ export function HomeObjectDetailPage() {
const [search] = useSearchParams()
const scope = useMemo(() => parseScope(search), [search])
const [window, setWindow] = useState
('24h')
- const [expandedKeys, setExpandedKeys] = useState([])
const { data: summary, isLoading: summaryLoading } = useDashboardObjectSummary(objectId, scope)
const { data: devices, isLoading: devicesLoading } = useDashboardObjectDevices(objectId, scope)
- const {
- data: expandedAvailability,
- isLoading: expandedAvailLoading,
- isFetching: expandedAvailFetching,
- } = useDashboardObjectAvailability(objectId, window, expandedKeys.length ? expandedKeys : undefined)
+ const { data: availabilityData, isLoading: availabilityLoading } = useDashboardObjectAvailability(objectId, window, undefined, true)
useDashboardMonitorSSE({
onUpdate: () => {
@@ -298,42 +135,160 @@ export function HomeObjectDetailPage() {
const object = summary?.object
const availabilityByDevice = useMemo(() => {
- const map = new Map()
- for (const row of expandedAvailability?.devices ?? []) {
- map.set(row.device_id, row)
+ const map = new Map()
+ for (const row of availabilityData?.devices ?? []) {
+ map.set(row.device_id, {
+ points: row.series ?? [],
+ availability: row.availability_pct,
+ })
}
return map
- }, [expandedAvailability])
+ }, [availabilityData])
const grouped = useMemo(() => {
const rows = devices?.devices ?? []
let online = 0
- let offline = 0
+ let warn = 0
+ const byRack = new Map()
+
for (const row of rows) {
if (row.ping_status === 'online') online += 1
- else if (row.ping_status === 'offline') offline += 1
+
+ if (row.disk_status === 'warn' || row.disk_status === 'failed') {
+ warn += 1
+ }
+
+ const rackName = row.rack_name && row.rack_name.trim() ? row.rack_name : ' '
+ if (!byRack.has(rackName)) byRack.set(rackName, [])
+ byRack.get(rackName)?.push(row)
}
- return { online, offline }
+
+ const rackGroups = Array.from(byRack.entries())
+ .sort((a, b) => {
+ if (a[0] === ' ') return 1
+ if (b[0] === ' ') return -1
+ return a[0].localeCompare(b[0])
+ })
+ .map(([rack, rowsInRack]) => ({
+ rack,
+ rows: [...rowsInRack].sort((a, b) => {
+ const aProblem = a.has_problem ? 0 : 1
+ const bProblem = b.has_problem ? 0 : 1
+ if (aProblem !== bProblem) return aProblem - bProblem
+ if (a.ping_status !== b.ping_status) {
+ if (a.ping_status === 'offline') return -1
+ if (b.ping_status === 'offline') return 1
+ }
+ return a.ip.localeCompare(b.ip)
+ }),
+ }))
+
+ return { online, warn, rackGroups }
}, [devices])
+ const columns: ColumnsType = [
+ {
+ title: 'IP / Hostname',
+ key: 'identity',
+ render: (_, row) => (
+
+ {row.ip}
+
+ {row.hostname ?? ''}
+
+
+ ),
+ },
+ {
+ title: '',
+ dataIndex: 'category',
+ key: 'category',
+ width: 130,
+ },
+ {
+ title: 'Ping',
+ key: 'ping',
+ width: 190,
+ render: (_, row) => (
+
+ {row.ping_status}
+
+ {row.ping_checked_at ? dayjs(row.ping_checked_at).format('DD.MM HH:mm:ss') : ''}
+ {row.last_ping_ms != null ? ` ${row.last_ping_ms}ms` : ''}
+
+
+ ),
+ },
+ {
+ title: 'Uptime',
+ key: 'uptime',
+ width: 280,
+ render: (_, row) => {
+ const availability = availabilityByDevice.get(Number(row.device_id))
+
+ if (availabilityLoading) {
+ return ...
+ }
+ if (!availability?.points?.length) {
+ return
+ }
+
+ return (
+
+
+
+ {availability.availability != null ? `${availability.availability}%` : ''}
+
+
+ )
+ },
+ },
+ {
+ title: '',
+ key: 'freshness',
+ width: 110,
+ render: (_, row) => {formatAge(row.ping_age_sec)},
+ },
+ {
+ title: 'Disk',
+ key: 'disk',
+ width: 140,
+ render: (_, row) =>
+ row.disk_status ? (
+
+ {row.disk_status}
+ {row.disk_max_pct != null ? ` (${row.disk_max_pct}%)` : ''}
+
+ ) : (
+ ''
+ ),
+ },
+ {
+ title: '',
+ key: 'problem',
+ width: 100,
+ render: (_, row) => (row.has_problem ? : ),
+ },
+ ]
+
return (
} onClick={() => navigate('/home')}>
- Назад
+
- {object?.object_name ?? `Объект #${objectId}`}
+ {object?.object_name ?? ` #${objectId}`}
- {object?.allowed ? В мониторинге : Вне мониторинга}
+ {object?.allowed ? : }
health {object?.health_score ?? '-'}
- окно {window}
+ {window}
@@ -344,19 +299,19 @@ export function HomeObjectDetailPage() {
) : null}
- {expandedAvailability?.insufficient_data ? (
+ {availabilityData?.insufficient_data ? (
) : null}
-
+
- Устройств
+
{summary?.totals.devices ?? 0}
@@ -364,7 +319,7 @@ export function HomeObjectDetailPage() {
- Проблемных
+
{summary?.totals.problem_devices ?? 0}
@@ -380,16 +335,16 @@ export function HomeObjectDetailPage() {
- Offline
-
- {grouped.offline}
+ Disk warn/failed
+
+ {grouped.warn}
setWindow(v as DashboardAvailabilityWindow)}
options={[
- { value: '24h', label: '24ч' },
- { value: '3d', label: '3д' },
- { value: '7d', label: '7д' },
+ { value: '24h', label: '24' },
+ { value: '3d', label: '3' },
+ { value: '7d', label: '7' },
]}
style={{ width: 120 }}
/>
}
>
- {
- const key = Number(row.device_id)
- setExpandedKeys((prev) => {
- if (expanded) {
- if (prev.includes(key)) return prev
- return [...prev, key]
- }
- return prev.filter((x) => x !== key)
- })
- },
- expandedRowRender: (row: any) => (
-
- ),
- }}
- columns={[
- {
- title: 'IP / Hostname',
- key: 'identity',
- render: (_: unknown, row: any) => (
-
- {row.ip}
-
- {row.hostname ?? '—'}
-
-
- ),
- },
- { title: 'Категория', dataIndex: 'category', key: 'category', width: 120 },
- {
- title: 'Ping',
- key: 'ping',
- width: 190,
- render: (_: unknown, row: any) => (
-
- {row.ping_status}
-
- {row.ping_checked_at ? dayjs(row.ping_checked_at).format('DD.MM HH:mm:ss') : '—'}
- {row.last_ping_ms != null ? ` • ${row.last_ping_ms}ms` : ''}
-
-
- ),
- },
- {
- title: 'Freshness',
- key: 'freshness',
- width: 110,
- render: (_: unknown, row: any) => (
- {formatAge(row.ping_age_sec)}
- ),
- },
- {
- title: 'Disk',
- key: 'disk',
- width: 130,
- render: (_: unknown, row: any) =>
- row.disk_status ? (
-
- {row.disk_status}
- {row.disk_max_pct != null ? ` (${row.disk_max_pct}%)` : ''}
-
- ) : (
- '—'
- ),
- },
- {
- title: 'Проблема',
- key: 'problem',
- width: 100,
- render: (_: unknown, row: any) =>
- row.has_problem ? Да : Нет,
- },
- ]}
- />
+ {!grouped.rackGroups.length ? (
+
+ ) : (
+
+ {grouped.rackGroups.map((rackGroup) => (
+
+
+ loading={devicesLoading}
+ dataSource={rackGroup.rows}
+ rowKey="device_id"
+ size="small"
+ pagination={false}
+ scroll={{ x: 1300 }}
+ columns={columns}
+ />
+
+ ))}
+
+ )}
)
diff --git a/frontend/src/pages/HomeSettings.tsx b/frontend/src/pages/HomeSettings.tsx
index 50f0918..a0ef068 100644
--- a/frontend/src/pages/HomeSettings.tsx
+++ b/frontend/src/pages/HomeSettings.tsx
@@ -1,13 +1,29 @@
-import { ArrowLeftOutlined } from '@ant-design/icons'
-import { Alert, Button, Card, Checkbox, Collapse, Row, Col, Space, Tag, Typography, message } from 'antd'
+import { ArrowLeftOutlined } from '@ant-design/icons'
+import {
+ Alert,
+ Button,
+ Card,
+ Checkbox,
+ Col,
+ Collapse,
+ InputNumber,
+ Row,
+ Space,
+ Switch,
+ Tag,
+ Typography,
+ message,
+} from 'antd'
import dayjs from 'dayjs'
-import { useMemo } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useObjects } from '../api/objects'
import {
+ useDashboardChecks,
useDashboardMonitorStatus,
useDashboardPolicy,
useDashboardRunNow,
+ usePatchDashboardChecks,
usePatchDashboardObjectPolicy,
usePatchDashboardTagPolicy,
} from '../api/dashboard'
@@ -17,6 +33,12 @@ function cityKey(city: string | null): string {
return city && city.trim() ? city : 'Без города'
}
+function formatCheckTitle(checkKey: string): string {
+ if (checkKey === 'ping') return 'Ping'
+ if (checkKey === 'disk_usage') return 'Disk check'
+ return checkKey
+}
+
export function HomeSettingsPage() {
const navigate = useNavigate()
@@ -24,12 +46,28 @@ export function HomeSettingsPage() {
const { data: tags } = useTags()
const { data: policy } = useDashboardPolicy()
const { data: monitorStatus } = useDashboardMonitorStatus()
+ const { data: checksData, isLoading: checksLoading } = useDashboardChecks()
const runNow = useDashboardRunNow()
const patchTagPolicy = usePatchDashboardTagPolicy()
const patchObjectPolicy = usePatchDashboardObjectPolicy()
+ const patchChecks = usePatchDashboardChecks()
- const busy = runNow.isPending || patchTagPolicy.isPending || patchObjectPolicy.isPending
+ const [checkIntervalsMin, setCheckIntervalsMin] = useState>({})
+
+ useEffect(() => {
+ const next: Record = {}
+ for (const check of checksData?.checks ?? []) {
+ next[check.check_key] = Math.max(1, Math.round(check.interval_sec_default / 60))
+ }
+ setCheckIntervalsMin(next)
+ }, [checksData])
+
+ const busy =
+ runNow.isPending ||
+ patchTagPolicy.isPending ||
+ patchObjectPolicy.isPending ||
+ patchChecks.isPending
const objectPolicyMap = useMemo(() => {
const map = new Map()
@@ -48,7 +86,7 @@ export function HomeSettingsPage() {
for (const obj of objects ?? []) {
const key = cityKey(obj.city)
if (!map.has(key)) map.set(key, [])
- map.get(key)!.push(obj)
+ map.get(key)?.push(obj)
}
return Array.from(map.entries())
@@ -64,7 +102,7 @@ export function HomeSettingsPage() {
for (const tag of tags ?? []) {
const key = tag.tag_type && tag.tag_type.trim() ? tag.tag_type : 'other'
if (!map.has(key)) map.set(key, [])
- map.get(key)!.push(tag)
+ map.get(key)?.push(tag)
}
return Array.from(map.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
@@ -79,22 +117,17 @@ export function HomeSettingsPage() {
const setObjectEnabled = async (objectId: number, enabled: boolean) => {
try {
- await patchObjectPolicy.mutateAsync([
- { object_id: objectId, mode: enabled ? 'include' : 'exclude' },
- ])
+ await patchObjectPolicy.mutateAsync([{ object_id: objectId, mode: enabled ? 'include' : 'exclude' }])
} catch {
message.error('Не удалось обновить объект')
}
}
const setObjectsEnabled = async (objectIds: number[], enabled: boolean) => {
- if (objectIds.length === 0) return
+ if (!objectIds.length) return
try {
await patchObjectPolicy.mutateAsync(
- objectIds.map((objectId) => ({
- object_id: objectId,
- mode: enabled ? 'include' : 'exclude',
- }))
+ objectIds.map((objectId) => ({ object_id: objectId, mode: enabled ? 'include' : 'exclude' }))
)
message.success(`Обновлено объектов: ${objectIds.length}`)
} catch {
@@ -111,7 +144,7 @@ export function HomeSettingsPage() {
}
const setTagsEnabled = async (tagIds: number[], enabled: boolean) => {
- if (tagIds.length === 0) return
+ if (!tagIds.length) return
try {
await patchTagPolicy.mutateAsync(tagIds.map((tagId) => ({ tag_id: tagId, enabled })))
message.success(`Обновлено тегов: ${tagIds.length}`)
@@ -120,6 +153,25 @@ export function HomeSettingsPage() {
}
}
+ const setCheckEnabled = async (checkKey: string, enabled: boolean) => {
+ try {
+ await patchChecks.mutateAsync([{ check_key: checkKey, enabled }])
+ message.success(`${formatCheckTitle(checkKey)} ${enabled ? 'включен' : 'выключен'}`)
+ } catch {
+ message.error('Не удалось обновить check')
+ }
+ }
+
+ const saveCheckInterval = async (checkKey: string) => {
+ const minutes = Math.max(1, Math.round(checkIntervalsMin[checkKey] ?? 1))
+ try {
+ await patchChecks.mutateAsync([{ check_key: checkKey, interval_sec_default: minutes * 60 }])
+ message.success(`${formatCheckTitle(checkKey)}: интервал ${minutes} мин`)
+ } catch {
+ message.error('Не удалось обновить интервал')
+ }
+ }
+
const handleRunNow = async () => {
try {
await runNow.mutateAsync({ force: true })
@@ -134,7 +186,7 @@ export function HomeSettingsPage() {
const someObjectsEnabled = allObjectIds.some((id) => isObjectEnabled(id))
return (
-
+
@@ -147,24 +199,76 @@ export function HomeSettingsPage() {
-
-
-
+
Статус: {monitorStatus?.status ?? '-'}
{monitorStatus?.last_success_at
- ? ` · успешный запуск ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}`
+ ? ` • последний успех ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}`
: ''}
- {monitorStatus?.last_error && (
+ {monitorStatus?.last_error ? (
- )}
+ ) : null}
+
+
+ {(checksData?.checks ?? []).length === 0 ? (
+ void handleRunNow()}>
+ Инициализировать
+
+ }
+ />
+ ) : (
+
+ {(checksData?.checks ?? []).map((check) => (
+
+
+
+
+ {formatCheckTitle(check.check_key)}
+ {check.enabled ? 'ON' : 'OFF'}
+
+
+
+
+ Включен:
+ void setCheckEnabled(check.check_key, v)}
+ />
+
+
+
+
+ Интервал (мин):
+ setCheckIntervalsMin((prev) => ({ ...prev, [check.check_key]: Number(v) || 1 }))}
+ />
+
+
+
+
+
+ ))}
+
+ )}
+