diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 63cbf00..443255f 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -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") diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index 57bfaeb..332fc19 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -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 diff --git a/backend/app/services/dashboard_monitor.py b/backend/app/services/dashboard_monitor.py index 86e8e65..3e5b0f7 100644 --- a/backend/app/services/dashboard_monitor.py +++ b/backend/app/services/dashboard_monitor.py @@ -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), } ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d5a74c1..7ab410c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> - {/* Инвентарь */} + {/* Inventory */} } /> } /> } /> } /> - {/* Работа */} + {/* Work */} } /> } /> } /> - {/* Общие */} + {/* Common */} } /> } /> } /> } /> - {/* Старые URL — редирект для совместимости */} + {/* Legacy URLs for backward compatibility */} } /> } /> diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 683b8c5..839d633 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -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(`/api/v1/dashboard/object/${objectId}/availability`, { - params: { window, device_ids: deviceIds }, + params: { window, device_ids: deviceIds, compact: compact ?? false }, }) .then((r) => r.data), enabled: diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index fafe598..e017520 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -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 = { - 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([]) const [tagIds, setTagIds] = useState([]) - 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) => ( - - - - {row.city ? {row.city} : null} - {row.client ? {row.client} : null} - - - ), - }, - { - title: 'Причина', - key: 'reason', - render: (_: unknown, row: any) => ( - - {reasonLabel(row.inclusion_reason)} - - ), - }, - { - title: 'Устройств', - dataIndex: 'device_count', - key: 'device_count', - width: 90, - }, - { - title: 'Health', - key: 'health', - width: 90, - render: (_: unknown, row: any) => - row.health_score == null ? '-' : {row.health_score}, - }, - ] + const actionQueue = useMemo(() => { + return included + .map((obj) => { + const ping = obj.checks?.ping?.status_counts ?? {} + const offline = ping.offline ?? 0 + const unknown = ping.unknown ?? 0 + const disk = obj.checks?.disk_usage?.status_counts ?? {} + const diskWarn = disk.warn ?? 0 + const diskFailed = disk.failed ?? 0 + const agePing = obj.checks?.ping?.max_age_sec ?? 0 + + let score = offline * 6 + unknown * 3 + diskFailed * 4 + diskWarn * 2 + if (agePing > pingInterval * 3) score += 2 + if (agePing > pingInterval * 6) score += 3 + + const reasons: string[] = [] + if (offline > 0) reasons.push(`offline: ${offline}`) + if (unknown > 0) reasons.push(`unknown: ${unknown}`) + if (diskFailed > 0 || diskWarn > 0) reasons.push(`disk warn/failed: ${diskWarn}/${diskFailed}`) + if (agePing > 0) reasons.push(`age ping: ${formatAge(agePing)}`) + + return { obj, score, reasons } + }) + .filter((x) => x.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 10) + }, [included, pingInterval]) + + const lastCounts = monitorStatus?.last_counts as Record | null + + const monitoredTotal = onlineTotal + offlineTotal + unknownTotal + const availabilityPct = toPct(onlineTotal, onlineTotal + offlineTotal) + const coveragePct = toPct(summary?.included_count ?? totals.allowed_objects, totals.objects) + const freshnessPct = toPct(Math.max(0, included.length - stalePingObjects.length), included.length) + const signalCount = offlineTotal + unknownTotal + diskWarnTotal + diskFailedTotal return ( -
+
- Light NOC Dashboard + NOC Dashboard - Оперативный обзор доступности и инцидентов + SLA, , - + @@ -195,7 +219,7 @@ export function HomePage() { ({ value: c, label: c }))} @@ -216,7 +240,7 @@ export function HomePage() { - - - Состояние мониторинга -
- - {monitorStatus?.status ?? '-'} - - - {monitorStatus?.last_success_at - ? `Последний успех: ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}` - : 'Нет успешных запусков'} - -
- {monitorStatus?.last_error ? ( - - ) : null} -
- - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + +
- - - - {pingTotals.all === 0 ? ( - - ) : ( - -
- Online - -
-
- Offline - -
-
- Unknown - -
-
- )} -
- - - - - {topIncidents.length === 0 ? ( - - ) : ( - ( - navigate(`/home/objects/${item.object_id}${scopeToSearch(scope)}`)} - > - Открыть - , - ]} - > - - {item.object_name} - health {item.health_score ?? '-'} - - } - description={ - •} wrap> - {item.city ?? '—'} - {item.client ?? '—'} - {item.device_count} устройств - Ping age: {formatAge(item.checks?.ping?.max_age_sec)} - - } - /> - - )} - /> - )} - - -
+ {monitorStatus?.last_error ? : null} - - - - + + + + {incidentObjects.length === 0 ? ( + + ) : ( + { + const ping = item.checks?.ping + const disk = item.checks?.disk_usage?.status_counts ?? {} + const offline = ping?.status_counts?.offline ?? 0 + const unknown = ping?.status_counts?.unknown ?? 0 + const diskWarn = disk.warn ?? 0 + const diskFailed = disk.failed ?? 0 + return ( + navigate(`/home/objects/${item.object_id}${scopeToSearch(scope)}`)}> + + , + ]} + > + + {item.object_name} + {item.city ?? ''} + {item.client ?? ''} + health {item.health_score ?? ''} + + } + description={ + } wrap> + offline: {offline} + unknown: {unknown} + disk warn/failed: {diskWarn}/{diskFailed} + : {item.device_count} + age ping: {formatAge(ping?.max_age_sec)} + + } + /> + + ) + }} + /> + )} + + + + {stalePingObjects.length === 0 ? ( + + ) : ( + ( + navigate(`/home/objects/${item.obj.object_id}${scopeToSearch(scope)}`)}>]}> + + + {item.obj.object_name} + age: {formatAge(item.pingAge)} + + + offline/unknown: {item.offline}/{item.unknown} + + + + )} + /> + )} + + - - -
- + + + + + +
+ + (online) + {availabilityPct != null ? `${availabilityPct}%` : ''} + + +
+ +
+ + + {coveragePct != null ? `${coveragePct}%` : ''} + + +
+ +
+ + + {freshnessPct != null ? `${freshnessPct}%` : ''} + + +
+ + + online: {onlineTotal} + offline: {offlineTotal} + unknown: {unknownTotal} + ping: {monitoredTotal} + +
+
+ + + + {monitorStatus?.status ?? '-'} + {summary?.included_count ?? totals.allowed_objects} + {monitorStatus?.last_success_at ? dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss') : ''} + + {lastCounts ? ( + + checks: {lastCounts.checks_executed ?? 0} + failed: {lastCounts.failed ?? 0} + checked devices: {lastCounts.devices_checked ?? 0} + skipped not due: {lastCounts.skipped_not_due ?? 0} + + ) : null} + + + + + {actionQueue.length === 0 ? ( + + ) : ( + ( + navigate(`/home/objects/${row.obj.object_id}${scopeToSearch(scope)}`)}> + + , + ]} + > + +
+ {row.obj.object_name} + + + prio {row.score} + + + + {row.reasons.join(' ')} + + + )} + /> + )} + + 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 ( - - - {[0, 25, 50, 75, 100].map((v) => { - const y = padY + ((100 - v) / 100) * (viewH - padY * 2) - return ( - - - - {v}% - - - ) - })} - {polyline ? : null} - - ) +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 (
- {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 }))} + /> + + + + + + ))} + + )} +