фигня какая-то, надеюсь ниче не сломал
This commit is contained in:
parent
5cb48d4883
commit
ecb929d780
8 changed files with 669 additions and 587 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
.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)
|
||||
})
|
||||
.slice(0, 6)
|
||||
}, [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={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>
|
||||
|
||||
{monitorStatus?.last_error ? <Alert type="error" showIcon style={{ marginBottom: 12 }} message={monitorStatus.last_error} /> : null}
|
||||
|
||||
<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>
|
||||
</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="Критичных объектов нет" />
|
||||
<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={topIncidents}
|
||||
renderItem={(item) => (
|
||||
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 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>
|
||||
<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>{item.city ?? '—'}</span>
|
||||
<span>{item.client ?? '—'}</span>
|
||||
<span>{item.device_count} устройств</span>
|
||||
<span>Ping age: {formatAge(item.checks?.ping?.max_age_sec)}</span>
|
||||
<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={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>
|
||||
|
||||
<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>
|
||||
<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 }}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.reasons.join(' • ')}
|
||||
</Typography.Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,216 +56,58 @@ 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>
|
||||
<div style={{ display: 'flex', gap: compact ? 1 : 2, alignItems: 'center', height: compact ? 14 : 20 }}>
|
||||
{points.map((point, idx) => (
|
||||
<div
|
||||
key={`${point.bucket_start}-${idx}`}
|
||||
title={`${dayjs(point.bucket_start).format('DD.MM HH:mm')} • ${point.availability_pct ?? '—'}%`}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: 18,
|
||||
borderRadius: 8,
|
||||
background: '#eef2f8',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #dbe4f0',
|
||||
}}
|
||||
>
|
||||
{bars.map((bar) => (
|
||||
<div
|
||||
key={bar.key}
|
||||
title={`${bar.status} • ${formatDuration(bar.duration)}`}
|
||||
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>
|
||||
</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>
|
||||
{!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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
return { online, offline }
|
||||
|
||||
const rackName = row.rack_name && row.rack_name.trim() ? row.rack_name : 'Áåç ñòîéêè'
|
||||
if (!byRack.has(rackName)) byRack.set(rackName, [])
|
||||
byRack.get(rackName)?.push(row)
|
||||
}
|
||||
|
||||
const rackGroups = Array.from(byRack.entries())
|
||||
.sort((a, b) => {
|
||||
if (a[0] === 'Áåç ñòîéêè') return 1
|
||||
if (b[0] === 'Áåç ñòîéêè') return -1
|
||||
return a[0].localeCompare(b[0])
|
||||
})
|
||||
.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
|
||||
{!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={devices?.devices ?? []}
|
||||
dataSource={rackGroup.rows}
|
||||
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}
|
||||
pagination={false}
|
||||
scroll={{ x: 1300 }}
|
||||
columns={columns}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
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>
|
||||
</Card>
|
||||
))}
|
||||
</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>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</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}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue