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

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

View file

@ -87,12 +87,14 @@ async def object_availability(
object_id: int, 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")

View file

@ -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

View file

@ -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),
} }
) )

View file

@ -1,4 +1,4 @@
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom'
import { AppLayout } from './components/AppLayout' import { 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>

View file

@ -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:

View file

@ -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 }
}) })
.slice(0, 6) .filter((x) => x.pingAge > staleThreshold)
.sort((a, b) => b.pingAge - a.pingAge)
.slice(0, 10)
}, [included, staleThreshold])
const avgHealth = useMemo(() => {
const values = included.map((x) => x.health_score).filter((v): v is number => v != null)
if (!values.length) return null
return Math.round(values.reduce((a, b) => a + b, 0) / values.length)
}, [included])
const incidentObjects = useMemo(() => {
return [...(summary?.problem_objects ?? [])].sort((a, b) => {
const aOffline = a.checks?.ping?.status_counts?.offline ?? 0
const bOffline = b.checks?.ping?.status_counts?.offline ?? 0
if (aOffline !== bOffline) return bOffline - aOffline
const aDiskFail = a.checks?.disk_usage?.status_counts?.failed ?? 0
const bDiskFail = b.checks?.disk_usage?.status_counts?.failed ?? 0
if (aDiskFail !== bDiskFail) return bDiskFail - aDiskFail
return (a.health_score ?? 100) - (b.health_score ?? 100)
})
}, [summary]) }, [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>
<Typography.Text style={{ marginLeft: 6 }}>
{monitorStatus?.last_success_at
? `Последний успех: ${dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss')}`
: 'Нет успешных запусков'}
</Typography.Text>
</div>
{monitorStatus?.last_error ? (
<Alert type="error" showIcon style={{ marginTop: 10 }} message={monitorStatus.last_error} />
) : null}
</Card>
</Col>
<Col xs={24} lg={16}>
<Row gutter={[12, 12]}>
<Col xs={12} md={6}>
<Card size="small" style={{ borderRadius: 12 }} loading={isLoading}>
<Statistic title="Объекты" value={totals.objects} />
</Card>
</Col>
<Col xs={12} md={6}>
<Card size="small" style={{ borderRadius: 12 }} loading={isLoading}>
<Statistic title="В мониторинге" value={summary?.included_count ?? totals.allowed_objects} />
</Card>
</Col>
<Col xs={12} md={6}>
<Card size="small" style={{ borderRadius: 12 }} loading={isLoading}>
<Statistic title="Вне мониторинга" value={summary?.excluded_count ?? totals.excluded_objects} />
</Card>
</Col>
<Col xs={12} md={6}>
<Card size="small" style={{ borderRadius: 12 }} loading={isLoading}>
<Statistic title="Устройства" value={totals.devices} />
</Card>
</Col>
</Row>
</Col>
</Row> </Row>
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}> {monitorStatus?.last_error ? <Alert type="error" showIcon style={{ marginBottom: 12 }} message={monitorStatus.last_error} /> : null}
<Col xs={24} lg={12}>
<Card
size="small"
title="Ping status (в мониторинге)"
style={{ borderRadius: 12, height: '100%' }}
loading={isLoading}
>
{pingTotals.all === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Нет данных" />
) : (
<Space direction="vertical" style={{ width: '100%' }} size={8}>
<div>
<Typography.Text type="secondary">Online</Typography.Text>
<Progress percent={Math.round((pingTotals.online / pingTotals.all) * 100)} status="success" />
</div>
<div>
<Typography.Text type="secondary">Offline</Typography.Text>
<Progress percent={Math.round((pingTotals.offline / pingTotals.all) * 100)} strokeColor="#cf1322" />
</div>
<div>
<Typography.Text type="secondary">Unknown</Typography.Text>
<Progress percent={Math.round((pingTotals.unknown / pingTotals.all) * 100)} strokeColor="#8c8c8c" />
</div>
</Space>
)}
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
size="small"
title="Приоритетные инциденты"
style={{ borderRadius: 12, height: '100%' }}
loading={isLoading}
>
{topIncidents.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Критичных объектов нет" />
) : (
<List
dataSource={topIncidents}
renderItem={(item) => (
<List.Item
actions={[
<Button
key="open"
type="link"
onClick={() => navigate(`/home/objects/${item.object_id}${scopeToSearch(scope)}`)}
>
Открыть
</Button>,
]}
>
<List.Item.Meta
title={
<Space wrap size={8}>
<span>{item.object_name}</span>
<Tag color={healthColor(item.health_score)}>health {item.health_score ?? '-'}</Tag>
</Space>
}
description={
<Space split={<span></span>} wrap>
<span>{item.city ?? '—'}</span>
<span>{item.client ?? '—'}</span>
<span>{item.device_count} устройств</span>
<span>Ping age: {formatAge(item.checks?.ping?.max_age_sec)}</span>
</Space>
}
/>
</List.Item>
)}
/>
)}
</Card>
</Col>
</Row>
<Row gutter={[12, 12]}> <Row gutter={[12, 12]}>
<Col xs={24} xl={12}> <Col xs={24} xl={16}>
<Card title={`В мониторинге (${summary?.included_count ?? 0})`} size="small" style={{ borderRadius: 12 }}> <Space direction="vertical" size={12} style={{ width: '100%' }}>
<Table <Card title="Îáúåêòû ñ èíöèäåíòàìè" size="small" style={{ borderRadius: 12 }}>
size="small" {incidentObjects.length === 0 ? (
dataSource={summary?.included_objects ?? []} <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Êðèòè÷íûõ îáúåêòîâ íåò" />
rowKey="object_id" ) : (
columns={objectColumns} <List
loading={isLoading} dataSource={incidentObjects.slice(0, 12)}
pagination={{ pageSize: 6 }} renderItem={(item) => {
scroll={{ x: 800 }} const ping = item.checks?.ping
/> const disk = item.checks?.disk_usage?.status_counts ?? {}
</Card> const offline = ping?.status_counts?.offline ?? 0
const unknown = ping?.status_counts?.unknown ?? 0
const diskWarn = disk.warn ?? 0
const diskFailed = disk.failed ?? 0
return (
<List.Item
actions={[
<Button key="open" type="link" onClick={() => navigate(`/home/objects/${item.object_id}${scopeToSearch(scope)}`)}>
Îòêðûòü
</Button>,
]}
>
<List.Item.Meta
title={
<Space wrap size={8}>
<Typography.Text strong>{item.object_name}</Typography.Text>
<Tag>{item.city ?? '—'}</Tag>
<Tag color="blue">{item.client ?? '—'}</Tag>
<Tag color={healthColor(item.health_score)}>health {item.health_score ?? '—'}</Tag>
</Space>
}
description={
<Space split={<span></span>} wrap>
<span>offline: {offline}</span>
<span>unknown: {unknown}</span>
<span>disk warn/failed: {diskWarn}/{diskFailed}</span>
<span>óñòðîéñòâ: {item.device_count}</span>
<span>age ping: {formatAge(ping?.max_age_sec)}</span>
</Space>
}
/>
</List.Item>
)
}}
/>
)}
</Card>
<Card title="Îáúåêòû ñ íåñâåæèìè äàííûìè ìîíèòîðèíãà" size="small" style={{ borderRadius: 12 }}>
{stalePingObjects.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Âñå îáúåêòû îáíîâëÿþòñÿ â ïðåäåëàõ SLA" />
) : (
<List
size="small"
dataSource={stalePingObjects}
renderItem={(item) => (
<List.Item actions={[<Button key="open" type="link" onClick={() => navigate(`/home/objects/${item.obj.object_id}${scopeToSearch(scope)}`)}>Îòêðûòü</Button>]}>
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Row justify="space-between">
<Typography.Text>{item.obj.object_name}</Typography.Text>
<Typography.Text type="secondary">age: {formatAge(item.pingAge)}</Typography.Text>
</Row>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
offline/unknown: {item.offline}/{item.unknown}
</Typography.Text>
</Space>
</List.Item>
)}
/>
)}
</Card>
</Space>
</Col> </Col>
<Col xs={24} xl={12}>
<Card title={`Вне мониторинга (${summary?.excluded_count ?? 0})`} size="small" style={{ borderRadius: 12 }}> <Col xs={24} xl={8}>
<Table <Space direction="vertical" size={12} style={{ width: '100%' }}>
size="small" <Card title="SLA è ïîêðûòèå" size="small" style={{ borderRadius: 12 }}>
dataSource={summary?.excluded_objects ?? []} <Space direction="vertical" size={10} style={{ width: '100%' }}>
rowKey="object_id" <div>
columns={objectColumns} <Row justify="space-between">
loading={isLoading} <Typography.Text>Äîñòóïíîñòü (online)</Typography.Text>
pagination={{ pageSize: 6 }} <Typography.Text strong>{availabilityPct != null ? `${availabilityPct}%` : '—'}</Typography.Text>
scroll={{ x: 800 }} </Row>
/> <Progress percent={availabilityPct ?? 0} size="small" showInfo={false} strokeColor={availabilityPct != null && availabilityPct < 95 ? '#ff4d4f' : '#52c41a'} />
</Card> </div>
<div>
<Row justify="space-between">
<Typography.Text>Ïîêðûòèå ìîíèòîðèíãîì</Typography.Text>
<Typography.Text strong>{coveragePct != null ? `${coveragePct}%` : '—'}</Typography.Text>
</Row>
<Progress percent={coveragePct ?? 0} size="small" showInfo={false} strokeColor="#1677ff" />
</div>
<div>
<Row justify="space-between">
<Typography.Text>Ñâåæåñòü äàííûõ</Typography.Text>
<Typography.Text strong>{freshnessPct != null ? `${freshnessPct}%` : '—'}</Typography.Text>
</Row>
<Progress percent={freshnessPct ?? 0} size="small" showInfo={false} strokeColor={freshnessPct != null && freshnessPct < 90 ? '#faad14' : '#52c41a'} />
</div>
<Space wrap>
<Tag color="green">online: {onlineTotal}</Tag>
<Tag color="red">offline: {offlineTotal}</Tag>
<Tag color="orange">unknown: {unknownTotal}</Tag>
<Tag>ïî ping: {monitoredTotal}</Tag>
</Space>
</Space>
</Card>
<Card title="Ñîñòîÿíèå öèêëà ìîíèòîðèíãà" size="small" style={{ borderRadius: 12 }}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Row justify="space-between"><Typography.Text>Ñòàòóñ</Typography.Text><Tag color={runStatusColor(monitorStatus?.status)}>{monitorStatus?.status ?? '-'}</Tag></Row>
<Row justify="space-between"><Typography.Text>Îáúåêòû â ìîíèòîðèíãå</Typography.Text><Typography.Text strong>{summary?.included_count ?? totals.allowed_objects}</Typography.Text></Row>
<Row justify="space-between"><Typography.Text>Ïîñëåäíèé óñïåõ</Typography.Text><Typography.Text strong>{monitorStatus?.last_success_at ? dayjs(monitorStatus.last_success_at).format('DD.MM HH:mm:ss') : '—'}</Typography.Text></Row>
{lastCounts ? (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>checks: {lastCounts.checks_executed ?? 0}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>failed: {lastCounts.failed ?? 0}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>checked devices: {lastCounts.devices_checked ?? 0}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>skipped not due: {lastCounts.skipped_not_due ?? 0}</Typography.Text>
</Space>
) : null}
</Space>
</Card>
<Card title="Action queue" size="small" style={{ borderRadius: 12 }}>
{actionQueue.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Êðèòè÷íûõ äåéñòâèé ñåé÷àñ íåò" />
) : (
<List
size="small"
dataSource={actionQueue}
renderItem={(row) => (
<List.Item
actions={[
<Button key="open" type="link" onClick={() => navigate(`/home/objects/${row.obj.object_id}${scopeToSearch(scope)}`)}>
Îòêðûòü
</Button>,
]}
>
<Row justify="space-between" style={{ width: '100%' }}>
<Col>
<Typography.Text>{row.obj.object_name}</Typography.Text>
</Col>
<Col>
<Tag color="red">prio {row.score}</Tag>
</Col>
</Row>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.reasons.join(' • ')}
</Typography.Text>
</List.Item>
)}
/>
)}
</Card>
</Space>
</Col> </Col>
</Row> </Row>
</div> </div>

View file

@ -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,219 +56,61 @@ 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 <div style={{ display: 'flex', gap: compact ? 1 : 2, alignItems: 'center', height: compact ? 14 : 20 }}>
style={{ {points.map((point, idx) => (
position: 'relative',
height: 18,
borderRadius: 8,
background: '#eef2f8',
overflow: 'hidden',
border: '1px solid #dbe4f0',
}}
>
{bars.map((bar) => (
<div <div
key={bar.key} key={`${point.bucket_start}-${idx}`}
title={`${bar.status}${formatDuration(bar.duration)}`} title={`${dayjs(point.bucket_start).format('DD.MM HH:mm')}${point.availability_pct ?? '—'}%`}
style={{ style={{
position: 'absolute', flex: 1,
left: `${bar.left}%`, minWidth: compact ? 3 : 4,
width: `${bar.width}%`, height: compact ? 9 : 14,
top: 0, borderRadius: compact ? 2 : 3,
bottom: 0, background: uptimeColor(point),
background: bar.status === 'online' ? '#52c41a' : '#f5222d', border: '1px solid rgba(0,0,0,0.08)',
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>
{!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> </div>
) )
} }
function DeviceExpandedRow({
data,
isLoading,
startAt,
endAt,
}: {
data?: DashboardDeviceAvailability
isLoading: boolean
startAt?: string
endAt?: string
}) {
if (isLoading) {
return <Typography.Text type="secondary">Загрузка истории доступности...</Typography.Text>
}
if (!data) {
return <Typography.Text type="secondary">Недостаточно данных по устройству в выбранном окне</Typography.Text>
}
const recentSwitches = [...(data.intervals ?? [])]
.filter((i) => i.status === 'online' || i.status === 'offline')
.sort((a, b) => dayjs(b.started_at).valueOf() - dayjs(a.started_at).valueOf())
.slice(0, 5)
return (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Row gutter={[12, 12]}>
<Col xs={24} lg={8}>
<Card size="small" bordered>
<Typography.Text type="secondary">Availability</Typography.Text>
<Typography.Title level={4} style={{ margin: 0 }}>
{data.availability_pct != null ? `${data.availability_pct}%` : '—'}
</Typography.Title>
<Typography.Text type="secondary">Samples: {data.samples}</Typography.Text>
</Card>
</Col>
<Col xs={24} lg={8}>
<Card size="small" bordered>
<Typography.Text type="secondary">Последний статус</Typography.Text>
<div style={{ marginTop: 4 }}>
<Tag color={statusColor(data.last_status)}>{data.last_status ?? 'unknown'}</Tag>
</div>
<Typography.Text type="secondary">
{data.last_checked_at ? dayjs(data.last_checked_at).format('DD.MM HH:mm:ss') : '—'}
</Typography.Text>
</Card>
</Col>
<Col xs={24} lg={8}>
<Card size="small" bordered>
<Typography.Text type="secondary">Последний ping</Typography.Text>
<Typography.Title level={4} style={{ margin: 0 }}>
{data.last_ping_ms != null ? `${data.last_ping_ms} ms` : '—'}
</Typography.Title>
<Typography.Text type="secondary">Online: {data.online} Offline: {data.offline}</Typography.Text>
</Card>
</Col>
</Row>
<Card size="small" title="Линия доступности" bordered>
{data.series.length ? (
<AvailabilityMiniChart points={data.series} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Нет точек для графика" />
)}
</Card>
<Card size="small" title="Таймлайн online/offline" bordered>
{startAt && endAt ? (
<StatusTimeline intervals={data.intervals} startAt={startAt} endAt={endAt} />
) : (
<Typography.Text type="secondary">Диапазон окна недоступен</Typography.Text>
)}
</Card>
<Card size="small" title="Последние переключения" bordered>
{recentSwitches.length ? (
<Space wrap>
{recentSwitches.map((sw, idx) => (
<Tag key={`${sw.started_at}-${idx}`} color={sw.status === 'online' ? 'green' : 'red'}>
{sw.status} {dayjs(sw.started_at).format('DD.MM HH:mm')} ({formatDuration(sw.duration_sec)})
</Tag>
))}
</Space>
) : (
<Typography.Text type="secondary">Переключений пока нет</Typography.Text>
)}
</Card>
</Space>
)
}
export function HomeObjectDetailPage() { export function HomeObjectDetailPage() {
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
@ -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
}
const rackName = row.rack_name && row.rack_name.trim() ? row.rack_name : 'Áåç ñòîéêè'
if (!byRack.has(rackName)) byRack.set(rackName, [])
byRack.get(rackName)?.push(row)
} }
return { online, offline }
const rackGroups = Array.from(byRack.entries())
.sort((a, b) => {
if (a[0] === 'Áåç ñòîéêè') return 1
if (b[0] === 'Áåç ñòîéêè') return -1
return a[0].localeCompare(b[0])
})
.map(([rack, rowsInRack]) => ({
rack,
rows: [...rowsInRack].sort((a, b) => {
const aProblem = a.has_problem ? 0 : 1
const bProblem = b.has_problem ? 0 : 1
if (aProblem !== bProblem) return aProblem - bProblem
if (a.ping_status !== b.ping_status) {
if (a.ping_status === 'offline') return -1
if (b.ping_status === 'offline') return 1
}
return a.ip.localeCompare(b.ip)
}),
}))
return { online, warn, rackGroups }
}, [devices]) }, [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 ? (
loading={devicesLoading} <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Óñòðîéñòâà íå íàéäåíû" />
dataSource={devices?.devices ?? []} ) : (
rowKey="device_id" <Space direction="vertical" size={12} style={{ width: '100%' }}>
size="small" {grouped.rackGroups.map((rackGroup) => (
pagination={{ pageSize: 20 }} <Card
scroll={{ x: 1100 }} key={rackGroup.rack}
expandable={{ size="small"
expandedRowKeys: expandedKeys, title={`${rackGroup.rack} (${rackGroup.rows.length})`}
onExpand: (expanded, row: any) => { style={{ borderRadius: 10 }}
const key = Number(row.device_id) >
setExpandedKeys((prev) => { <Table<DashboardDeviceNOCItem>
if (expanded) { loading={devicesLoading}
if (prev.includes(key)) return prev dataSource={rackGroup.rows}
return [...prev, key] rowKey="device_id"
} size="small"
return prev.filter((x) => x !== key) pagination={false}
}) scroll={{ x: 1300 }}
}, columns={columns}
expandedRowRender: (row: any) => ( />
<DeviceExpandedRow </Card>
data={availabilityByDevice.get(Number(row.device_id))} ))}
isLoading={expandedAvailLoading || expandedAvailFetching} </Space>
startAt={expandedAvailability?.start_at} )}
endAt={expandedAvailability?.end_at}
/>
),
}}
columns={[
{
title: 'IP / Hostname',
key: 'identity',
render: (_: unknown, row: any) => (
<Space direction="vertical" size={0}>
<code>{row.ip}</code>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.hostname ?? '—'}
</Typography.Text>
</Space>
),
},
{ title: 'Категория', dataIndex: 'category', key: 'category', width: 120 },
{
title: 'Ping',
key: 'ping',
width: 190,
render: (_: unknown, row: any) => (
<Space direction="vertical" size={0}>
<Tag color={statusColor(row.ping_status)}>{row.ping_status}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.ping_checked_at ? dayjs(row.ping_checked_at).format('DD.MM HH:mm:ss') : '—'}
{row.last_ping_ms != null ? `${row.last_ping_ms}ms` : ''}
</Typography.Text>
</Space>
),
},
{
title: 'Freshness',
key: 'freshness',
width: 110,
render: (_: unknown, row: any) => (
<Tag color={row.ping_fresh ? 'green' : 'orange'}>{formatAge(row.ping_age_sec)}</Tag>
),
},
{
title: 'Disk',
key: 'disk',
width: 130,
render: (_: unknown, row: any) =>
row.disk_status ? (
<Tag color={statusColor(row.disk_status)}>
{row.disk_status}
{row.disk_max_pct != null ? ` (${row.disk_max_pct}%)` : ''}
</Tag>
) : (
'—'
),
},
{
title: 'Проблема',
key: 'problem',
width: 100,
render: (_: unknown, row: any) =>
row.has_problem ? <Tag color="red">Да</Tag> : <Tag color="green">Нет</Tag>,
},
]}
/>
</Card> </Card>
</div> </div>
) )

View file

@ -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}>