From dde764494963e39a552206342200849de7962c8d Mon Sep 17 00:00:00 2001 From: dv Date: Thu, 9 Apr 2026 10:59:39 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/tasks.py | 19 +- frontend/src/App.tsx | 2 + frontend/src/api/objects.ts | 24 +- frontend/src/api/tasks.ts | 9 + frontend/src/components/DeviceEditModal.tsx | 209 +++++++ frontend/src/pages/DeviceDetail.tsx | 618 +++++++++++++++++++ frontend/src/pages/InventoryObjectDetail.tsx | 191 +----- 7 files changed, 895 insertions(+), 177 deletions(-) create mode 100644 frontend/src/components/DeviceEditModal.tsx create mode 100644 frontend/src/pages/DeviceDetail.tsx diff --git a/backend/app/routers/tasks.py b/backend/app/routers/tasks.py index bba75b3..8fee559 100644 --- a/backend/app/routers/tasks.py +++ b/backend/app/routers/tasks.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select, desc from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession @@ -7,7 +9,7 @@ from app.dependencies import get_current_user, get_db from app.models.device import Device from app.models.object import Object from app.models.rack import Rack -from app.models.task import Task +from app.models.task import Task, TaskResult from app.models.zone import Zone from app.models.user import User from app.plugins.registry import action_registry @@ -125,12 +127,19 @@ async def create_task( async def list_tasks( limit: int = 50, skip: int = 0, + device_id: Optional[int] = Query(None), db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): - rows = await db.execute( - select(Task).order_by(desc(Task.created_at)).offset(skip).limit(limit) - ) + query = select(Task).order_by(desc(Task.created_at)) + if device_id is not None: + query = ( + query + .join(TaskResult, Task.id == TaskResult.task_id) + .where(TaskResult.device_id == device_id) + .distinct() + ) + rows = await db.execute(query.offset(skip).limit(limit)) tasks = list(rows.scalars().all()) labels = await _resolve_labels(db, tasks) result = [] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d140032..48e8afd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { AppLayout } from './components/AppLayout' import { AdminPage } from './pages/Admin' import { AlertsPage } from './pages/Alerts' +import { DeviceDetailPage } from './pages/DeviceDetail' import { LoginPage } from './pages/Login' import { ObjectsPage } from './pages/Objects' import { SnapshotsPage } from './pages/Snapshots' @@ -34,6 +35,7 @@ export default function App() { {/* Инвентарь */} } /> } /> + } /> } /> {/* Работа */} diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index d755acb..419cd93 100644 --- a/frontend/src/api/objects.ts +++ b/frontend/src/api/objects.ts @@ -26,21 +26,30 @@ export interface DeviceItem { ip: string hostname: string | null mac: string | null + vendor: string | null category: string role: string model: string | null + firmware_version: string | null source: string + external_id: string | null rack_id: number | null rack_name: string | null rack_type: string | null + location: string | null ssh_user_override: string | null ssh_port_override: number | null + device_meta: Record + connection_params: Record + capabilities: string[] status: string last_seen: string | null last_ping_ms: number | null monitor_enabled: boolean is_active: boolean - capabilities: string[] + notes: string | null + created_at: string + updated_at: string } export interface SyncResult { @@ -84,6 +93,14 @@ export const useDevices = (objId: number) => api.get(`/api/v1/objects/${objId}/devices`).then((r) => r.data), }) +export const useDevice = (objId: number, deviceId: number) => + useQuery({ + queryKey: ['device', objId, deviceId], + queryFn: () => + api.get(`/api/v1/objects/${objId}/devices/${deviceId}`).then((r) => r.data), + enabled: !!objId && !!deviceId, + }) + export interface ObjectCreatePayload { name: string description?: string @@ -175,7 +192,10 @@ export const useUpdateDevice = (objId: number) => { return useMutation({ mutationFn: ({ id, body }: { id: number; body: Partial }) => api.patch(`/api/v1/objects/${objId}/devices/${id}`, body).then((r) => r.data), - onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }), + onSuccess: (_, { id }) => { + qc.invalidateQueries({ queryKey: ['devices', objId] }) + qc.invalidateQueries({ queryKey: ['device', objId, id] }) + }, }) } diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts index 035f86b..59513b0 100644 --- a/frontend/src/api/tasks.ts +++ b/frontend/src/api/tasks.ts @@ -78,3 +78,12 @@ export const useCreateTask = () => mutationFn: (body: { type: string; target_scope: object; params: object }) => api.post('/api/v1/tasks', body).then((r) => r.data), }) + +export const useDeviceTasks = (deviceId: number) => + useQuery({ + queryKey: ['tasks', 'device', deviceId], + queryFn: () => + api + .get('/api/v1/tasks', { params: { device_id: deviceId, limit: 100 } }) + .then((r) => r.data), + }) diff --git a/frontend/src/components/DeviceEditModal.tsx b/frontend/src/components/DeviceEditModal.tsx new file mode 100644 index 0000000..2848181 --- /dev/null +++ b/frontend/src/components/DeviceEditModal.tsx @@ -0,0 +1,209 @@ +import { + Checkbox, + Col, + Divider, + Form, + Input, + InputNumber, + Modal, + Row, + Select, + message, +} from 'antd' +import { useEffect } from 'react' +import { + useCreateDevice, + useUpdateDevice, + type DeviceItem, +} from '../api/objects' +import { useRacks } from '../api/racks' +import { stripEmpty } from '../utils/form' + +const DEVICE_CATEGORIES = [ + { value: 'main_server', label: 'Главный сервер' }, + { value: 'vm', label: 'Виртуалка / сервис' }, + { value: 'router', label: 'Роутер / шлюз' }, + { value: 'embedded', label: 'Пром-ПК (embedded)' }, + { value: 'camera', label: 'Камера' }, + { value: 'io_board', label: 'Модуль ввода/вывода' }, + { value: 'bank_terminal', label: 'Банковский терминал' }, + { value: 'cash_register', label: 'ККТ' }, + { value: 'other', label: 'Другое' }, +] + +const DEVICE_ROLES = [ + { value: 'plate', label: 'ГРЗ (номерная)' }, + { value: 'face', label: 'Лицевая / обзорная' }, + { value: 'overview', label: 'Обзорная (улица)' }, + { value: 'other', label: 'Другое' }, +] + +const DEVICE_LOCATIONS = [ + { value: 'server_room', label: 'Серверная' }, + { value: 'street', label: 'Улица' }, +] + +interface Props { + open: boolean + device: DeviceItem | null + objId: number + onClose: () => void + onSaved?: (device: DeviceItem) => void +} + +export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props) { + const [form] = Form.useForm() + const { data: racks } = useRacks(objId) + const createDevice = useCreateDevice(objId) + const updateDevice = useUpdateDevice(objId) + + const rackOptions = (racks ?? []).map((r) => ({ value: r.id, label: r.name })) + + useEffect(() => { + if (!open) return + if (device) { + form.setFieldsValue({ + ip: device.ip, + hostname: device.hostname ?? '', + category: device.category, + role: device.role, + vendor: device.vendor ?? '', + model: device.model ?? '', + mac: device.mac ?? '', + rack_id: device.rack_id ?? undefined, + location: device.location ?? undefined, + monitor_enabled: device.monitor_enabled ?? false, + ssh_user_override: device.ssh_user_override ?? '', + ssh_port_override: device.ssh_port_override ?? undefined, + }) + } else { + form.resetFields() + } + }, [open, device]) + + const handleOk = async () => { + const values = await form.validateFields() + const payload = stripEmpty(values) + if ('rack_id' in values && values.rack_id === undefined) { + payload.rack_id = null + } + try { + let saved: DeviceItem + if (device) { + saved = await updateDevice.mutateAsync({ id: device.id, body: payload as any }) + message.success('Устройство обновлено') + } else { + saved = await createDevice.mutateAsync(payload as any) + message.success('Устройство добавлено') + } + form.resetFields() + onClose() + onSaved?.(saved) + } catch { + message.error(device ? 'Ошибка при обновлении' : 'Ошибка при добавлении') + } + } + + const handleCancel = () => { + form.resetFields() + onClose() + } + + return ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Мониторинг (автоматический ping) + + + SSH (переопределить настройки объекта) + + + + + + + + + + + + + + + + + + +
+
+ ) +} diff --git a/frontend/src/pages/DeviceDetail.tsx b/frontend/src/pages/DeviceDetail.tsx new file mode 100644 index 0000000..a2b8639 --- /dev/null +++ b/frontend/src/pages/DeviceDetail.tsx @@ -0,0 +1,618 @@ +import { + CheckCircleOutlined, + ClockCircleOutlined, + DeleteOutlined, + EditOutlined, + EyeOutlined, +} from '@ant-design/icons' +import { + Badge, + Breadcrumb, + Button, + Card, + Col, + Descriptions, + Popconfirm, + Result, + Row, + Select, + Space, + Spin, + Table, + Tag, + Tooltip, + Typography, + message, +} from 'antd' +import dayjs from 'dayjs' +import { useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { useAlerts, useAcknowledgeAlert, useResolveAlert, type AlertItem } from '../api/alerts' +import { useDevice, useDeleteDevice, useObject } from '../api/objects' +import { useSnapshots, useSnapshotDiff } from '../api/snapshots' +import { useDeviceTasks, useTask, type Task } from '../api/tasks' +import { DeviceEditModal } from '../components/DeviceEditModal' +import { CategoryTag, DeviceStatusBadge, TaskStatusBadge } from '../components/StatusBadge' +import { useMonitorSSE } from '../hooks/useMonitorSSE' + +const ALERT_STATUS_COLOR: Record = { + open: 'error', + acknowledged: 'warning', + resolved: 'success', +} + +const ALERT_STATUS_LABEL: Record = { + open: 'Открыт', + acknowledged: 'Подтверждён', + resolved: 'Закрыт', +} + +const LOCATION_LABEL: Record = { + server_room: 'Серверная', + street: 'Улица', +} + +// ─── Alerts tab ───────────────────────────────────────────────────────────── + +function AlertsTab({ deviceId }: { deviceId: number }) { + const [statusFilter, setStatusFilter] = useState(undefined) + const { data: alerts, isLoading } = useAlerts({ device_id: deviceId, status: statusFilter }) + const acknowledge = useAcknowledgeAlert() + const resolve = useResolveAlert() + + const columns = [ + { + title: 'Сообщение', + dataIndex: 'message', + key: 'message', + }, + { + title: 'Статус', + dataIndex: 'status', + key: 'status', + width: 140, + render: (s: string) => ( + + ), + }, + { + title: 'Создан', + dataIndex: 'created_at', + key: 'created_at', + width: 160, + render: (v: string) => dayjs(v).format('DD.MM.YY HH:mm'), + }, + { + title: 'Подтверждён', + dataIndex: 'acknowledged_by_username', + key: 'ack', + width: 140, + render: (v: string | null, row: AlertItem) => + v ? ( + + {v} + + ) : '—', + }, + { + title: '', + key: 'actions', + width: 120, + render: (_: unknown, row: AlertItem) => ( + + {row.status === 'open' && ( + +