фигня какая-то, надеюсь ниче не сломал

This commit is contained in:
dv 2026-05-05 14:57:54 +03:00
parent 5cb48d4883
commit ecb929d780
8 changed files with 669 additions and 587 deletions

View file

@ -87,12 +87,14 @@ async def object_availability(
object_id: int,
window: str = Query("24h"),
device_ids: list[int] | None = None,
compact: bool = Query(False),
_: User = Depends(get_current_user),
):
data = await dashboard_service.get_object_availability(
object_id,
window=window,
device_ids=device_ids,
compact=compact,
)
if data is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")

View file

@ -150,6 +150,8 @@ class DashboardHomeSummaryResponse(BaseModel):
class DashboardDeviceNOCItem(BaseModel):
device_id: int
object_id: int
rack_id: Optional[int] = None
rack_name: Optional[str] = None
ip: str
hostname: Optional[str] = None
category: str

View file

@ -19,6 +19,7 @@ from app.models.dashboard import (
)
from app.models.device import Device
from app.models.object import Object
from app.models.rack import Rack
from app.models.tag import Tag
from app.plugins.registry import action_registry
from app.services.database import AsyncSessionLocal
@ -659,6 +660,7 @@ async def request_manual_run(scope: dict[str, Any] | None, force: bool) -> tuple
async def list_checks_with_overrides() -> tuple[list[DashboardCheckDefinition], list[DashboardObjectCheckOverride]]:
await ensure_dashboard_bootstrap()
async with AsyncSessionLocal() as db:
checks = (await db.execute(select(DashboardCheckDefinition).order_by(DashboardCheckDefinition.check_key))).scalars().all()
overrides = (
@ -675,6 +677,7 @@ async def list_checks_with_overrides() -> tuple[list[DashboardCheckDefinition],
async def patch_checks(items: list[dict[str, Any]]) -> None:
if not items:
return
await ensure_dashboard_bootstrap()
async with AsyncSessionLocal() as db:
async with db.begin():
rows = (
@ -872,6 +875,7 @@ def _device_noc_item(
*,
now: datetime,
device: Device,
rack_name: str | None,
ping_state: DashboardDeviceLiveState | None,
disk_state: DashboardDeviceLiveState | None,
ping_interval_sec: int,
@ -903,6 +907,8 @@ def _device_noc_item(
return {
"device_id": device.id,
"object_id": device.object_id,
"rack_id": device.rack_id,
"rack_name": rack_name,
"ip": device.ip,
"hostname": device.hostname,
"category": device.category,
@ -922,6 +928,7 @@ def _device_noc_item(
async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]:
await ensure_dashboard_bootstrap()
now = datetime.now(timezone.utc)
scope = _scope_to_dict(scope)
@ -1005,6 +1012,7 @@ async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]:
async def get_object_devices(object_id: int, scope: dict[str, Any] | None) -> dict[str, Any] | None:
await ensure_dashboard_bootstrap()
now = datetime.now(timezone.utc)
scope = _scope_to_dict(scope)
@ -1020,6 +1028,15 @@ async def get_object_devices(object_id: int, scope: dict[str, Any] | None) -> di
.order_by(Device.category, Device.ip)
)
).scalars().all()
rack_name_by_id: dict[int, str] = {}
rack_ids = {d.rack_id for d in device_rows if d.rack_id is not None}
if rack_ids:
rack_rows = (
await db.execute(
select(Rack.id, Rack.name).where(Rack.id.in_(rack_ids))
)
).all()
rack_name_by_id = {rid: name for rid, name in rack_rows}
state_rows = []
if device_rows:
@ -1047,6 +1064,7 @@ async def get_object_devices(object_id: int, scope: dict[str, Any] | None) -> di
_device_noc_item(
now=now,
device=device,
rack_name=rack_name_by_id.get(device.rack_id) if device.rack_id else None,
ping_state=state_map.get((device.id, "ping")),
disk_state=state_map.get((device.id, "disk_usage")),
ping_interval_sec=ping_interval,
@ -1070,6 +1088,7 @@ async def get_object_devices(object_id: int, scope: dict[str, Any] | None) -> di
async def get_object_summary(object_id: int, scope: dict[str, Any] | None) -> dict[str, Any] | None:
await ensure_dashboard_bootstrap()
now = datetime.now(timezone.utc)
scope = _scope_to_dict(scope)
@ -1092,6 +1111,15 @@ async def get_object_summary(object_id: int, scope: dict[str, Any] | None) -> di
select(Device).where(Device.object_id == object_id, Device.is_active == True)
)
).scalars().all()
rack_name_by_id: dict[int, str] = {}
rack_ids = {d.rack_id for d in device_rows if d.rack_id is not None}
if rack_ids:
rack_rows = (
await db.execute(
select(Rack.id, Rack.name).where(Rack.id.in_(rack_ids))
)
).all()
rack_name_by_id = {rid: name for rid, name in rack_rows}
state_rows = []
if device_rows:
@ -1123,6 +1151,7 @@ async def get_object_summary(object_id: int, scope: dict[str, Any] | None) -> di
_device_noc_item(
now=now,
device=device,
rack_name=rack_name_by_id.get(device.rack_id) if device.rack_id else None,
ping_state=state_map.get((device.id, "ping")),
disk_state=state_map.get((device.id, "disk_usage")),
ping_interval_sec=ping_interval,
@ -1231,7 +1260,9 @@ async def get_object_availability(
*,
window: str,
device_ids: list[int] | None = None,
compact: bool = False,
) -> dict[str, Any] | None:
await ensure_dashboard_bootstrap()
now = datetime.now(timezone.utc)
window_key = window if window in WINDOW_SECONDS else "24h"
start_at = now - timedelta(seconds=_window_seconds(window_key))
@ -1303,7 +1334,7 @@ async def get_object_availability(
"last_status": last_row.status if last_row else None,
"last_ping_ms": last_row.ping_ms if last_row else None,
"series": _series_from_rows(d_rows, start_at=start_at, end_at=now, bucket_sec=bucket_sec),
"intervals": _intervals_from_rows(d_rows, end_at=now),
"intervals": [] if compact else _intervals_from_rows(d_rows, end_at=now),
}
)

View file

@ -1,4 +1,4 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { Navigate, Route, Routes } from 'react-router-dom'
import { AppLayout } from './components/AppLayout'
import { AdminPage } from './pages/Admin'
import { AlertsPage } from './pages/Alerts'
@ -37,24 +37,24 @@ export default function App() {
<Route path="home/settings" element={<HomeSettingsPage />} />
<Route path="home/objects/:id" element={<HomeObjectDetailPage />} />
{/* Инвентарь */}
{/* Inventory */}
<Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} />
<Route path="inventory/objects/:id" element={<ObjectDetailPage defaultMode="inventory" />} />
<Route path="inventory/objects/:id/devices/:deviceId" element={<DeviceDetailPage />} />
<Route path="inventory/objects/:id/snapshots" element={<SnapshotsPage />} />
{/* Работа */}
{/* Work */}
<Route path="work/objects" element={<ObjectsPage mode="work" />} />
<Route path="work/objects/:id" element={<ObjectDetailPage defaultMode="work" />} />
<Route path="work/devices" element={<WorkDevicesPage />} />
{/* Общие */}
{/* Common */}
<Route path="alerts" element={<AlertsPage />} />
<Route path="tasks" element={<TasksPage />} />
<Route path="tasks/:id" element={<TaskDetailPage />} />
<Route path="admin" element={<AdminPage />} />
{/* Старые URL — редирект для совместимости */}
{/* Legacy URLs for backward compatibility */}
<Route path="objects" element={<Navigate to="/inventory/objects" replace />} />
<Route path="objects/:id" element={<Navigate to="/inventory/objects" replace />} />
</Route>

View file

@ -93,6 +93,8 @@ export interface DashboardHomeSummary {
export interface DashboardDeviceNOCItem {
device_id: number
object_id: number
rack_id: number | null
rack_name: string | null
ip: string
hostname: string | null
category: string
@ -210,14 +212,15 @@ export const useDashboardObjectDevices = (objectId: number, scope: DashboardScop
export const useDashboardObjectAvailability = (
objectId: number,
window: DashboardAvailabilityWindow,
deviceIds?: number[]
deviceIds?: number[],
compact?: boolean
) =>
useQuery({
queryKey: ['dashboard-object-availability', objectId, window, deviceIds],
queryKey: ['dashboard-object-availability', objectId, window, deviceIds, compact],
queryFn: () =>
api
.get<DashboardObjectAvailabilityResponse>(`/api/v1/dashboard/object/${objectId}/availability`, {
params: { window, device_ids: deviceIds },
params: { window, device_ids: deviceIds, compact: compact ?? false },
})
.then((r) => r.data),
enabled:

View file

@ -1,3 +1,4 @@
import { useQueryClient } from '@tanstack/react-query'
import {
Alert,
Button,
@ -10,14 +11,12 @@ import {
Select,
Space,
Statistic,
Table,
Tag,
Typography,
message,
} from 'antd'
import dayjs from 'dayjs'
import { useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useObjects, useObjectsMeta } from '../api/objects'
import { useDashboardHomeSummary, useDashboardMonitorStatus, useDashboardRunNow, type DashboardScope } from '../api/dashboard'
@ -25,22 +24,10 @@ import { useTags } from '../api/tags'
import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE'
function formatAge(seconds: number | null | undefined): string {
if (seconds == null) return '-'
if (seconds < 60) return `${seconds}с`
if (seconds < 3600) return `${Math.floor(seconds / 60)}м`
return `${Math.floor(seconds / 3600)}ч ${Math.floor((seconds % 3600) / 60)}м`
}
function reasonLabel(reason: string): string {
const map: Record<string, string> = {
object_include_policy: 'Включен политикой объекта',
object_exclude_policy: 'Исключен политикой объекта',
inherit_default: 'Наследование по умолчанию',
tag_policy_enabled: 'Включен политикой тегов',
tag_policy_disabled: 'Исключен политикой тегов',
scope_filtered: 'Вне текущего фильтра',
}
return map[reason] ?? reason
if (seconds == null) return '—'
if (seconds < 60) return `${seconds}ñ`
if (seconds < 3600) return `${Math.floor(seconds / 60)}ì`
return `${Math.floor(seconds / 3600)}÷ ${Math.floor((seconds % 3600) / 60)}ì`
}
function scopeToSearch(scope: DashboardScope): string {
@ -60,6 +47,18 @@ function healthColor(score: number | null | undefined): string {
return 'red'
}
function runStatusColor(status: string | undefined): string {
if (status === 'running') return 'processing'
if (status === 'error') return 'red'
if (status === 'idle') return 'blue'
return 'default'
}
function toPct(numerator: number, denominator: number): number | null {
if (denominator <= 0) return null
return Math.round((numerator / denominator) * 1000) / 10
}
export function HomePage() {
const qc = useQueryClient()
const navigate = useNavigate()
@ -73,10 +72,7 @@ export function HomePage() {
const [objectIds, setObjectIds] = useState<number[]>([])
const [tagIds, setTagIds] = useState<number[]>([])
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()
@ -95,97 +91,125 @@ export function HomePage() {
const handleRunNow = async () => {
try {
await runNow.mutateAsync({ ...scope, force: true })
message.success('Мониторинг запущен')
message.success('Ìîíèòîðèíã çàïóùåí')
} catch {
message.error('Не удалось запустить мониторинг')
message.error('Íå óäàëîñü çàïóñòèòü ìîíèòîðèíã')
}
}
const totals = summary?.totals ?? { objects: 0, devices: 0, allowed_objects: 0, excluded_objects: 0 }
const included = summary?.included_objects ?? []
const pingTotals = useMemo(() => {
let online = 0
let offline = 0
let unknown = 0
for (const obj of summary?.included_objects ?? []) {
const cnt = obj.checks?.ping?.status_counts ?? {}
online += cnt.online ?? 0
offline += cnt.offline ?? 0
unknown += cnt.unknown ?? 0
}
const all = online + offline + unknown
return { online, offline, unknown, all }
const pingInterval = useMemo(() => {
return summary?.checks_meta?.find((c) => c.check_key === 'ping')?.interval_sec_default ?? 300
}, [summary])
const topIncidents = useMemo(() => {
return [...(summary?.problem_objects ?? [])]
.sort((a, b) => {
const ah = a.health_score ?? 100
const bh = b.health_score ?? 100
if (ah !== bh) return ah - bh
return (b.device_count ?? 0) - (a.device_count ?? 0)
const offlineTotal = useMemo(
() => included.reduce((acc, obj) => acc + (obj.checks?.ping?.status_counts?.offline ?? 0), 0),
[included]
)
const unknownTotal = useMemo(
() => included.reduce((acc, obj) => acc + (obj.checks?.ping?.status_counts?.unknown ?? 0), 0),
[included]
)
const diskWarnTotal = useMemo(
() => included.reduce((acc, obj) => acc + (obj.checks?.disk_usage?.status_counts?.warn ?? 0), 0),
[included]
)
const diskFailedTotal = useMemo(
() => 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 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 }
})
.slice(0, 6)
.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 objectColumns = [
{
title: 'Объект',
key: 'object_name',
render: (_: unknown, row: any) => (
<Space direction="vertical" size={0}>
<Button
type="link"
style={{ padding: 0, textAlign: 'left' }}
onClick={() => navigate(`/home/objects/${row.object_id}${scopeToSearch(scope)}`)}
>
{row.object_name}
</Button>
<Space size={6} wrap>
{row.city ? <Tag>{row.city}</Tag> : null}
{row.client ? <Tag color="blue">{row.client}</Tag> : null}
</Space>
</Space>
),
},
{
title: 'Причина',
key: 'reason',
render: (_: unknown, row: any) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{reasonLabel(row.inclusion_reason)}
</Typography.Text>
),
},
{
title: 'Устройств',
dataIndex: 'device_count',
key: 'device_count',
width: 90,
},
{
title: 'Health',
key: 'health',
width: 90,
render: (_: unknown, row: any) =>
row.health_score == null ? '-' : <Tag color={healthColor(row.health_score)}>{row.health_score}</Tag>,
},
]
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 signalCount = offlineTotal + unknownTotal + diskWarnTotal + diskFailedTotal
return (
<div style={{ background: '#f4f6fb', margin: -12, padding: 12, borderRadius: 12 }}>
<div style={{ background: '#f5f7fb', margin: -12, padding: 12, borderRadius: 12 }}>
<Row justify="space-between" align="middle" gutter={[8, 8]} style={{ marginBottom: 12 }}>
<Col>
<Typography.Title level={4} style={{ margin: 0 }}>
Light NOC Dashboard
NOC Dashboard
</Typography.Title>
<Typography.Text type="secondary">Оперативный обзор доступности и инцидентов</Typography.Text>
<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>
</Space>
</Col>
@ -195,7 +219,7 @@ export function HomePage() {
<Row gutter={[8, 8]}>
<Col xs={24} sm={12} lg={6}>
<Select
placeholder="Город"
placeholder="Ãîðîä"
allowClear
style={{ width: '100%' }}
options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))}
@ -205,7 +229,7 @@ export function HomePage() {
</Col>
<Col xs={24} sm={12} lg={6}>
<Select
placeholder="Клиент"
placeholder="Êëèåíò"
allowClear
style={{ width: '100%' }}
options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))}
@ -216,7 +240,7 @@ export function HomePage() {
<Col xs={24} sm={12} lg={6}>
<Select
mode="multiple"
placeholder="Объекты"
placeholder="Îáúåêòû"
allowClear
style={{ width: '100%' }}
value={objectIds}
@ -227,7 +251,7 @@ export function HomePage() {
<Col xs={24} sm={12} lg={6}>
<Select
mode="multiple"
placeholder="Теги"
placeholder="Òåãè"
allowClear
style={{ width: '100%' }}
value={tagIds}
@ -239,154 +263,178 @@ export function HomePage() {
</Card>
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
<Col xs={24} lg={8}>
<Card size="small" style={{ borderRadius: 12, height: '100%' }}>
<Typography.Text type="secondary">Состояние мониторинга</Typography.Text>
<div style={{ marginTop: 6 }}>
<Tag color={monitorStatus?.status === 'running' ? 'processing' : monitorStatus?.status === 'error' ? 'red' : 'blue'}>
{monitorStatus?.status ?? '-'}
</Tag>
<Typography.Text style={{ marginLeft: 6 }}>
{monitorStatus?.last_success_at
? `Последний успех: ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}`
: 'Нет успешных запусков'}
</Typography.Text>
</div>
{monitorStatus?.last_error ? (
<Alert type="error" showIcon style={{ marginTop: 10 }} message={monitorStatus.last_error} />
) : null}
</Card>
</Col>
<Col xs={24} lg={16}>
<Row gutter={[12, 12]}>
<Col xs={12} md={6}>
<Card size="small" style={{ borderRadius: 12 }} loading={isLoading}>
<Statistic title="Объекты" value={totals.objects} />
</Card>
</Col>
<Col xs={12} md={6}>
<Card size="small" style={{ borderRadius: 12 }} loading={isLoading}>
<Statistic title="В мониторинге" value={summary?.included_count ?? totals.allowed_objects} />
</Card>
</Col>
<Col xs={12} md={6}>
<Card size="small" style={{ borderRadius: 12 }} loading={isLoading}>
<Statistic title="Вне мониторинга" value={summary?.excluded_count ?? totals.excluded_objects} />
</Card>
</Col>
<Col xs={12} md={6}>
<Card size="small" style={{ borderRadius: 12 }} loading={isLoading}>
<Statistic title="Устройства" value={totals.devices} />
</Card>
</Col>
</Row>
</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={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>
</Row>
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
<Col xs={24} lg={12}>
<Card
size="small"
title="Ping status (в мониторинге)"
style={{ borderRadius: 12, height: '100%' }}
loading={isLoading}
>
{pingTotals.all === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Нет данных" />
) : (
<Space direction="vertical" style={{ width: '100%' }} size={8}>
<div>
<Typography.Text type="secondary">Online</Typography.Text>
<Progress percent={Math.round((pingTotals.online / pingTotals.all) * 100)} status="success" />
</div>
<div>
<Typography.Text type="secondary">Offline</Typography.Text>
<Progress percent={Math.round((pingTotals.offline / pingTotals.all) * 100)} strokeColor="#cf1322" />
</div>
<div>
<Typography.Text type="secondary">Unknown</Typography.Text>
<Progress percent={Math.round((pingTotals.unknown / pingTotals.all) * 100)} strokeColor="#8c8c8c" />
</div>
</Space>
)}
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
size="small"
title="Приоритетные инциденты"
style={{ borderRadius: 12, height: '100%' }}
loading={isLoading}
>
{topIncidents.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Критичных объектов нет" />
) : (
<List
dataSource={topIncidents}
renderItem={(item) => (
<List.Item
actions={[
<Button
key="open"
type="link"
onClick={() => navigate(`/home/objects/${item.object_id}${scopeToSearch(scope)}`)}
>
Открыть
</Button>,
]}
>
<List.Item.Meta
title={
<Space wrap size={8}>
<span>{item.object_name}</span>
<Tag color={healthColor(item.health_score)}>health {item.health_score ?? '-'}</Tag>
</Space>
}
description={
<Space split={<span></span>} wrap>
<span>{item.city ?? '—'}</span>
<span>{item.client ?? '—'}</span>
<span>{item.device_count} устройств</span>
<span>Ping age: {formatAge(item.checks?.ping?.max_age_sec)}</span>
</Space>
}
/>
</List.Item>
)}
/>
)}
</Card>
</Col>
</Row>
{monitorStatus?.last_error ? <Alert type="error" showIcon style={{ marginBottom: 12 }} message={monitorStatus.last_error} /> : null}
<Row gutter={[12, 12]}>
<Col xs={24} xl={12}>
<Card title={`В мониторинге (${summary?.included_count ?? 0})`} size="small" style={{ borderRadius: 12 }}>
<Table
size="small"
dataSource={summary?.included_objects ?? []}
rowKey="object_id"
columns={objectColumns}
loading={isLoading}
pagination={{ pageSize: 6 }}
scroll={{ x: 800 }}
/>
</Card>
<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="Êðèòè÷íûõ îáúåêòîâ íåò" />
) : (
<List
dataSource={incidentObjects.slice(0, 12)}
renderItem={(item) => {
const ping = item.checks?.ping
const disk = item.checks?.disk_usage?.status_counts ?? {}
const offline = ping?.status_counts?.offline ?? 0
const unknown = ping?.status_counts?.unknown ?? 0
const diskWarn = disk.warn ?? 0
const diskFailed = disk.failed ?? 0
return (
<List.Item
actions={[
<Button key="open" type="link" onClick={() => navigate(`/home/objects/${item.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>
}
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>
}
/>
</List.Item>
)
}}
/>
)}
</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={12}>
<Card title={`Вне мониторинга (${summary?.excluded_count ?? 0})`} size="small" style={{ borderRadius: 12 }}>
<Table
size="small"
dataSource={summary?.excluded_objects ?? []}
rowKey="object_id"
columns={objectColumns}
loading={isLoading}
pagination={{ pageSize: 6 }}
scroll={{ x: 800 }}
/>
</Card>
<Col xs={24} xl={8}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Card title="SLA è ïîêðûòèå" size="small" style={{ borderRadius: 12 }}>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<div>
<Row justify="space-between">
<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'} />
</div>
<div>
<Row justify="space-between">
<Typography.Text>Ïîêðûòèå ìîíèòîðèíãîì</Typography.Text>
<Typography.Text strong>{coveragePct != null ? `${coveragePct}%` : '—'}</Typography.Text>
</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'} />
</div>
<Space wrap>
<Tag color="green">online: {onlineTotal}</Tag>
<Tag color="red">offline: {offlineTotal}</Tag>
<Tag color="orange">unknown: {unknownTotal}</Tag>
<Tag>ïî ping: {monitoredTotal}</Tag>
</Space>
</Space>
</Card>
<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 ? (
<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>,
]}
>
<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(' • ')}
</Typography.Text>
</List.Item>
)}
/>
)}
</Card>
</Space>
</Col>
</Row>
</div>

View file

@ -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<string, string> = {
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 (
<svg viewBox={`0 0 ${viewW} ${viewH}`} style={{ width: '100%', height: 160, display: 'block' }}>
<rect x={0} y={0} width={viewW} height={viewH} rx={10} fill="#f8fafc" />
{[0, 25, 50, 75, 100].map((v) => {
const y = padY + ((100 - v) / 100) * (viewH - padY * 2)
return (
<g key={v}>
<line x1={padX} y1={y} x2={viewW - padX} y2={y} stroke="#d9e2ef" strokeWidth={1} />
<text x={2} y={y + 4} fill="#8da1b8" fontSize={9}>
{v}%
</text>
</g>
)
})}
{polyline ? <polyline points={polyline} fill="none" stroke="#1677ff" strokeWidth={2.4} /> : null}
</svg>
)
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 <Typography.Text type="secondary">Недостаточно интервалов для отображения</Typography.Text>
function UptimeBars({ points, compact = false }: { points: DashboardAvailabilityPoint[]; compact?: boolean }) {
if (!points.length) {
return <Typography.Text type="secondary">Íåò òî÷åê â âûáðàííîì îêíå</Typography.Text>
}
return (
<div style={{ marginTop: 8 }}>
<div
style={{
position: 'relative',
height: 18,
borderRadius: 8,
background: '#eef2f8',
overflow: 'hidden',
border: '1px solid #dbe4f0',
}}
>
{bars.map((bar) => (
<div>
<div style={{ display: 'flex', gap: compact ? 1 : 2, alignItems: 'center', height: compact ? 14 : 20 }}>
{points.map((point, idx) => (
<div
key={bar.key}
title={`${bar.status}${formatDuration(bar.duration)}`}
key={`${point.bucket_start}-${idx}`}
title={`${dayjs(point.bucket_start).format('DD.MM HH:mm')}${point.availability_pct ?? '—'}%`}
style={{
position: 'absolute',
left: `${bar.left}%`,
width: `${bar.width}%`,
top: 0,
bottom: 0,
background: bar.status === 'online' ? '#52c41a' : '#f5222d',
opacity: 0.9,
flex: 1,
minWidth: compact ? 3 : 4,
height: compact ? 9 : 14,
borderRadius: compact ? 2 : 3,
background: uptimeColor(point),
border: '1px solid rgba(0,0,0,0.08)',
}}
/>
))}
</div>
<Row justify="space-between" style={{ marginTop: 4 }}>
<Row justify="space-between" style={{ marginTop: compact ? 4 : 6 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{dayjs(startAt).format('DD.MM HH:mm')}
{dayjs(points[0].bucket_start).format('DD.MM HH:mm')}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{dayjs(endAt).format('DD.MM HH:mm')}
{dayjs(points[points.length - 1].bucket_end).format('DD.MM HH:mm')}
</Typography.Text>
</Row>
{!compact ? (
<Space size={12} style={{ marginTop: 6 }} wrap>
<Space size={4}><span style={{ width: 10, height: 10, borderRadius: 2, background: '#52c41a', display: 'inline-block' }} /><Typography.Text type="secondary" style={{ fontSize: 12 }}>online</Typography.Text></Space>
<Space size={4}><span style={{ width: 10, height: 10, borderRadius: 2, background: '#ff4d4f', display: 'inline-block' }} /><Typography.Text type="secondary" style={{ fontSize: 12 }}>offline</Typography.Text></Space>
<Space size={4}><span style={{ width: 10, height: 10, borderRadius: 2, background: '#d9d9d9', display: 'inline-block' }} /><Typography.Text type="secondary" style={{ fontSize: 12 }}>íåò äàííûõ</Typography.Text></Space>
</Space>
) : null}
</div>
)
}
function DeviceExpandedRow({
data,
isLoading,
startAt,
endAt,
}: {
data?: DashboardDeviceAvailability
isLoading: boolean
startAt?: string
endAt?: string
}) {
if (isLoading) {
return <Typography.Text type="secondary">Загрузка истории доступности...</Typography.Text>
}
if (!data) {
return <Typography.Text type="secondary">Недостаточно данных по устройству в выбранном окне</Typography.Text>
}
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 (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Row gutter={[12, 12]}>
<Col xs={24} lg={8}>
<Card size="small" bordered>
<Typography.Text type="secondary">Availability</Typography.Text>
<Typography.Title level={4} style={{ margin: 0 }}>
{data.availability_pct != null ? `${data.availability_pct}%` : '—'}
</Typography.Title>
<Typography.Text type="secondary">Samples: {data.samples}</Typography.Text>
</Card>
</Col>
<Col xs={24} lg={8}>
<Card size="small" bordered>
<Typography.Text type="secondary">Последний статус</Typography.Text>
<div style={{ marginTop: 4 }}>
<Tag color={statusColor(data.last_status)}>{data.last_status ?? 'unknown'}</Tag>
</div>
<Typography.Text type="secondary">
{data.last_checked_at ? dayjs(data.last_checked_at).format('DD.MM HH:mm:ss') : '—'}
</Typography.Text>
</Card>
</Col>
<Col xs={24} lg={8}>
<Card size="small" bordered>
<Typography.Text type="secondary">Последний ping</Typography.Text>
<Typography.Title level={4} style={{ margin: 0 }}>
{data.last_ping_ms != null ? `${data.last_ping_ms} ms` : '—'}
</Typography.Title>
<Typography.Text type="secondary">Online: {data.online} Offline: {data.offline}</Typography.Text>
</Card>
</Col>
</Row>
<Card size="small" title="Линия доступности" bordered>
{data.series.length ? (
<AvailabilityMiniChart points={data.series} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Нет точек для графика" />
)}
</Card>
<Card size="small" title="Таймлайн online/offline" bordered>
{startAt && endAt ? (
<StatusTimeline intervals={data.intervals} startAt={startAt} endAt={endAt} />
) : (
<Typography.Text type="secondary">Диапазон окна недоступен</Typography.Text>
)}
</Card>
<Card size="small" title="Последние переключения" bordered>
{recentSwitches.length ? (
<Space wrap>
{recentSwitches.map((sw, idx) => (
<Tag key={`${sw.started_at}-${idx}`} color={sw.status === 'online' ? 'green' : 'red'}>
{sw.status} {dayjs(sw.started_at).format('DD.MM HH:mm')} ({formatDuration(sw.duration_sec)})
</Tag>
))}
</Space>
) : (
<Typography.Text type="secondary">Переключений пока нет</Typography.Text>
)}
</Card>
</Space>
)
}
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<DashboardAvailabilityWindow>('24h')
const [expandedKeys, setExpandedKeys] = useState<number[]>([])
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<number, DashboardDeviceAvailability>()
for (const row of expandedAvailability?.devices ?? []) {
map.set(row.device_id, row)
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,
})
}
return map
}, [expandedAvailability])
}, [availabilityData])
const grouped = useMemo(() => {
const rows = devices?.devices ?? []
let online = 0
let offline = 0
let warn = 0
const byRack = new Map<string, DashboardDeviceNOCItem[]>()
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<DashboardDeviceNOCItem> = [
{
title: 'IP / Hostname',
key: 'identity',
render: (_, row) => (
<Space direction="vertical" size={0}>
<code>{row.ip}</code>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.hostname ?? '—'}
</Typography.Text>
</Space>
),
},
{
title: 'Êàòåãîðèÿ',
dataIndex: 'category',
key: 'category',
width: 130,
},
{
title: 'Ping',
key: 'ping',
width: 190,
render: (_, row) => (
<Space direction="vertical" size={0}>
<Tag color={statusColor(row.ping_status)}>{row.ping_status}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{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` : ''}
</Typography.Text>
</Space>
),
},
{
title: 'Uptime',
key: 'uptime',
width: 280,
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>
}
return (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<UptimeBars points={availability.points} compact />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{availability.availability != null ? `${availability.availability}%` : '—'}
</Typography.Text>
</Space>
)
},
},
{
title: 'Ñâåæåñòü',
key: 'freshness',
width: 110,
render: (_, row) => <Tag color={row.ping_fresh ? 'green' : 'orange'}>{formatAge(row.ping_age_sec)}</Tag>,
},
{
title: 'Disk',
key: 'disk',
width: 140,
render: (_, row) =>
row.disk_status ? (
<Tag color={statusColor(row.disk_status)}>
{row.disk_status}
{row.disk_max_pct != null ? ` (${row.disk_max_pct}%)` : ''}
</Tag>
) : (
'—'
),
},
{
title: 'Ïðîáëåìà',
key: 'problem',
width: 100,
render: (_, row) => (row.has_problem ? <Tag color="red">Äà</Tag> : <Tag color="green">Íåò</Tag>),
},
]
return (
<div style={{ background: '#f4f6fb', margin: -12, padding: 12, borderRadius: 12 }}>
<Row justify="space-between" align="middle" gutter={[8, 8]} style={{ marginBottom: 12 }}>
<Col>
<Space wrap>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/home')}>
Назад
Íàçàä
</Button>
<Typography.Title level={4} style={{ margin: 0 }}>
{object?.object_name ?? `Объект #${objectId}`}
{object?.object_name ?? `Îáúåêò #${objectId}`}
</Typography.Title>
</Space>
</Col>
<Col>
<Space wrap>
{object?.allowed ? <Tag color="green">В мониторинге</Tag> : <Tag color="red">Вне мониторинга</Tag>}
{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>
<Tag color="blue">îêíî {window}</Tag>
</Space>
</Col>
</Row>
@ -344,19 +299,19 @@ export function HomeObjectDetailPage() {
</Typography.Text>
) : null}
{expandedAvailability?.insufficient_data ? (
{availabilityData?.insufficient_data ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="По части устройств данных пока недостаточно. Это нормально для новых объектов."
message="Ïî ÷àñòè óñòðîéñòâ äàííûõ ïîêà íåäîñòàòî÷íî. Äëÿ íîâûõ îáúåêòîâ ýòî íîðìàëüíî."
/>
) : null}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<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.Text type="secondary">Óñòðîéñòâ</Typography.Text>
<Typography.Title level={3} style={{ margin: 0 }}>
{summary?.totals.devices ?? 0}
</Typography.Title>
@ -364,7 +319,7 @@ export function HomeObjectDetailPage() {
</Col>
<Col xs={12} md={6}>
<Card size="small" loading={summaryLoading} style={{ borderRadius: 12 }}>
<Typography.Text type="secondary">Проблемных</Typography.Text>
<Typography.Text type="secondary">Ïðîáëåìíûõ</Typography.Text>
<Typography.Title level={3} style={{ margin: 0 }}>
{summary?.totals.problem_devices ?? 0}
</Typography.Title>
@ -380,16 +335,16 @@ export function HomeObjectDetailPage() {
</Col>
<Col xs={12} md={6}>
<Card size="small" loading={devicesLoading} style={{ borderRadius: 12 }}>
<Typography.Text type="secondary">Offline</Typography.Text>
<Typography.Title level={3} style={{ margin: 0, color: '#cf1322' }}>
{grouped.offline}
<Typography.Text type="secondary">Disk warn/failed</Typography.Text>
<Typography.Title level={3} style={{ margin: 0, color: '#d46b08' }}>
{grouped.warn}
</Typography.Title>
</Card>
</Col>
</Row>
<Card
title="Устройства объекта"
title="Óñòðîéñòâà îáúåêòà"
size="small"
style={{ borderRadius: 12 }}
extra={
@ -397,101 +352,38 @@ export function HomeObjectDetailPage() {
value={window}
onChange={(v) => 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 }}
/>
}
>
<Table
loading={devicesLoading}
dataSource={devices?.devices ?? []}
rowKey="device_id"
size="small"
pagination={{ pageSize: 20 }}
scroll={{ x: 1100 }}
expandable={{
expandedRowKeys: expandedKeys,
onExpand: (expanded, row: any) => {
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) => (
<DeviceExpandedRow
data={availabilityByDevice.get(Number(row.device_id))}
isLoading={expandedAvailLoading || expandedAvailFetching}
startAt={expandedAvailability?.start_at}
endAt={expandedAvailability?.end_at}
/>
),
}}
columns={[
{
title: 'IP / Hostname',
key: 'identity',
render: (_: unknown, row: any) => (
<Space direction="vertical" size={0}>
<code>{row.ip}</code>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.hostname ?? '—'}
</Typography.Text>
</Space>
),
},
{ title: 'Категория', dataIndex: 'category', key: 'category', width: 120 },
{
title: 'Ping',
key: 'ping',
width: 190,
render: (_: unknown, row: any) => (
<Space direction="vertical" size={0}>
<Tag color={statusColor(row.ping_status)}>{row.ping_status}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{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` : ''}
</Typography.Text>
</Space>
),
},
{
title: 'Freshness',
key: 'freshness',
width: 110,
render: (_: unknown, row: any) => (
<Tag color={row.ping_fresh ? 'green' : 'orange'}>{formatAge(row.ping_age_sec)}</Tag>
),
},
{
title: 'Disk',
key: 'disk',
width: 130,
render: (_: unknown, row: any) =>
row.disk_status ? (
<Tag color={statusColor(row.disk_status)}>
{row.disk_status}
{row.disk_max_pct != null ? ` (${row.disk_max_pct}%)` : ''}
</Tag>
) : (
'—'
),
},
{
title: 'Проблема',
key: 'problem',
width: 100,
render: (_: unknown, row: any) =>
row.has_problem ? <Tag color="red">Да</Tag> : <Tag color="green">Нет</Tag>,
},
]}
/>
{!grouped.rackGroups.length ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Óñòðîéñòâà íå íàéäåíû" />
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{grouped.rackGroups.map((rackGroup) => (
<Card
key={rackGroup.rack}
size="small"
title={`${rackGroup.rack} (${rackGroup.rows.length})`}
style={{ borderRadius: 10 }}
>
<Table<DashboardDeviceNOCItem>
loading={devicesLoading}
dataSource={rackGroup.rows}
rowKey="device_id"
size="small"
pagination={false}
scroll={{ x: 1300 }}
columns={columns}
/>
</Card>
))}
</Space>
)}
</Card>
</div>
)

View file

@ -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<Record<string, number>>({})
useEffect(() => {
const next: Record<string, number> = {}
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<number, 'inherit' | 'include' | 'exclude'>()
@ -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 (
<div>
<div style={{ background: '#f5f7fb', margin: -12, padding: 12, borderRadius: 12 }}>
<Row justify="space-between" align="middle" gutter={[8, 8]} style={{ marginBottom: 12 }}>
<Col>
<Space wrap>
@ -147,24 +199,76 @@ export function HomeSettingsPage() {
</Space>
</Col>
<Col>
<Space wrap>
<Button type="primary" onClick={handleRunNow} loading={runNow.isPending}>
Запустить сейчас
</Button>
</Space>
<Button type="primary" onClick={handleRunNow} loading={runNow.isPending}>
Запустить сейчас
</Button>
</Col>
</Row>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Статус: {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')}`
: ''}
</Typography.Text>
{monitorStatus?.last_error && (
{monitorStatus?.last_error ? (
<Alert type="error" showIcon style={{ marginBottom: 12 }} message={monitorStatus.last_error} />
)}
) : null}
<Card title="Плагины мониторинга" style={{ marginBottom: 16 }} loading={checksLoading}>
{(checksData?.checks ?? []).length === 0 ? (
<Alert
type="warning"
showIcon
message="Плагины мониторинга пока не загружены"
description="Ожидаются как минимум ping и disk_usage. Нажмите «Инициализировать», чтобы создать checks и обновить список."
action={
<Button size="small" type="primary" loading={runNow.isPending} onClick={() => void handleRunNow()}>
Инициализировать
</Button>
}
/>
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{(checksData?.checks ?? []).map((check) => (
<Card key={check.check_key} size="small" bordered>
<Row gutter={[12, 12]} align="middle">
<Col xs={24} md={8}>
<Space>
<Typography.Text strong>{formatCheckTitle(check.check_key)}</Typography.Text>
<Tag color={check.enabled ? 'green' : 'default'}>{check.enabled ? 'ON' : 'OFF'}</Tag>
</Space>
</Col>
<Col xs={24} md={7}>
<Space>
<Typography.Text type="secondary">Включен:</Typography.Text>
<Switch
checked={check.enabled}
disabled={busy}
onChange={(v) => void setCheckEnabled(check.check_key, v)}
/>
</Space>
</Col>
<Col xs={24} md={9}>
<Space wrap>
<Typography.Text type="secondary">Интервал (мин):</Typography.Text>
<InputNumber
min={1}
value={checkIntervalsMin[check.check_key]}
onChange={(v) => setCheckIntervalsMin((prev) => ({ ...prev, [check.check_key]: Number(v) || 1 }))}
/>
<Button disabled={busy} onClick={() => void saveCheckInterval(check.check_key)}>
Сохранить
</Button>
</Space>
</Col>
</Row>
</Card>
))}
</Space>
)}
</Card>
<Card title="Объекты" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: '100%' }} size={12}>