фигня какая-то, надеюсь ниче не сломал
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,
|
object_id: int,
|
||||||
window: str = Query("24h"),
|
window: str = Query("24h"),
|
||||||
device_ids: list[int] | None = None,
|
device_ids: list[int] | None = None,
|
||||||
|
compact: bool = Query(False),
|
||||||
_: User = Depends(get_current_user),
|
_: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
data = await dashboard_service.get_object_availability(
|
data = await dashboard_service.get_object_availability(
|
||||||
object_id,
|
object_id,
|
||||||
window=window,
|
window=window,
|
||||||
device_ids=device_ids,
|
device_ids=device_ids,
|
||||||
|
compact=compact,
|
||||||
)
|
)
|
||||||
if data is None:
|
if data is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,8 @@ class DashboardHomeSummaryResponse(BaseModel):
|
||||||
class DashboardDeviceNOCItem(BaseModel):
|
class DashboardDeviceNOCItem(BaseModel):
|
||||||
device_id: int
|
device_id: int
|
||||||
object_id: int
|
object_id: int
|
||||||
|
rack_id: Optional[int] = None
|
||||||
|
rack_name: Optional[str] = None
|
||||||
ip: str
|
ip: str
|
||||||
hostname: Optional[str] = None
|
hostname: Optional[str] = None
|
||||||
category: str
|
category: str
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from app.models.dashboard import (
|
||||||
)
|
)
|
||||||
from app.models.device import Device
|
from app.models.device import Device
|
||||||
from app.models.object import Object
|
from app.models.object import Object
|
||||||
|
from app.models.rack import Rack
|
||||||
from app.models.tag import Tag
|
from app.models.tag import Tag
|
||||||
from app.plugins.registry import action_registry
|
from app.plugins.registry import action_registry
|
||||||
from app.services.database import AsyncSessionLocal
|
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]]:
|
async def list_checks_with_overrides() -> tuple[list[DashboardCheckDefinition], list[DashboardObjectCheckOverride]]:
|
||||||
|
await ensure_dashboard_bootstrap()
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
checks = (await db.execute(select(DashboardCheckDefinition).order_by(DashboardCheckDefinition.check_key))).scalars().all()
|
checks = (await db.execute(select(DashboardCheckDefinition).order_by(DashboardCheckDefinition.check_key))).scalars().all()
|
||||||
overrides = (
|
overrides = (
|
||||||
|
|
@ -675,6 +677,7 @@ async def list_checks_with_overrides() -> tuple[list[DashboardCheckDefinition],
|
||||||
async def patch_checks(items: list[dict[str, Any]]) -> None:
|
async def patch_checks(items: list[dict[str, Any]]) -> None:
|
||||||
if not items:
|
if not items:
|
||||||
return
|
return
|
||||||
|
await ensure_dashboard_bootstrap()
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
rows = (
|
rows = (
|
||||||
|
|
@ -872,6 +875,7 @@ def _device_noc_item(
|
||||||
*,
|
*,
|
||||||
now: datetime,
|
now: datetime,
|
||||||
device: Device,
|
device: Device,
|
||||||
|
rack_name: str | None,
|
||||||
ping_state: DashboardDeviceLiveState | None,
|
ping_state: DashboardDeviceLiveState | None,
|
||||||
disk_state: DashboardDeviceLiveState | None,
|
disk_state: DashboardDeviceLiveState | None,
|
||||||
ping_interval_sec: int,
|
ping_interval_sec: int,
|
||||||
|
|
@ -903,6 +907,8 @@ def _device_noc_item(
|
||||||
return {
|
return {
|
||||||
"device_id": device.id,
|
"device_id": device.id,
|
||||||
"object_id": device.object_id,
|
"object_id": device.object_id,
|
||||||
|
"rack_id": device.rack_id,
|
||||||
|
"rack_name": rack_name,
|
||||||
"ip": device.ip,
|
"ip": device.ip,
|
||||||
"hostname": device.hostname,
|
"hostname": device.hostname,
|
||||||
"category": device.category,
|
"category": device.category,
|
||||||
|
|
@ -922,6 +928,7 @@ def _device_noc_item(
|
||||||
|
|
||||||
|
|
||||||
async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]:
|
async def get_home_summary(scope: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
await ensure_dashboard_bootstrap()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
scope = _scope_to_dict(scope)
|
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:
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
scope = _scope_to_dict(scope)
|
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)
|
.order_by(Device.category, Device.ip)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).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 = []
|
state_rows = []
|
||||||
if device_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(
|
_device_noc_item(
|
||||||
now=now,
|
now=now,
|
||||||
device=device,
|
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")),
|
ping_state=state_map.get((device.id, "ping")),
|
||||||
disk_state=state_map.get((device.id, "disk_usage")),
|
disk_state=state_map.get((device.id, "disk_usage")),
|
||||||
ping_interval_sec=ping_interval,
|
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:
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
scope = _scope_to_dict(scope)
|
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)
|
select(Device).where(Device.object_id == object_id, Device.is_active == True)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).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 = []
|
state_rows = []
|
||||||
if device_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(
|
_device_noc_item(
|
||||||
now=now,
|
now=now,
|
||||||
device=device,
|
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")),
|
ping_state=state_map.get((device.id, "ping")),
|
||||||
disk_state=state_map.get((device.id, "disk_usage")),
|
disk_state=state_map.get((device.id, "disk_usage")),
|
||||||
ping_interval_sec=ping_interval,
|
ping_interval_sec=ping_interval,
|
||||||
|
|
@ -1231,7 +1260,9 @@ async def get_object_availability(
|
||||||
*,
|
*,
|
||||||
window: str,
|
window: str,
|
||||||
device_ids: list[int] | None = None,
|
device_ids: list[int] | None = None,
|
||||||
|
compact: bool = False,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
|
await ensure_dashboard_bootstrap()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
window_key = window if window in WINDOW_SECONDS else "24h"
|
window_key = window if window in WINDOW_SECONDS else "24h"
|
||||||
start_at = now - timedelta(seconds=_window_seconds(window_key))
|
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_status": last_row.status if last_row else None,
|
||||||
"last_ping_ms": last_row.ping_ms 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),
|
"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 { AppLayout } from './components/AppLayout'
|
||||||
import { AdminPage } from './pages/Admin'
|
import { AdminPage } from './pages/Admin'
|
||||||
import { AlertsPage } from './pages/Alerts'
|
import { AlertsPage } from './pages/Alerts'
|
||||||
|
|
@ -37,24 +37,24 @@ export default function App() {
|
||||||
<Route path="home/settings" element={<HomeSettingsPage />} />
|
<Route path="home/settings" element={<HomeSettingsPage />} />
|
||||||
<Route path="home/objects/:id" element={<HomeObjectDetailPage />} />
|
<Route path="home/objects/:id" element={<HomeObjectDetailPage />} />
|
||||||
|
|
||||||
{/* Инвентарь */}
|
{/* Inventory */}
|
||||||
<Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} />
|
<Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} />
|
||||||
<Route path="inventory/objects/:id" element={<ObjectDetailPage defaultMode="inventory" />} />
|
<Route path="inventory/objects/:id" element={<ObjectDetailPage defaultMode="inventory" />} />
|
||||||
<Route path="inventory/objects/:id/devices/:deviceId" element={<DeviceDetailPage />} />
|
<Route path="inventory/objects/:id/devices/:deviceId" element={<DeviceDetailPage />} />
|
||||||
<Route path="inventory/objects/:id/snapshots" element={<SnapshotsPage />} />
|
<Route path="inventory/objects/:id/snapshots" element={<SnapshotsPage />} />
|
||||||
|
|
||||||
{/* Работа */}
|
{/* Work */}
|
||||||
<Route path="work/objects" element={<ObjectsPage mode="work" />} />
|
<Route path="work/objects" element={<ObjectsPage mode="work" />} />
|
||||||
<Route path="work/objects/:id" element={<ObjectDetailPage defaultMode="work" />} />
|
<Route path="work/objects/:id" element={<ObjectDetailPage defaultMode="work" />} />
|
||||||
<Route path="work/devices" element={<WorkDevicesPage />} />
|
<Route path="work/devices" element={<WorkDevicesPage />} />
|
||||||
|
|
||||||
{/* Общие */}
|
{/* Common */}
|
||||||
<Route path="alerts" element={<AlertsPage />} />
|
<Route path="alerts" element={<AlertsPage />} />
|
||||||
<Route path="tasks" element={<TasksPage />} />
|
<Route path="tasks" element={<TasksPage />} />
|
||||||
<Route path="tasks/:id" element={<TaskDetailPage />} />
|
<Route path="tasks/:id" element={<TaskDetailPage />} />
|
||||||
<Route path="admin" element={<AdminPage />} />
|
<Route path="admin" element={<AdminPage />} />
|
||||||
|
|
||||||
{/* Старые URL — редирект для совместимости */}
|
{/* Legacy URLs for backward compatibility */}
|
||||||
<Route path="objects" element={<Navigate to="/inventory/objects" replace />} />
|
<Route path="objects" element={<Navigate to="/inventory/objects" replace />} />
|
||||||
<Route path="objects/:id" element={<Navigate to="/inventory/objects" replace />} />
|
<Route path="objects/:id" element={<Navigate to="/inventory/objects" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,8 @@ export interface DashboardHomeSummary {
|
||||||
export interface DashboardDeviceNOCItem {
|
export interface DashboardDeviceNOCItem {
|
||||||
device_id: number
|
device_id: number
|
||||||
object_id: number
|
object_id: number
|
||||||
|
rack_id: number | null
|
||||||
|
rack_name: string | null
|
||||||
ip: string
|
ip: string
|
||||||
hostname: string | null
|
hostname: string | null
|
||||||
category: string
|
category: string
|
||||||
|
|
@ -210,14 +212,15 @@ export const useDashboardObjectDevices = (objectId: number, scope: DashboardScop
|
||||||
export const useDashboardObjectAvailability = (
|
export const useDashboardObjectAvailability = (
|
||||||
objectId: number,
|
objectId: number,
|
||||||
window: DashboardAvailabilityWindow,
|
window: DashboardAvailabilityWindow,
|
||||||
deviceIds?: number[]
|
deviceIds?: number[],
|
||||||
|
compact?: boolean
|
||||||
) =>
|
) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['dashboard-object-availability', objectId, window, deviceIds],
|
queryKey: ['dashboard-object-availability', objectId, window, deviceIds, compact],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api
|
api
|
||||||
.get<DashboardObjectAvailabilityResponse>(`/api/v1/dashboard/object/${objectId}/availability`, {
|
.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),
|
.then((r) => r.data),
|
||||||
enabled:
|
enabled:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
|
@ -10,14 +11,12 @@ import {
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Statistic,
|
Statistic,
|
||||||
Table,
|
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useObjects, useObjectsMeta } from '../api/objects'
|
import { useObjects, useObjectsMeta } from '../api/objects'
|
||||||
import { useDashboardHomeSummary, useDashboardMonitorStatus, useDashboardRunNow, type DashboardScope } from '../api/dashboard'
|
import { useDashboardHomeSummary, useDashboardMonitorStatus, useDashboardRunNow, type DashboardScope } from '../api/dashboard'
|
||||||
|
|
@ -25,22 +24,10 @@ import { useTags } from '../api/tags'
|
||||||
import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE'
|
import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE'
|
||||||
|
|
||||||
function formatAge(seconds: number | null | undefined): string {
|
function formatAge(seconds: number | null | undefined): string {
|
||||||
if (seconds == null) return '-'
|
if (seconds == null) return '—'
|
||||||
if (seconds < 60) return `${seconds}с`
|
if (seconds < 60) return `${seconds}ñ`
|
||||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}м`
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}ì`
|
||||||
return `${Math.floor(seconds / 3600)}ч ${Math.floor((seconds % 3600) / 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scopeToSearch(scope: DashboardScope): string {
|
function scopeToSearch(scope: DashboardScope): string {
|
||||||
|
|
@ -60,6 +47,18 @@ function healthColor(score: number | null | undefined): string {
|
||||||
return 'red'
|
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() {
|
export function HomePage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -73,10 +72,7 @@ export function HomePage() {
|
||||||
const [objectIds, setObjectIds] = useState<number[]>([])
|
const [objectIds, setObjectIds] = useState<number[]>([])
|
||||||
const [tagIds, setTagIds] = useState<number[]>([])
|
const [tagIds, setTagIds] = useState<number[]>([])
|
||||||
|
|
||||||
const scope = useMemo(
|
const scope = useMemo(() => ({ city, client, object_ids: objectIds, tag_ids: tagIds }), [city, client, objectIds, tagIds])
|
||||||
() => ({ city, client, object_ids: objectIds, tag_ids: tagIds }),
|
|
||||||
[city, client, objectIds, tagIds]
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: summary, isLoading } = useDashboardHomeSummary(scope)
|
const { data: summary, isLoading } = useDashboardHomeSummary(scope)
|
||||||
const { data: monitorStatus } = useDashboardMonitorStatus()
|
const { data: monitorStatus } = useDashboardMonitorStatus()
|
||||||
|
|
@ -95,97 +91,125 @@ export function HomePage() {
|
||||||
const handleRunNow = async () => {
|
const handleRunNow = async () => {
|
||||||
try {
|
try {
|
||||||
await runNow.mutateAsync({ ...scope, force: true })
|
await runNow.mutateAsync({ ...scope, force: true })
|
||||||
message.success('Мониторинг запущен')
|
message.success('Ìîíèòîðèíã çàïóùåí')
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Не удалось запустить мониторинг')
|
message.error('Íå óäàëîñü çàïóñòèòü ìîíèòîðèíã')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totals = summary?.totals ?? { objects: 0, devices: 0, allowed_objects: 0, excluded_objects: 0 }
|
const totals = summary?.totals ?? { objects: 0, devices: 0, allowed_objects: 0, excluded_objects: 0 }
|
||||||
|
const included = summary?.included_objects ?? []
|
||||||
|
|
||||||
const pingTotals = useMemo(() => {
|
const pingInterval = useMemo(() => {
|
||||||
let online = 0
|
return summary?.checks_meta?.find((c) => c.check_key === 'ping')?.interval_sec_default ?? 300
|
||||||
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 }
|
|
||||||
}, [summary])
|
}, [summary])
|
||||||
|
|
||||||
const topIncidents = useMemo(() => {
|
const offlineTotal = useMemo(
|
||||||
return [...(summary?.problem_objects ?? [])]
|
() => included.reduce((acc, obj) => acc + (obj.checks?.ping?.status_counts?.offline ?? 0), 0),
|
||||||
.sort((a, b) => {
|
[included]
|
||||||
const ah = a.health_score ?? 100
|
)
|
||||||
const bh = b.health_score ?? 100
|
const unknownTotal = useMemo(
|
||||||
if (ah !== bh) return ah - bh
|
() => included.reduce((acc, obj) => acc + (obj.checks?.ping?.status_counts?.unknown ?? 0), 0),
|
||||||
return (b.device_count ?? 0) - (a.device_count ?? 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])
|
}, [summary])
|
||||||
|
|
||||||
const objectColumns = [
|
const actionQueue = useMemo(() => {
|
||||||
{
|
return included
|
||||||
title: 'Объект',
|
.map((obj) => {
|
||||||
key: 'object_name',
|
const ping = obj.checks?.ping?.status_counts ?? {}
|
||||||
render: (_: unknown, row: any) => (
|
const offline = ping.offline ?? 0
|
||||||
<Space direction="vertical" size={0}>
|
const unknown = ping.unknown ?? 0
|
||||||
<Button
|
const disk = obj.checks?.disk_usage?.status_counts ?? {}
|
||||||
type="link"
|
const diskWarn = disk.warn ?? 0
|
||||||
style={{ padding: 0, textAlign: 'left' }}
|
const diskFailed = disk.failed ?? 0
|
||||||
onClick={() => navigate(`/home/objects/${row.object_id}${scopeToSearch(scope)}`)}
|
const agePing = obj.checks?.ping?.max_age_sec ?? 0
|
||||||
>
|
|
||||||
{row.object_name}
|
let score = offline * 6 + unknown * 3 + diskFailed * 4 + diskWarn * 2
|
||||||
</Button>
|
if (agePing > pingInterval * 3) score += 2
|
||||||
<Space size={6} wrap>
|
if (agePing > pingInterval * 6) score += 3
|
||||||
{row.city ? <Tag>{row.city}</Tag> : null}
|
|
||||||
{row.client ? <Tag color="blue">{row.client}</Tag> : null}
|
const reasons: string[] = []
|
||||||
</Space>
|
if (offline > 0) reasons.push(`offline: ${offline}`)
|
||||||
</Space>
|
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)}`)
|
||||||
{
|
|
||||||
title: 'Причина',
|
return { obj, score, reasons }
|
||||||
key: 'reason',
|
})
|
||||||
render: (_: unknown, row: any) => (
|
.filter((x) => x.score > 0)
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
.sort((a, b) => b.score - a.score)
|
||||||
{reasonLabel(row.inclusion_reason)}
|
.slice(0, 10)
|
||||||
</Typography.Text>
|
}, [included, pingInterval])
|
||||||
),
|
|
||||||
},
|
const lastCounts = monitorStatus?.last_counts as Record<string, number> | null
|
||||||
{
|
|
||||||
title: 'Устройств',
|
const monitoredTotal = onlineTotal + offlineTotal + unknownTotal
|
||||||
dataIndex: 'device_count',
|
const availabilityPct = toPct(onlineTotal, onlineTotal + offlineTotal)
|
||||||
key: 'device_count',
|
const coveragePct = toPct(summary?.included_count ?? totals.allowed_objects, totals.objects)
|
||||||
width: 90,
|
const freshnessPct = toPct(Math.max(0, included.length - stalePingObjects.length), included.length)
|
||||||
},
|
const signalCount = offlineTotal + unknownTotal + diskWarnTotal + diskFailedTotal
|
||||||
{
|
|
||||||
title: 'Health',
|
|
||||||
key: 'health',
|
|
||||||
width: 90,
|
|
||||||
render: (_: unknown, row: any) =>
|
|
||||||
row.health_score == null ? '-' : <Tag color={healthColor(row.health_score)}>{row.health_score}</Tag>,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
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 }}>
|
<Row justify="space-between" align="middle" gutter={[8, 8]} style={{ marginBottom: 12 }}>
|
||||||
<Col>
|
<Col>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
Light NOC Dashboard
|
NOC Dashboard
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Text type="secondary">Оперативный обзор доступности и инцидентов</Typography.Text>
|
<Typography.Text type="secondary">SLA, ïîêðûòèå ìîíèòîðèíãîì, èíöèäåíòû è î÷åðåäü äåéñòâèé</Typography.Text>
|
||||||
</Col>
|
</Col>
|
||||||
<Col>
|
<Col>
|
||||||
<Space wrap>
|
<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 type="primary" onClick={handleRunNow} loading={runNow.isPending}>
|
||||||
Запустить сейчас
|
Çàïóñòèòü ñåé÷àñ
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -195,7 +219,7 @@ export function HomePage() {
|
||||||
<Row gutter={[8, 8]}>
|
<Row gutter={[8, 8]}>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Город"
|
placeholder="Ãîðîä"
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))}
|
options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))}
|
||||||
|
|
@ -205,7 +229,7 @@ export function HomePage() {
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Клиент"
|
placeholder="Êëèåíò"
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))}
|
options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))}
|
||||||
|
|
@ -216,7 +240,7 @@ export function HomePage() {
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
placeholder="Объекты"
|
placeholder="Îáúåêòû"
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
value={objectIds}
|
value={objectIds}
|
||||||
|
|
@ -227,7 +251,7 @@ export function HomePage() {
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
placeholder="Теги"
|
placeholder="Òåãè"
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
value={tagIds}
|
value={tagIds}
|
||||||
|
|
@ -239,154 +263,178 @@ export function HomePage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||||
<Col xs={24} lg={8}>
|
<Col xs={12} md={8} lg={4}><Card size="small" loading={isLoading}><Statistic title="Îáúåêòîâ â ñêîóïå" value={totals.objects} /></Card></Col>
|
||||||
<Card size="small" style={{ borderRadius: 12, height: '100%' }}>
|
<Col xs={12} md={8} lg={4}><Card size="small" loading={isLoading}><Statistic title="Óñòðîéñòâ" value={totals.devices} /></Card></Col>
|
||||||
<Typography.Text type="secondary">Состояние мониторинга</Typography.Text>
|
<Col xs={12} md={8} lg={4}><Card size="small" loading={isLoading}><Statistic title="Èíöèäåíòíûõ îáúåêòîâ" value={incidentObjects.length} /></Card></Col>
|
||||||
<div style={{ marginTop: 6 }}>
|
<Col xs={12} md={8} lg={4}><Card size="small" loading={isLoading}><Statistic title="Ñèãíàëîâ ïðîáëåì" value={signalCount} /></Card></Col>
|
||||||
<Tag color={monitorStatus?.status === 'running' ? 'processing' : monitorStatus?.status === 'error' ? 'red' : 'blue'}>
|
<Col xs={12} md={8} lg={4}><Card size="small" loading={isLoading}><Statistic title="Ïðîñðî÷åííûå îáúåêòû" value={stalePingObjects.length} /></Card></Col>
|
||||||
{monitorStatus?.status ?? '-'}
|
<Col xs={12} md={8} lg={4}><Card size="small" loading={isLoading}><Statistic title="Ñðåäíèé health" value={avgHealth ?? '—'} /></Card></Col>
|
||||||
</Tag>
|
</Row>
|
||||||
<Typography.Text style={{ marginLeft: 6 }}>
|
|
||||||
{monitorStatus?.last_success_at
|
{monitorStatus?.last_error ? <Alert type="error" showIcon style={{ marginBottom: 12 }} message={monitorStatus.last_error} /> : null}
|
||||||
? `Последний успех: ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}`
|
|
||||||
: 'Нет успешных запусков'}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
{monitorStatus?.last_error ? (
|
|
||||||
<Alert type="error" showIcon style={{ marginTop: 10 }} message={monitorStatus.last_error} />
|
|
||||||
) : null}
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} lg={16}>
|
|
||||||
<Row gutter={[12, 12]}>
|
<Row gutter={[12, 12]}>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={24} xl={16}>
|
||||||
<Card size="small" style={{ borderRadius: 12 }} loading={isLoading}>
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
<Statistic title="Объекты" value={totals.objects} />
|
<Card title="Îáúåêòû ñ èíöèäåíòàìè" size="small" style={{ borderRadius: 12 }}>
|
||||||
</Card>
|
{incidentObjects.length === 0 ? (
|
||||||
</Col>
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Êðèòè÷íûõ îáúåêòîâ íåò" />
|
||||||
<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="Критичных объектов нет" />
|
|
||||||
) : (
|
) : (
|
||||||
<List
|
<List
|
||||||
dataSource={topIncidents}
|
dataSource={incidentObjects.slice(0, 12)}
|
||||||
renderItem={(item) => (
|
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
|
<List.Item
|
||||||
actions={[
|
actions={[
|
||||||
<Button
|
<Button key="open" type="link" onClick={() => navigate(`/home/objects/${item.object_id}${scopeToSearch(scope)}`)}>
|
||||||
key="open"
|
Îòêðûòü
|
||||||
type="link"
|
|
||||||
onClick={() => navigate(`/home/objects/${item.object_id}${scopeToSearch(scope)}`)}
|
|
||||||
>
|
|
||||||
Открыть
|
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
title={
|
title={
|
||||||
<Space wrap size={8}>
|
<Space wrap size={8}>
|
||||||
<span>{item.object_name}</span>
|
<Typography.Text strong>{item.object_name}</Typography.Text>
|
||||||
<Tag color={healthColor(item.health_score)}>health {item.health_score ?? '-'}</Tag>
|
<Tag>{item.city ?? '—'}</Tag>
|
||||||
|
<Tag color="blue">{item.client ?? '—'}</Tag>
|
||||||
|
<Tag color={healthColor(item.health_score)}>health {item.health_score ?? '—'}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<Space split={<span>•</span>} wrap>
|
<Space split={<span>•</span>} wrap>
|
||||||
<span>{item.city ?? '—'}</span>
|
<span>offline: {offline}</span>
|
||||||
<span>{item.client ?? '—'}</span>
|
<span>unknown: {unknown}</span>
|
||||||
<span>{item.device_count} устройств</span>
|
<span>disk warn/failed: {diskWarn}/{diskFailed}</span>
|
||||||
<span>Ping age: {formatAge(item.checks?.ping?.max_age_sec)}</span>
|
<span>óñòðîéñòâ: {item.device_count}</span>
|
||||||
|
<span>age ping: {formatAge(ping?.max_age_sec)}</span>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</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>
|
</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>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
<Row gutter={[12, 12]}>
|
{row.reasons.join(' • ')}
|
||||||
<Col xs={24} xl={12}>
|
</Typography.Text>
|
||||||
<Card title={`В мониторинге (${summary?.included_count ?? 0})`} size="small" style={{ borderRadius: 12 }}>
|
</List.Item>
|
||||||
<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 }}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,29 @@
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
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 { 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 dayjs from 'dayjs'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
useDashboardObjectAvailability,
|
useDashboardObjectAvailability,
|
||||||
useDashboardObjectDevices,
|
useDashboardObjectDevices,
|
||||||
useDashboardObjectSummary,
|
useDashboardObjectSummary,
|
||||||
type DashboardAvailabilityInterval,
|
|
||||||
type DashboardAvailabilityPoint,
|
type DashboardAvailabilityPoint,
|
||||||
type DashboardAvailabilityWindow,
|
type DashboardAvailabilityWindow,
|
||||||
type DashboardDeviceAvailability,
|
type DashboardDeviceNOCItem,
|
||||||
type DashboardScope,
|
type DashboardScope,
|
||||||
} from '../api/dashboard'
|
} from '../api/dashboard'
|
||||||
import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE'
|
import { useDashboardMonitorSSE } from '../hooks/useDashboardMonitorSSE'
|
||||||
|
|
||||||
function reasonLabel(reason: string): string {
|
function reasonLabel(reason: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
object_include_policy: 'Включен политикой объекта',
|
object_include_policy: 'Âêëþ÷åí ïîëèòèêîé îáúåêòà',
|
||||||
object_exclude_policy: 'Исключен политикой объекта',
|
object_exclude_policy: 'Èñêëþ÷åí ïîëèòèêîé îáúåêòà',
|
||||||
inherit_default: 'Наследование по умолчанию',
|
inherit_default: 'Íàñëåäîâàíèå ïî óìîë÷àíèþ',
|
||||||
tag_policy_enabled: 'Включен политикой тегов',
|
tag_policy_enabled: 'Âêëþ÷åí ïîëèòèêîé òåãîâ',
|
||||||
tag_policy_disabled: 'Исключен политикой тегов',
|
tag_policy_disabled: 'Èñêëþ÷åí ïîëèòèêîé òåãîâ',
|
||||||
scope_filtered: 'Вне текущего фильтра',
|
scope_filtered: 'Âíå òåêóùåãî ôèëüòðà',
|
||||||
}
|
}
|
||||||
return map[reason] ?? reason
|
return map[reason] ?? reason
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ function parseScope(params: URLSearchParams): DashboardScope {
|
||||||
function statusColor(status: string | null | undefined): string {
|
function statusColor(status: string | null | undefined): string {
|
||||||
if (status === 'online') return 'green'
|
if (status === 'online') return 'green'
|
||||||
if (status === 'offline') return 'red'
|
if (status === 'offline') return 'red'
|
||||||
if (status === 'warn') return 'orange'
|
if (status === 'warn' || status === 'failed') return 'orange'
|
||||||
return 'default'
|
return 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,216 +56,58 @@ function healthColor(score: number | null | undefined): string {
|
||||||
return 'red'
|
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 {
|
function formatAge(seconds: number | null | undefined): string {
|
||||||
if (seconds == null) return '-'
|
if (seconds == null) return '-'
|
||||||
if (seconds < 60) return `${seconds}с`
|
if (seconds < 60) return `${seconds}ñ`
|
||||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}м`
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}ì`
|
||||||
return `${Math.floor(seconds / 3600)}ч ${Math.floor((seconds % 3600) / 60)}м`
|
return `${Math.floor(seconds / 3600)}÷ ${Math.floor((seconds % 3600) / 60)}ì`
|
||||||
}
|
}
|
||||||
|
|
||||||
function AvailabilityMiniChart({ points }: { points: DashboardAvailabilityPoint[] }) {
|
function uptimeColor(point: DashboardAvailabilityPoint): string {
|
||||||
const viewW = 860
|
if (point.samples === 0) return '#d9d9d9'
|
||||||
const viewH = 150
|
if (point.offline > 0) return '#ff4d4f'
|
||||||
const padX = 14
|
return '#52c41a'
|
||||||
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 StatusTimeline({
|
function UptimeBars({ points, compact = false }: { points: DashboardAvailabilityPoint[]; compact?: boolean }) {
|
||||||
intervals,
|
if (!points.length) {
|
||||||
startAt,
|
return <Typography.Text type="secondary">Íåò òî÷åê â âûáðàííîì îêíå</Typography.Text>
|
||||||
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>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
|
key={`${point.bucket_start}-${idx}`}
|
||||||
|
title={`${dayjs(point.bucket_start).format('DD.MM HH:mm')} • ${point.availability_pct ?? '—'}%`}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
flex: 1,
|
||||||
height: 18,
|
minWidth: compact ? 3 : 4,
|
||||||
borderRadius: 8,
|
height: compact ? 9 : 14,
|
||||||
background: '#eef2f8',
|
borderRadius: compact ? 2 : 3,
|
||||||
overflow: 'hidden',
|
background: uptimeColor(point),
|
||||||
border: '1px solid #dbe4f0',
|
border: '1px solid rgba(0,0,0,0.08)',
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Row justify="space-between" style={{ marginTop: 4 }}>
|
<Row justify="space-between" style={{ marginTop: compact ? 4 : 6 }}>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<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>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<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>
|
</Typography.Text>
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
{!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>
|
||||||
function DeviceExpandedRow({
|
<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>
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
startAt,
|
|
||||||
endAt,
|
|
||||||
}: {
|
|
||||||
data?: DashboardDeviceAvailability
|
|
||||||
isLoading: boolean
|
|
||||||
startAt?: string
|
|
||||||
endAt?: string
|
|
||||||
}) {
|
|
||||||
if (isLoading) {
|
|
||||||
return <Typography.Text type="secondary">Загрузка истории доступности...</Typography.Text>
|
|
||||||
}
|
|
||||||
if (!data) {
|
|
||||||
return <Typography.Text type="secondary">Недостаточно данных по устройству в выбранном окне</Typography.Text>
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentSwitches = [...(data.intervals ?? [])]
|
|
||||||
.filter((i) => i.status === 'online' || i.status === 'offline')
|
|
||||||
.sort((a, b) => dayjs(b.started_at).valueOf() - dayjs(a.started_at).valueOf())
|
|
||||||
.slice(0, 5)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
|
||||||
<Row gutter={[12, 12]}>
|
|
||||||
<Col xs={24} lg={8}>
|
|
||||||
<Card size="small" bordered>
|
|
||||||
<Typography.Text type="secondary">Availability</Typography.Text>
|
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
||||||
{data.availability_pct != null ? `${data.availability_pct}%` : '—'}
|
|
||||||
</Typography.Title>
|
|
||||||
<Typography.Text type="secondary">Samples: {data.samples}</Typography.Text>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} lg={8}>
|
|
||||||
<Card size="small" bordered>
|
|
||||||
<Typography.Text type="secondary">Последний статус</Typography.Text>
|
|
||||||
<div style={{ marginTop: 4 }}>
|
|
||||||
<Tag color={statusColor(data.last_status)}>{data.last_status ?? 'unknown'}</Tag>
|
|
||||||
</div>
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
{data.last_checked_at ? dayjs(data.last_checked_at).format('DD.MM HH:mm:ss') : '—'}
|
|
||||||
</Typography.Text>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} lg={8}>
|
|
||||||
<Card size="small" bordered>
|
|
||||||
<Typography.Text type="secondary">Последний ping</Typography.Text>
|
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
||||||
{data.last_ping_ms != null ? `${data.last_ping_ms} ms` : '—'}
|
|
||||||
</Typography.Title>
|
|
||||||
<Typography.Text type="secondary">Online: {data.online} • Offline: {data.offline}</Typography.Text>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Card size="small" title="Линия доступности" bordered>
|
|
||||||
{data.series.length ? (
|
|
||||||
<AvailabilityMiniChart points={data.series} />
|
|
||||||
) : (
|
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Нет точек для графика" />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card size="small" title="Таймлайн online/offline" bordered>
|
|
||||||
{startAt && endAt ? (
|
|
||||||
<StatusTimeline intervals={data.intervals} startAt={startAt} endAt={endAt} />
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">Диапазон окна недоступен</Typography.Text>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card size="small" title="Последние переключения" bordered>
|
|
||||||
{recentSwitches.length ? (
|
|
||||||
<Space wrap>
|
|
||||||
{recentSwitches.map((sw, idx) => (
|
|
||||||
<Tag key={`${sw.started_at}-${idx}`} color={sw.status === 'online' ? 'green' : 'red'}>
|
|
||||||
{sw.status} {dayjs(sw.started_at).format('DD.MM HH:mm')} ({formatDuration(sw.duration_sec)})
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">Переключений пока нет</Typography.Text>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Space>
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,15 +119,10 @@ export function HomeObjectDetailPage() {
|
||||||
const [search] = useSearchParams()
|
const [search] = useSearchParams()
|
||||||
const scope = useMemo(() => parseScope(search), [search])
|
const scope = useMemo(() => parseScope(search), [search])
|
||||||
const [window, setWindow] = useState<DashboardAvailabilityWindow>('24h')
|
const [window, setWindow] = useState<DashboardAvailabilityWindow>('24h')
|
||||||
const [expandedKeys, setExpandedKeys] = useState<number[]>([])
|
|
||||||
|
|
||||||
const { data: summary, isLoading: summaryLoading } = useDashboardObjectSummary(objectId, scope)
|
const { data: summary, isLoading: summaryLoading } = useDashboardObjectSummary(objectId, scope)
|
||||||
const { data: devices, isLoading: devicesLoading } = useDashboardObjectDevices(objectId, scope)
|
const { data: devices, isLoading: devicesLoading } = useDashboardObjectDevices(objectId, scope)
|
||||||
const {
|
const { data: availabilityData, isLoading: availabilityLoading } = useDashboardObjectAvailability(objectId, window, undefined, true)
|
||||||
data: expandedAvailability,
|
|
||||||
isLoading: expandedAvailLoading,
|
|
||||||
isFetching: expandedAvailFetching,
|
|
||||||
} = useDashboardObjectAvailability(objectId, window, expandedKeys.length ? expandedKeys : undefined)
|
|
||||||
|
|
||||||
useDashboardMonitorSSE({
|
useDashboardMonitorSSE({
|
||||||
onUpdate: () => {
|
onUpdate: () => {
|
||||||
|
|
@ -298,42 +135,160 @@ export function HomeObjectDetailPage() {
|
||||||
const object = summary?.object
|
const object = summary?.object
|
||||||
|
|
||||||
const availabilityByDevice = useMemo(() => {
|
const availabilityByDevice = useMemo(() => {
|
||||||
const map = new Map<number, DashboardDeviceAvailability>()
|
const map = new Map<number, { points: DashboardAvailabilityPoint[]; availability: number | null }>()
|
||||||
for (const row of expandedAvailability?.devices ?? []) {
|
for (const row of availabilityData?.devices ?? []) {
|
||||||
map.set(row.device_id, row)
|
map.set(row.device_id, {
|
||||||
|
points: row.series ?? [],
|
||||||
|
availability: row.availability_pct,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}, [expandedAvailability])
|
}, [availabilityData])
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const rows = devices?.devices ?? []
|
const rows = devices?.devices ?? []
|
||||||
let online = 0
|
let online = 0
|
||||||
let offline = 0
|
let warn = 0
|
||||||
|
const byRack = new Map<string, DashboardDeviceNOCItem[]>()
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.ping_status === 'online') online += 1
|
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])
|
}, [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 (
|
return (
|
||||||
<div style={{ background: '#f4f6fb', margin: -12, padding: 12, borderRadius: 12 }}>
|
<div style={{ background: '#f4f6fb', margin: -12, padding: 12, borderRadius: 12 }}>
|
||||||
<Row justify="space-between" align="middle" gutter={[8, 8]} style={{ marginBottom: 12 }}>
|
<Row justify="space-between" align="middle" gutter={[8, 8]} style={{ marginBottom: 12 }}>
|
||||||
<Col>
|
<Col>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/home')}>
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/home')}>
|
||||||
Назад
|
Íàçàä
|
||||||
</Button>
|
</Button>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
{object?.object_name ?? `Объект #${objectId}`}
|
{object?.object_name ?? `Îáúåêò #${objectId}`}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
<Col>
|
<Col>
|
||||||
<Space wrap>
|
<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={healthColor(object?.health_score)}>health {object?.health_score ?? '-'}</Tag>
|
||||||
<Tag color="blue">окно {window}</Tag>
|
<Tag color="blue">îêíî {window}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
@ -344,19 +299,19 @@ export function HomeObjectDetailPage() {
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{expandedAvailability?.insufficient_data ? (
|
{availabilityData?.insufficient_data ? (
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
message="По части устройств данных пока недостаточно. Это нормально для новых объектов."
|
message="Ïî ÷àñòè óñòðîéñòâ äàííûõ ïîêà íåäîñòàòî÷íî. Äëÿ íîâûõ îáúåêòîâ ýòî íîðìàëüíî."
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card size="small" loading={summaryLoading} style={{ borderRadius: 12 }}>
|
<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 }}>
|
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||||
{summary?.totals.devices ?? 0}
|
{summary?.totals.devices ?? 0}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
|
@ -364,7 +319,7 @@ export function HomeObjectDetailPage() {
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card size="small" loading={summaryLoading} style={{ borderRadius: 12 }}>
|
<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 }}>
|
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||||
{summary?.totals.problem_devices ?? 0}
|
{summary?.totals.problem_devices ?? 0}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
|
@ -380,16 +335,16 @@ export function HomeObjectDetailPage() {
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card size="small" loading={devicesLoading} style={{ borderRadius: 12 }}>
|
<Card size="small" loading={devicesLoading} style={{ borderRadius: 12 }}>
|
||||||
<Typography.Text type="secondary">Offline</Typography.Text>
|
<Typography.Text type="secondary">Disk warn/failed</Typography.Text>
|
||||||
<Typography.Title level={3} style={{ margin: 0, color: '#cf1322' }}>
|
<Typography.Title level={3} style={{ margin: 0, color: '#d46b08' }}>
|
||||||
{grouped.offline}
|
{grouped.warn}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
title="Устройства объекта"
|
title="Óñòðîéñòâà îáúåêòà"
|
||||||
size="small"
|
size="small"
|
||||||
style={{ borderRadius: 12 }}
|
style={{ borderRadius: 12 }}
|
||||||
extra={
|
extra={
|
||||||
|
|
@ -397,101 +352,38 @@ export function HomeObjectDetailPage() {
|
||||||
value={window}
|
value={window}
|
||||||
onChange={(v) => setWindow(v as DashboardAvailabilityWindow)}
|
onChange={(v) => setWindow(v as DashboardAvailabilityWindow)}
|
||||||
options={[
|
options={[
|
||||||
{ value: '24h', label: '24ч' },
|
{ value: '24h', label: '24÷' },
|
||||||
{ value: '3d', label: '3д' },
|
{ value: '3d', label: '3ä' },
|
||||||
{ value: '7d', label: '7д' },
|
{ value: '7d', label: '7ä' },
|
||||||
]}
|
]}
|
||||||
style={{ width: 120 }}
|
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}
|
loading={devicesLoading}
|
||||||
dataSource={devices?.devices ?? []}
|
dataSource={rackGroup.rows}
|
||||||
rowKey="device_id"
|
rowKey="device_id"
|
||||||
size="small"
|
size="small"
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={false}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1300 }}
|
||||||
expandable={{
|
columns={columns}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
),
|
</Card>
|
||||||
}}
|
))}
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
title: 'IP / Hostname',
|
|
||||||
key: 'identity',
|
|
||||||
render: (_: unknown, row: any) => (
|
|
||||||
<Space direction="vertical" size={0}>
|
|
||||||
<code>{row.ip}</code>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{row.hostname ?? '—'}
|
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
</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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,29 @@
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||||
import { Alert, Button, Card, Checkbox, Collapse, Row, Col, Space, Tag, Typography, message } from 'antd'
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Col,
|
||||||
|
Collapse,
|
||||||
|
InputNumber,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useObjects } from '../api/objects'
|
import { useObjects } from '../api/objects'
|
||||||
import {
|
import {
|
||||||
|
useDashboardChecks,
|
||||||
useDashboardMonitorStatus,
|
useDashboardMonitorStatus,
|
||||||
useDashboardPolicy,
|
useDashboardPolicy,
|
||||||
useDashboardRunNow,
|
useDashboardRunNow,
|
||||||
|
usePatchDashboardChecks,
|
||||||
usePatchDashboardObjectPolicy,
|
usePatchDashboardObjectPolicy,
|
||||||
usePatchDashboardTagPolicy,
|
usePatchDashboardTagPolicy,
|
||||||
} from '../api/dashboard'
|
} from '../api/dashboard'
|
||||||
|
|
@ -17,6 +33,12 @@ function cityKey(city: string | null): string {
|
||||||
return city && city.trim() ? city : 'Без города'
|
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() {
|
export function HomeSettingsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
|
@ -24,12 +46,28 @@ export function HomeSettingsPage() {
|
||||||
const { data: tags } = useTags()
|
const { data: tags } = useTags()
|
||||||
const { data: policy } = useDashboardPolicy()
|
const { data: policy } = useDashboardPolicy()
|
||||||
const { data: monitorStatus } = useDashboardMonitorStatus()
|
const { data: monitorStatus } = useDashboardMonitorStatus()
|
||||||
|
const { data: checksData, isLoading: checksLoading } = useDashboardChecks()
|
||||||
|
|
||||||
const runNow = useDashboardRunNow()
|
const runNow = useDashboardRunNow()
|
||||||
const patchTagPolicy = usePatchDashboardTagPolicy()
|
const patchTagPolicy = usePatchDashboardTagPolicy()
|
||||||
const patchObjectPolicy = usePatchDashboardObjectPolicy()
|
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 objectPolicyMap = useMemo(() => {
|
||||||
const map = new Map<number, 'inherit' | 'include' | 'exclude'>()
|
const map = new Map<number, 'inherit' | 'include' | 'exclude'>()
|
||||||
|
|
@ -48,7 +86,7 @@ export function HomeSettingsPage() {
|
||||||
for (const obj of objects ?? []) {
|
for (const obj of objects ?? []) {
|
||||||
const key = cityKey(obj.city)
|
const key = cityKey(obj.city)
|
||||||
if (!map.has(key)) map.set(key, [])
|
if (!map.has(key)) map.set(key, [])
|
||||||
map.get(key)!.push(obj)
|
map.get(key)?.push(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(map.entries())
|
return Array.from(map.entries())
|
||||||
|
|
@ -64,7 +102,7 @@ export function HomeSettingsPage() {
|
||||||
for (const tag of tags ?? []) {
|
for (const tag of tags ?? []) {
|
||||||
const key = tag.tag_type && tag.tag_type.trim() ? tag.tag_type : 'other'
|
const key = tag.tag_type && tag.tag_type.trim() ? tag.tag_type : 'other'
|
||||||
if (!map.has(key)) map.set(key, [])
|
if (!map.has(key)) map.set(key, [])
|
||||||
map.get(key)!.push(tag)
|
map.get(key)?.push(tag)
|
||||||
}
|
}
|
||||||
return Array.from(map.entries())
|
return Array.from(map.entries())
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
|
@ -79,22 +117,17 @@ export function HomeSettingsPage() {
|
||||||
|
|
||||||
const setObjectEnabled = async (objectId: number, enabled: boolean) => {
|
const setObjectEnabled = async (objectId: number, enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
await patchObjectPolicy.mutateAsync([
|
await patchObjectPolicy.mutateAsync([{ object_id: objectId, mode: enabled ? 'include' : 'exclude' }])
|
||||||
{ object_id: objectId, mode: enabled ? 'include' : 'exclude' },
|
|
||||||
])
|
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Не удалось обновить объект')
|
message.error('Не удалось обновить объект')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setObjectsEnabled = async (objectIds: number[], enabled: boolean) => {
|
const setObjectsEnabled = async (objectIds: number[], enabled: boolean) => {
|
||||||
if (objectIds.length === 0) return
|
if (!objectIds.length) return
|
||||||
try {
|
try {
|
||||||
await patchObjectPolicy.mutateAsync(
|
await patchObjectPolicy.mutateAsync(
|
||||||
objectIds.map((objectId) => ({
|
objectIds.map((objectId) => ({ object_id: objectId, mode: enabled ? 'include' : 'exclude' }))
|
||||||
object_id: objectId,
|
|
||||||
mode: enabled ? 'include' : 'exclude',
|
|
||||||
}))
|
|
||||||
)
|
)
|
||||||
message.success(`Обновлено объектов: ${objectIds.length}`)
|
message.success(`Обновлено объектов: ${objectIds.length}`)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -111,7 +144,7 @@ export function HomeSettingsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const setTagsEnabled = async (tagIds: number[], enabled: boolean) => {
|
const setTagsEnabled = async (tagIds: number[], enabled: boolean) => {
|
||||||
if (tagIds.length === 0) return
|
if (!tagIds.length) return
|
||||||
try {
|
try {
|
||||||
await patchTagPolicy.mutateAsync(tagIds.map((tagId) => ({ tag_id: tagId, enabled })))
|
await patchTagPolicy.mutateAsync(tagIds.map((tagId) => ({ tag_id: tagId, enabled })))
|
||||||
message.success(`Обновлено тегов: ${tagIds.length}`)
|
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 () => {
|
const handleRunNow = async () => {
|
||||||
try {
|
try {
|
||||||
await runNow.mutateAsync({ force: true })
|
await runNow.mutateAsync({ force: true })
|
||||||
|
|
@ -134,7 +186,7 @@ export function HomeSettingsPage() {
|
||||||
const someObjectsEnabled = allObjectIds.some((id) => isObjectEnabled(id))
|
const someObjectsEnabled = allObjectIds.some((id) => isObjectEnabled(id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ background: '#f5f7fb', margin: -12, padding: 12, borderRadius: 12 }}>
|
||||||
<Row justify="space-between" align="middle" gutter={[8, 8]} style={{ marginBottom: 12 }}>
|
<Row justify="space-between" align="middle" gutter={[8, 8]} style={{ marginBottom: 12 }}>
|
||||||
<Col>
|
<Col>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
|
|
@ -147,24 +199,76 @@ export function HomeSettingsPage() {
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
<Col>
|
<Col>
|
||||||
<Space wrap>
|
|
||||||
<Button type="primary" onClick={handleRunNow} loading={runNow.isPending}>
|
<Button type="primary" onClick={handleRunNow} loading={runNow.isPending}>
|
||||||
Запустить сейчас
|
Запустить сейчас
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
Статус: {monitorStatus?.status ?? '-'}
|
Статус: {monitorStatus?.status ?? '-'}
|
||||||
{monitorStatus?.last_success_at
|
{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>
|
</Typography.Text>
|
||||||
|
|
||||||
{monitorStatus?.last_error && (
|
{monitorStatus?.last_error ? (
|
||||||
<Alert type="error" showIcon style={{ marginBottom: 12 }} message={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 }}>
|
<Card title="Объекты" style={{ marginBottom: 16 }}>
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue