карточка устройства
This commit is contained in:
parent
5cda395153
commit
dde7644949
7 changed files with 895 additions and 177 deletions
|
|
@ -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 import select, desc
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.device import Device
|
||||||
from app.models.object import Object
|
from app.models.object import Object
|
||||||
from app.models.rack import Rack
|
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.zone import Zone
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.plugins.registry import action_registry
|
from app.plugins.registry import action_registry
|
||||||
|
|
@ -125,12 +127,19 @@ async def create_task(
|
||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
|
device_id: Optional[int] = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(get_current_user),
|
_: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
rows = await db.execute(
|
query = select(Task).order_by(desc(Task.created_at))
|
||||||
select(Task).order_by(desc(Task.created_at)).offset(skip).limit(limit)
|
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())
|
tasks = list(rows.scalars().all())
|
||||||
labels = await _resolve_labels(db, tasks)
|
labels = await _resolve_labels(db, tasks)
|
||||||
result = []
|
result = []
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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'
|
||||||
|
import { DeviceDetailPage } from './pages/DeviceDetail'
|
||||||
import { LoginPage } from './pages/Login'
|
import { LoginPage } from './pages/Login'
|
||||||
import { ObjectsPage } from './pages/Objects'
|
import { ObjectsPage } from './pages/Objects'
|
||||||
import { SnapshotsPage } from './pages/Snapshots'
|
import { SnapshotsPage } from './pages/Snapshots'
|
||||||
|
|
@ -34,6 +35,7 @@ export default function App() {
|
||||||
{/* Инвентарь */}
|
{/* Инвентарь */}
|
||||||
<Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} />
|
<Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} />
|
||||||
<Route path="inventory/objects/:id" element={<InventoryObjectDetailPage />} />
|
<Route path="inventory/objects/:id" element={<InventoryObjectDetailPage />} />
|
||||||
|
<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 />} />
|
||||||
|
|
||||||
{/* Работа */}
|
{/* Работа */}
|
||||||
|
|
|
||||||
|
|
@ -26,21 +26,30 @@ export interface DeviceItem {
|
||||||
ip: string
|
ip: string
|
||||||
hostname: string | null
|
hostname: string | null
|
||||||
mac: string | null
|
mac: string | null
|
||||||
|
vendor: string | null
|
||||||
category: string
|
category: string
|
||||||
role: string
|
role: string
|
||||||
model: string | null
|
model: string | null
|
||||||
|
firmware_version: string | null
|
||||||
source: string
|
source: string
|
||||||
|
external_id: string | null
|
||||||
rack_id: number | null
|
rack_id: number | null
|
||||||
rack_name: string | null
|
rack_name: string | null
|
||||||
rack_type: string | null
|
rack_type: string | null
|
||||||
|
location: string | null
|
||||||
ssh_user_override: string | null
|
ssh_user_override: string | null
|
||||||
ssh_port_override: number | null
|
ssh_port_override: number | null
|
||||||
|
device_meta: Record<string, unknown>
|
||||||
|
connection_params: Record<string, unknown>
|
||||||
|
capabilities: string[]
|
||||||
status: string
|
status: string
|
||||||
last_seen: string | null
|
last_seen: string | null
|
||||||
last_ping_ms: number | null
|
last_ping_ms: number | null
|
||||||
monitor_enabled: boolean
|
monitor_enabled: boolean
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
capabilities: string[]
|
notes: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncResult {
|
export interface SyncResult {
|
||||||
|
|
@ -84,6 +93,14 @@ export const useDevices = (objId: number) =>
|
||||||
api.get<DeviceItem[]>(`/api/v1/objects/${objId}/devices`).then((r) => r.data),
|
api.get<DeviceItem[]>(`/api/v1/objects/${objId}/devices`).then((r) => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const useDevice = (objId: number, deviceId: number) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['device', objId, deviceId],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<DeviceItem>(`/api/v1/objects/${objId}/devices/${deviceId}`).then((r) => r.data),
|
||||||
|
enabled: !!objId && !!deviceId,
|
||||||
|
})
|
||||||
|
|
||||||
export interface ObjectCreatePayload {
|
export interface ObjectCreatePayload {
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
|
|
@ -175,7 +192,10 @@ export const useUpdateDevice = (objId: number) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, body }: { id: number; body: Partial<DeviceCreatePayload> }) =>
|
mutationFn: ({ id, body }: { id: number; body: Partial<DeviceCreatePayload> }) =>
|
||||||
api.patch<DeviceItem>(`/api/v1/objects/${objId}/devices/${id}`, body).then((r) => r.data),
|
api.patch<DeviceItem>(`/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] })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,3 +78,12 @@ export const useCreateTask = () =>
|
||||||
mutationFn: (body: { type: string; target_scope: object; params: object }) =>
|
mutationFn: (body: { type: string; target_scope: object; params: object }) =>
|
||||||
api.post<Task>('/api/v1/tasks', body).then((r) => r.data),
|
api.post<Task>('/api/v1/tasks', body).then((r) => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const useDeviceTasks = (deviceId: number) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['tasks', 'device', deviceId],
|
||||||
|
queryFn: () =>
|
||||||
|
api
|
||||||
|
.get<Task[]>('/api/v1/tasks', { params: { device_id: deviceId, limit: 100 } })
|
||||||
|
.then((r) => r.data),
|
||||||
|
})
|
||||||
|
|
|
||||||
209
frontend/src/components/DeviceEditModal.tsx
Normal file
209
frontend/src/components/DeviceEditModal.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Modal
|
||||||
|
title={device ? `Редактировать: ${device.hostname ?? device.ip}` : 'Добавить устройство'}
|
||||||
|
open={open}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onOk={handleOk}
|
||||||
|
confirmLoading={createDevice.isPending || updateDevice.isPending}
|
||||||
|
okText={device ? 'Сохранить' : 'Добавить'}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="ip" label="IP-адрес" rules={[{ required: true, message: 'Обязательное поле' }]}>
|
||||||
|
<Input placeholder="192.168.1.100" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="hostname" label="Hostname">
|
||||||
|
<Input placeholder="camera-01" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="category" label="Категория" rules={[{ required: true }]}>
|
||||||
|
<Select placeholder="Выберите категорию" options={DEVICE_CATEGORIES} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="role" label="Роль">
|
||||||
|
<Select placeholder="Выберите роль" allowClear options={DEVICE_ROLES} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="vendor" label="Производитель">
|
||||||
|
<Input placeholder="Iptronic, Овен, PAX..." />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="model" label="Модель">
|
||||||
|
<Input placeholder="IPT-IPL1080S" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="rack_id" label="Стойка">
|
||||||
|
<Select placeholder="Без стойки" allowClear options={rackOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="location" label="Локация (если не в стойке)">
|
||||||
|
<Select placeholder="Не задана" allowClear options={DEVICE_LOCATIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="mac" label="MAC">
|
||||||
|
<Input placeholder="AA:BB:CC:DD:EE:FF" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item name="monitor_enabled" valuePropName="checked">
|
||||||
|
<Checkbox>Мониторинг (автоматический ping)</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider orientation="left" plain>SSH (переопределить настройки объекта)</Divider>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item name="ssh_user_override" label="Логин">
|
||||||
|
<Input placeholder="caps" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={10}>
|
||||||
|
<Form.Item name="ssh_password_override" label="Пароль">
|
||||||
|
<Input.Password placeholder="••••••••" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="ssh_port_override" label="Порт">
|
||||||
|
<InputNumber style={{ width: '100%' }} min={1} max={65535} placeholder="22" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
618
frontend/src/pages/DeviceDetail.tsx
Normal file
618
frontend/src/pages/DeviceDetail.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
open: 'error',
|
||||||
|
acknowledged: 'warning',
|
||||||
|
resolved: 'success',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALERT_STATUS_LABEL: Record<string, string> = {
|
||||||
|
open: 'Открыт',
|
||||||
|
acknowledged: 'Подтверждён',
|
||||||
|
resolved: 'Закрыт',
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCATION_LABEL: Record<string, string> = {
|
||||||
|
server_room: 'Серверная',
|
||||||
|
street: 'Улица',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Alerts tab ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AlertsTab({ deviceId }: { deviceId: number }) {
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | undefined>(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) => (
|
||||||
|
<Badge status={ALERT_STATUS_COLOR[s] as any} text={ALERT_STATUS_LABEL[s] ?? s} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 ? (
|
||||||
|
<Tooltip title={row.acknowledged_at ? dayjs(row.acknowledged_at).format('DD.MM.YY HH:mm') : ''}>
|
||||||
|
<Typography.Text type="secondary">{v}</Typography.Text>
|
||||||
|
</Tooltip>
|
||||||
|
) : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, row: AlertItem) => (
|
||||||
|
<Space size={4}>
|
||||||
|
{row.status === 'open' && (
|
||||||
|
<Tooltip title="Подтвердить">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
loading={acknowledge.isPending}
|
||||||
|
onClick={() => acknowledge.mutateAsync(row.id).catch(() => message.error('Ошибка'))}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{row.status !== 'resolved' && (
|
||||||
|
<Tooltip title="Закрыть">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
loading={resolve.isPending}
|
||||||
|
onClick={() => resolve.mutateAsync(row.id).catch(() => message.error('Ошибка'))}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Select
|
||||||
|
placeholder="Все статусы"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 180 }}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={setStatusFilter}
|
||||||
|
options={[
|
||||||
|
{ value: 'open', label: 'Открытые' },
|
||||||
|
{ value: 'acknowledged', label: 'Подтверждённые' },
|
||||||
|
{ value: 'resolved', label: 'Закрытые' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
dataSource={alerts ?? []}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
locale={{ emptyText: 'Нет алертов' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Snapshots tab ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SnapshotsTab({ objId, deviceId }: { objId: number; deviceId: number }) {
|
||||||
|
const { data: snapshots, isLoading } = useSnapshots(objId, { device_id: deviceId })
|
||||||
|
const [selectedA, setSelectedA] = useState<number | null>(null)
|
||||||
|
const [selectedB, setSelectedB] = useState<number | null>(null)
|
||||||
|
const { data: diffData, isFetching: diffLoading } = useSnapshotDiff(objId, selectedA, selectedB)
|
||||||
|
|
||||||
|
const snapshotOptions = (snapshots ?? []).map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: `${dayjs(s.created_at).format('DD.MM.YY HH:mm')} — ${s.sha256.slice(0, 8)}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'Дата',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string) => dayjs(v).format('DD.MM.YY HH:mm:ss'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SHA-256',
|
||||||
|
dataIndex: 'sha256',
|
||||||
|
key: 'sha256',
|
||||||
|
render: (v: string) => (
|
||||||
|
<Tooltip title={v}>
|
||||||
|
<code>{v.slice(0, 12)}…</code>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Размер',
|
||||||
|
dataIndex: 'size',
|
||||||
|
key: 'size',
|
||||||
|
width: 100,
|
||||||
|
render: (v: number) => `${v} Б`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Метка',
|
||||||
|
dataIndex: 'label',
|
||||||
|
key: 'label',
|
||||||
|
render: (v: string | null) => v ?? '—',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const renderDiff = (diff: string) => {
|
||||||
|
if (!diff) return <Typography.Text type="secondary">Нет изменений</Typography.Text>
|
||||||
|
return (
|
||||||
|
<pre style={{ fontSize: 12, maxHeight: 500, overflow: 'auto', background: '#fafafa', padding: 12, border: '1px solid #e8e8e8', borderRadius: 6 }}>
|
||||||
|
{diff.split('\n').map((line, i) => {
|
||||||
|
let color = 'inherit'
|
||||||
|
if (line.startsWith('+') && !line.startsWith('+++')) color = '#237804'
|
||||||
|
else if (line.startsWith('-') && !line.startsWith('---')) color = '#a8071a'
|
||||||
|
else if (line.startsWith('@@')) color = '#096dd9'
|
||||||
|
return (
|
||||||
|
<span key={i} style={{ color, display: 'block' }}>{line}</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
dataSource={snapshots ?? []}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
locale={{ emptyText: 'Снапшоты не найдены' }}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginBottom: 12 }}>Сравнить снапшоты</Typography.Title>
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Select
|
||||||
|
placeholder="Снапшот A (старый)"
|
||||||
|
style={{ width: 280 }}
|
||||||
|
options={snapshotOptions}
|
||||||
|
value={selectedA}
|
||||||
|
onChange={setSelectedA}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Снапшот B (новый)"
|
||||||
|
style={{ width: 280 }}
|
||||||
|
options={snapshotOptions}
|
||||||
|
value={selectedB}
|
||||||
|
onChange={setSelectedB}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{diffLoading && <Spin />}
|
||||||
|
{diffData && !diffLoading && (
|
||||||
|
diffData.is_identical
|
||||||
|
? <Typography.Text type="secondary">Снапшоты идентичны</Typography.Text>
|
||||||
|
: renderDiff(diffData.diff)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tasks tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TaskResultRow({ taskId, deviceId }: { taskId: string; deviceId: number }) {
|
||||||
|
const { data: detail, isLoading } = useTask(taskId)
|
||||||
|
const deviceResult = detail?.device_results.find((r) => r.device_id === deviceId)
|
||||||
|
|
||||||
|
if (isLoading) return <Spin size="small" />
|
||||||
|
if (!deviceResult) return <Typography.Text type="secondary">Нет данных</Typography.Text>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '8px 16px', background: '#fafafa', borderRadius: 4 }}>
|
||||||
|
<Space size={16} style={{ marginBottom: 8 }}>
|
||||||
|
<span><b>Статус:</b> {deviceResult.status}</span>
|
||||||
|
{deviceResult.exit_code !== null && <span><b>Код:</b> {deviceResult.exit_code}</span>}
|
||||||
|
{deviceResult.duration_ms !== null && <span><b>Время:</b> {deviceResult.duration_ms}мс</span>}
|
||||||
|
{deviceResult.executed_at && (
|
||||||
|
<span><b>Выполнено:</b> {dayjs(deviceResult.executed_at).format('DD.MM.YY HH:mm:ss')}</span>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
{deviceResult.stdout && (
|
||||||
|
<pre style={{ fontSize: 11, maxHeight: 200, overflow: 'auto', background: '#fff', padding: 8, border: '1px solid #e8e8e8', borderRadius: 4, marginBottom: 4 }}>
|
||||||
|
{deviceResult.stdout}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{deviceResult.stderr && (
|
||||||
|
<pre style={{ fontSize: 11, maxHeight: 100, overflow: 'auto', background: '#fff4f4', padding: 8, border: '1px solid #ffa39e', borderRadius: 4, color: '#a8071a' }}>
|
||||||
|
{deviceResult.stderr}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{deviceResult.data && Object.keys(deviceResult.data).length > 0 && (
|
||||||
|
<pre style={{ fontSize: 11, maxHeight: 200, overflow: 'auto', background: '#f6ffed', padding: 8, border: '1px solid #b7eb8f', borderRadius: 4 }}>
|
||||||
|
{JSON.stringify(deviceResult.data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TasksTab({ deviceId }: { deviceId: number }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: tasks, isLoading } = useDeviceTasks(deviceId)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'Тип',
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Статус',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 140,
|
||||||
|
render: (v: string) => <TaskStatusBadge status={v} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Цель',
|
||||||
|
dataIndex: 'target_label',
|
||||||
|
key: 'target_label',
|
||||||
|
render: (v: string | null) => v ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Создана',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string) => dayjs(v).format('DD.MM.YY HH:mm'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Завершена',
|
||||||
|
dataIndex: 'finished_at',
|
||||||
|
key: 'finished_at',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string | null) => v ? dayjs(v).format('DD.MM.YY HH:mm') : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 80,
|
||||||
|
render: (_: unknown, row: Task) => (
|
||||||
|
<Tooltip title="Открыть задачу">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => navigate(`/tasks/${row.id}`)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
dataSource={tasks ?? []}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
locale={{ emptyText: 'Задачи не найдены' }}
|
||||||
|
expandable={{
|
||||||
|
expandedRowRender: (row: Task) => (
|
||||||
|
<TaskResultRow taskId={row.id} deviceId={deviceId} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function DeviceDetailPage() {
|
||||||
|
const { id: objIdStr, deviceId: deviceIdStr } = useParams<{ id: string; deviceId: string }>()
|
||||||
|
const objId = Number(objIdStr)
|
||||||
|
const deviceId = Number(deviceIdStr)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: obj } = useObject(objId)
|
||||||
|
const { data: device, isLoading } = useDevice(objId, deviceId)
|
||||||
|
const deleteDevice = useDeleteDevice(objId)
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState('info')
|
||||||
|
const [liveStatus, setLiveStatus] = useState<{ status: string; ping_ms: number | null } | null>(null)
|
||||||
|
|
||||||
|
useMonitorSSE({
|
||||||
|
onDeviceStatus: (ev) => {
|
||||||
|
if (ev.device_id === deviceId) {
|
||||||
|
setLiveStatus({ status: ev.status, ping_ms: ev.ping_ms })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const effectiveStatus = liveStatus?.status ?? device?.status ?? 'unknown'
|
||||||
|
const effectivePing = liveStatus !== null ? liveStatus.ping_ms : (device?.last_ping_ms ?? null)
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteDevice.mutateAsync(deviceId)
|
||||||
|
message.success('Устройство удалено')
|
||||||
|
navigate(`/inventory/objects/${objId}`)
|
||||||
|
} catch {
|
||||||
|
message.error('Ошибка при удалении')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <Spin style={{ display: 'block', marginTop: 48 }} />
|
||||||
|
if (!device) return <Result status="404" title="Устройство не найдено" extra={<Button onClick={() => navigate(`/inventory/objects/${objId}`)}>К объекту</Button>} />
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ title: <a onClick={() => navigate('/inventory/objects')}>Инвентарь</a> },
|
||||||
|
...(obj?.city ? [{ title: <a onClick={() => navigate('/inventory/objects')}>{obj.city}</a> }] : []),
|
||||||
|
{ title: <a onClick={() => navigate(`/inventory/objects/${objId}`)}>{obj?.name ?? `Объект #${objId}`}</a> },
|
||||||
|
{ title: device.ip },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{
|
||||||
|
key: 'info',
|
||||||
|
label: 'Основное',
|
||||||
|
children: (
|
||||||
|
<Row gutter={24}>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }}>
|
||||||
|
<Descriptions.Item label="IP"><code>{device.ip}</code></Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Hostname">{device.hostname ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="MAC">{device.mac ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Производитель">{device.vendor ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Модель">{device.model ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Прошивка">{device.firmware_version ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Категория"><CategoryTag category={device.category} /></Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Роль">{device.role !== 'other' ? device.role : '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Источник">
|
||||||
|
<Typography.Text type="secondary">{device.source}</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{device.external_id && (
|
||||||
|
<Descriptions.Item label="External ID">
|
||||||
|
<code>{device.external_id}</code>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }} style={{ marginTop: 16 }}>
|
||||||
|
<Descriptions.Item label="Объект">
|
||||||
|
<a onClick={() => navigate(`/inventory/objects/${objId}`)}>{obj?.name ?? `#${objId}`}</a>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Стойка">
|
||||||
|
{device.rack_name ? <Tag color="blue">{device.rack_name}{device.rack_type ? ` ${device.rack_type}` : ''}</Tag> : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Локация">
|
||||||
|
{device.location ? (LOCATION_LABEL[device.location] ?? device.location) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }} style={{ marginTop: 16 }}>
|
||||||
|
<Descriptions.Item label="Статус"><DeviceStatusBadge status={effectiveStatus} /></Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Последний ping">
|
||||||
|
{device.last_seen ? (
|
||||||
|
<Tooltip title={dayjs(device.last_seen).format('DD.MM.YYYY HH:mm:ss')}>
|
||||||
|
<Space size={4}>
|
||||||
|
<ClockCircleOutlined style={{ color: '#999' }} />
|
||||||
|
<span>{effectivePing !== null ? `${effectivePing}мс` : '—'}</span>
|
||||||
|
</Space>
|
||||||
|
</Tooltip>
|
||||||
|
) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Мониторинг">
|
||||||
|
{device.monitor_enabled
|
||||||
|
? <Tag color="green">Включён</Tag>
|
||||||
|
: <Tag>Выключен</Tag>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
{(device.ssh_user_override || device.ssh_port_override) && (
|
||||||
|
<Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }} style={{ marginTop: 16 }}>
|
||||||
|
{device.ssh_user_override && (
|
||||||
|
<Descriptions.Item label="SSH логин (override)">{device.ssh_user_override}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{device.ssh_port_override && (
|
||||||
|
<Descriptions.Item label="SSH порт (override)">{device.ssh_port_override}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{device.notes && (
|
||||||
|
<Card size="small" title="Заметки" style={{ marginTop: 16 }}>
|
||||||
|
<Typography.Text>{device.notes}</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Descriptions bordered size="small" column={2} style={{ marginTop: 16 }}>
|
||||||
|
<Descriptions.Item label="Создано">
|
||||||
|
{dayjs(device.created_at).format('DD.MM.YYYY HH:mm')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Обновлено">
|
||||||
|
{dayjs(device.updated_at).format('DD.MM.YYYY HH:mm')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
{device.capabilities.length > 0 && (
|
||||||
|
<Card size="small" title="Возможности" style={{ marginBottom: 16 }}>
|
||||||
|
<Space wrap>
|
||||||
|
{device.capabilities.map((c) => <Tag key={c}>{c}</Tag>)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{device.device_meta && Object.keys(device.device_meta).length > 0 && (
|
||||||
|
<Card size="small" title="Метаданные устройства" style={{ marginBottom: 16 }}>
|
||||||
|
<pre style={{ fontSize: 11, margin: 0, maxHeight: 300, overflow: 'auto' }}>
|
||||||
|
{JSON.stringify(device.device_meta, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{device.connection_params && Object.keys(device.connection_params).length > 0 && (
|
||||||
|
<Card size="small" title="Параметры подключения">
|
||||||
|
<pre style={{ fontSize: 11, margin: 0, maxHeight: 300, overflow: 'auto' }}>
|
||||||
|
{JSON.stringify(device.connection_params, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'alerts',
|
||||||
|
label: 'Алерты',
|
||||||
|
children: <AlertsTab deviceId={deviceId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'snapshots',
|
||||||
|
label: 'Снапшоты',
|
||||||
|
children: <SnapshotsTab objId={objId} deviceId={deviceId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tasks',
|
||||||
|
label: 'История задач',
|
||||||
|
children: <TasksTab deviceId={deviceId} />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumb style={{ marginBottom: 16 }} items={breadcrumbItems} />
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
<Row justify="space-between" align="top">
|
||||||
|
<Col>
|
||||||
|
<Space align="baseline" size={12}>
|
||||||
|
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||||
|
<code>{device.ip}</code>
|
||||||
|
</Typography.Title>
|
||||||
|
<DeviceStatusBadge status={effectiveStatus} />
|
||||||
|
{effectivePing !== null && (
|
||||||
|
<Typography.Text type="secondary">{effectivePing}мс</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
{device.hostname && (
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 14 }}>{device.hostname}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Space style={{ marginTop: 8 }} wrap>
|
||||||
|
<CategoryTag category={device.category} />
|
||||||
|
{device.role && device.role !== 'other' && <Tag>{device.role}</Tag>}
|
||||||
|
{device.rack_name && <Tag color="blue">{device.rack_name}</Tag>}
|
||||||
|
{device.vendor && <Tag color="default">{device.vendor}</Tag>}
|
||||||
|
{device.model && <Typography.Text type="secondary" style={{ fontSize: 13 }}>{device.model}</Typography.Text>}
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Space>
|
||||||
|
<Button icon={<EditOutlined />} onClick={() => setEditOpen(true)}>
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="Удалить устройство?"
|
||||||
|
description="Это действие необратимо."
|
||||||
|
okText="Удалить"
|
||||||
|
cancelText="Отмена"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
>
|
||||||
|
<Button danger icon={<DeleteOutlined />} loading={deleteDevice.isPending}>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
tabList={tabItems.map((t) => ({ key: t.key, tab: t.label }))}
|
||||||
|
activeTabKey={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
|
bodyStyle={{ paddingTop: 16 }}
|
||||||
|
>
|
||||||
|
{tabItems.find((t) => t.key === activeTab)?.children}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<DeviceEditModal
|
||||||
|
open={editOpen}
|
||||||
|
device={device}
|
||||||
|
objId={objId}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
EyeOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
GoogleOutlined,
|
GoogleOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
|
|
@ -13,13 +14,10 @@ import {
|
||||||
Alert,
|
Alert,
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
|
||||||
Col,
|
Col,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Divider,
|
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Row,
|
Row,
|
||||||
|
|
@ -38,12 +36,10 @@ import { useMemo, useState } from 'react'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
useCreateDevice,
|
|
||||||
useDeleteDevice,
|
useDeleteDevice,
|
||||||
useDeleteAllDevices,
|
useDeleteAllDevices,
|
||||||
useForceDeleteObject,
|
useForceDeleteObject,
|
||||||
useDiscoverObject,
|
useDiscoverObject,
|
||||||
useUpdateDevice,
|
|
||||||
useDevices,
|
useDevices,
|
||||||
useImportCSV,
|
useImportCSV,
|
||||||
useObject,
|
useObject,
|
||||||
|
|
@ -56,6 +52,7 @@ import {
|
||||||
import { useRacks, useCreateRack, useUpdateRack, useDeleteRack, type RackItem } from '../api/racks'
|
import { useRacks, useCreateRack, useUpdateRack, useDeleteRack, type RackItem } from '../api/racks'
|
||||||
import { useZones, useCreateZone, useDeleteZone, type ZoneItem } from '../api/zones'
|
import { useZones, useCreateZone, useDeleteZone, type ZoneItem } from '../api/zones'
|
||||||
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
|
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
|
||||||
|
import { DeviceEditModal } from '../components/DeviceEditModal'
|
||||||
import { DiscoveryDrawer } from '../components/DiscoveryDrawer'
|
import { DiscoveryDrawer } from '../components/DiscoveryDrawer'
|
||||||
import { stripEmpty } from '../utils/form'
|
import { stripEmpty } from '../utils/form'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
@ -70,29 +67,6 @@ type GroupRow = { _type: 'group'; _key: string; label: string; count: number; on
|
||||||
type DeviceRow = DeviceItem & { _type: 'device' }
|
type DeviceRow = DeviceItem & { _type: 'device' }
|
||||||
type TableRow = GroupRow | DeviceRow
|
type TableRow = GroupRow | DeviceRow
|
||||||
|
|
||||||
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: 'Улица' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function InventoryObjectDetailPage() {
|
export function InventoryObjectDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
|
@ -107,8 +81,6 @@ export function InventoryObjectDetailPage() {
|
||||||
const syncMutation = useSyncObject(objId)
|
const syncMutation = useSyncObject(objId)
|
||||||
const discoverMutation = useDiscoverObject(objId)
|
const discoverMutation = useDiscoverObject(objId)
|
||||||
const syncSheetsMutation = useSyncSheets(objId)
|
const syncSheetsMutation = useSyncSheets(objId)
|
||||||
const createDevice = useCreateDevice(objId)
|
|
||||||
const updateDevice = useUpdateDevice(objId)
|
|
||||||
const deleteDevice = useDeleteDevice(objId)
|
const deleteDevice = useDeleteDevice(objId)
|
||||||
const previewCSV = usePreviewCSV(objId)
|
const previewCSV = usePreviewCSV(objId)
|
||||||
const importCSV = useImportCSV(objId)
|
const importCSV = useImportCSV(objId)
|
||||||
|
|
@ -132,17 +104,11 @@ export function InventoryObjectDetailPage() {
|
||||||
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null)
|
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null)
|
||||||
const [searchText, setSearchText] = useState('')
|
const [searchText, setSearchText] = useState('')
|
||||||
|
|
||||||
const [deviceForm] = Form.useForm()
|
|
||||||
const [rackForm] = Form.useForm()
|
const [rackForm] = Form.useForm()
|
||||||
const [zoneForm] = Form.useForm()
|
const [zoneForm] = Form.useForm()
|
||||||
const [sheetsForm] = Form.useForm()
|
const [sheetsForm] = Form.useForm()
|
||||||
const [editRackForm] = Form.useForm()
|
const [editRackForm] = Form.useForm()
|
||||||
|
|
||||||
const rackOptions = useMemo(
|
|
||||||
() => (racks ?? []).map((r) => ({ value: r.id, label: r.name })),
|
|
||||||
[racks]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await syncMutation.mutateAsync()
|
const result = await syncMutation.mutateAsync()
|
||||||
|
|
@ -183,53 +149,15 @@ export function InventoryObjectDetailPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAddDevice = () => {
|
const openAddDevice = () => {
|
||||||
deviceForm.resetFields()
|
|
||||||
setEditDevice(null)
|
setEditDevice(null)
|
||||||
setDeviceModal(true)
|
setDeviceModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditDevice = (row: DeviceItem) => {
|
const openEditDevice = (row: DeviceItem) => {
|
||||||
deviceForm.resetFields()
|
|
||||||
deviceForm.setFieldsValue({
|
|
||||||
ip: row.ip,
|
|
||||||
hostname: row.hostname ?? '',
|
|
||||||
category: row.category,
|
|
||||||
role: row.role,
|
|
||||||
vendor: (row as any).vendor ?? '',
|
|
||||||
model: row.model ?? '',
|
|
||||||
mac: row.mac ?? '',
|
|
||||||
rack_id: row.rack_id ?? undefined,
|
|
||||||
location: (row as any).location ?? undefined,
|
|
||||||
monitor_enabled: row.monitor_enabled ?? false,
|
|
||||||
ssh_user_override: row.ssh_user_override ?? '',
|
|
||||||
ssh_port_override: row.ssh_port_override ?? undefined,
|
|
||||||
})
|
|
||||||
setEditDevice(row)
|
setEditDevice(row)
|
||||||
setDeviceModal(true)
|
setDeviceModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveDevice = async () => {
|
|
||||||
const values = await deviceForm.validateFields()
|
|
||||||
const payload = stripEmpty(values)
|
|
||||||
if ('rack_id' in values && values.rack_id === undefined) {
|
|
||||||
payload.rack_id = null
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (editDevice) {
|
|
||||||
await updateDevice.mutateAsync({ id: editDevice.id, body: payload as any })
|
|
||||||
message.success('Устройство обновлено')
|
|
||||||
} else {
|
|
||||||
await createDevice.mutateAsync(payload as any)
|
|
||||||
message.success('Устройство добавлено')
|
|
||||||
}
|
|
||||||
setDeviceModal(false)
|
|
||||||
setEditDevice(null)
|
|
||||||
deviceForm.resetFields()
|
|
||||||
} catch {
|
|
||||||
message.error(editDevice ? 'Ошибка при обновлении' : 'Ошибка при добавлении')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteDevice = async (row: DeviceItem) => {
|
const handleDeleteDevice = async (row: DeviceItem) => {
|
||||||
try {
|
try {
|
||||||
await deleteDevice.mutateAsync(row.id)
|
await deleteDevice.mutateAsync(row.id)
|
||||||
|
|
@ -407,7 +335,17 @@ export function InventoryObjectDetailPage() {
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <code>{(row as DeviceRow).ip}</code>
|
const d = row as DeviceRow
|
||||||
|
return (
|
||||||
|
<Space size={4}>
|
||||||
|
<code
|
||||||
|
style={{ cursor: 'pointer', color: '#1677ff' }}
|
||||||
|
onClick={() => navigate(`/inventory/objects/${objId}/devices/${d.id}`)}
|
||||||
|
>
|
||||||
|
{d.ip}
|
||||||
|
</code>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -485,6 +423,9 @@ export function InventoryObjectDetailPage() {
|
||||||
const d = row as DeviceRow
|
const d = row as DeviceRow
|
||||||
return (
|
return (
|
||||||
<Space>
|
<Space>
|
||||||
|
<Tooltip title="Карточка устройства">
|
||||||
|
<Button size="small" icon={<EyeOutlined />} onClick={() => navigate(`/inventory/objects/${objId}/devices/${d.id}`)} />
|
||||||
|
</Tooltip>
|
||||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEditDevice(d)} />
|
<Button size="small" icon={<EditOutlined />} onClick={() => openEditDevice(d)} />
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="Удалить устройство?"
|
title="Удалить устройство?"
|
||||||
|
|
@ -784,102 +725,12 @@ export function InventoryObjectDetailPage() {
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Add / Edit Device Modal */}
|
<DeviceEditModal
|
||||||
<Modal
|
|
||||||
title={editDevice ? `Редактировать: ${editDevice.hostname ?? editDevice.ip}` : 'Добавить устройство'}
|
|
||||||
open={deviceModal}
|
open={deviceModal}
|
||||||
onCancel={() => { setDeviceModal(false); setEditDevice(null); deviceForm.resetFields() }}
|
device={editDevice}
|
||||||
onOk={handleSaveDevice}
|
objId={objId}
|
||||||
confirmLoading={createDevice.isPending || updateDevice.isPending}
|
onClose={() => { setDeviceModal(false); setEditDevice(null) }}
|
||||||
okText={editDevice ? 'Сохранить' : 'Добавить'}
|
/>
|
||||||
width={640}
|
|
||||||
>
|
|
||||||
<Form form={deviceForm} layout="vertical" style={{ marginTop: 16 }}>
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item name="ip" label="IP-адрес" rules={[{ required: true, message: 'Обязательное поле' }]}>
|
|
||||||
<Input placeholder="192.168.1.100" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item name="hostname" label="Hostname">
|
|
||||||
<Input placeholder="camera-01" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item name="category" label="Категория" rules={[{ required: true }]}>
|
|
||||||
<Select placeholder="Выберите категорию" options={DEVICE_CATEGORIES} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item name="role" label="Роль">
|
|
||||||
<Select placeholder="Выберите роль" allowClear options={DEVICE_ROLES} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item name="vendor" label="Производитель">
|
|
||||||
<Input placeholder="Iptronic, Овен, PAX..." />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item name="model" label="Модель">
|
|
||||||
<Input placeholder="IPT-IPL1080S" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item name="rack_id" label="Стойка">
|
|
||||||
<Select placeholder="Без стойки" allowClear options={rackOptions} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item name="location" label="Локация (если не в стойке)">
|
|
||||||
<Select placeholder="Не задана" allowClear options={DEVICE_LOCATIONS} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item name="mac" label="MAC">
|
|
||||||
<Input placeholder="AA:BB:CC:DD:EE:FF" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Form.Item name="monitor_enabled" valuePropName="checked">
|
|
||||||
<Checkbox>Мониторинг (автоматический ping)</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Divider orientation="left" plain>SSH (переопределить настройки объекта)</Divider>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={8}>
|
|
||||||
<Form.Item name="ssh_user_override" label="Логин">
|
|
||||||
<Input placeholder="caps" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={10}>
|
|
||||||
<Form.Item name="ssh_password_override" label="Пароль">
|
|
||||||
<Input.Password placeholder="••••••••" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={6}>
|
|
||||||
<Form.Item name="ssh_port_override" label="Порт">
|
|
||||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} placeholder="22" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<DiscoveryDrawer
|
<DiscoveryDrawer
|
||||||
open={discoveryOpen}
|
open={discoveryOpen}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue