Compare commits

...

2 commits

7 changed files with 895 additions and 177 deletions

View file

@ -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 = []

View file

@ -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() {
{/* Инвентарь */}
<Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} />
<Route path="inventory/objects/:id" element={<InventoryObjectDetailPage />} />
<Route path="inventory/objects/:id/devices/:deviceId" element={<DeviceDetailPage />} />
<Route path="inventory/objects/:id/snapshots" element={<SnapshotsPage />} />
{/* Работа */}

View file

@ -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<string, unknown>
connection_params: Record<string, unknown>
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<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 {
name: string
description?: string
@ -175,7 +192,10 @@ export const useUpdateDevice = (objId: number) => {
return useMutation({
mutationFn: ({ id, body }: { id: number; body: Partial<DeviceCreatePayload> }) =>
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] })
},
})
}

View file

@ -78,3 +78,12 @@ export const useCreateTask = () =>
mutationFn: (body: { type: string; target_scope: object; params: object }) =>
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),
})

View 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>
)
}

View 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>
)
}

View file

@ -2,6 +2,7 @@ import {
ApiOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
FileTextOutlined,
GoogleOutlined,
PlusOutlined,
@ -13,13 +14,10 @@ import {
Alert,
Breadcrumb,
Button,
Checkbox,
Col,
Descriptions,
Divider,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
@ -38,12 +36,10 @@ import { useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import {
useCreateDevice,
useDeleteDevice,
useDeleteAllDevices,
useForceDeleteObject,
useDiscoverObject,
useUpdateDevice,
useDevices,
useImportCSV,
useObject,
@ -56,6 +52,7 @@ import {
import { useRacks, useCreateRack, useUpdateRack, useDeleteRack, type RackItem } from '../api/racks'
import { useZones, useCreateZone, useDeleteZone, type ZoneItem } from '../api/zones'
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import { DeviceEditModal } from '../components/DeviceEditModal'
import { DiscoveryDrawer } from '../components/DiscoveryDrawer'
import { stripEmpty } from '../utils/form'
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 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() {
const { id } = useParams<{ id: string }>()
@ -107,8 +81,6 @@ export function InventoryObjectDetailPage() {
const syncMutation = useSyncObject(objId)
const discoverMutation = useDiscoverObject(objId)
const syncSheetsMutation = useSyncSheets(objId)
const createDevice = useCreateDevice(objId)
const updateDevice = useUpdateDevice(objId)
const deleteDevice = useDeleteDevice(objId)
const previewCSV = usePreviewCSV(objId)
const importCSV = useImportCSV(objId)
@ -132,17 +104,11 @@ export function InventoryObjectDetailPage() {
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null)
const [searchText, setSearchText] = useState('')
const [deviceForm] = Form.useForm()
const [rackForm] = Form.useForm()
const [zoneForm] = Form.useForm()
const [sheetsForm] = Form.useForm()
const [editRackForm] = Form.useForm()
const rackOptions = useMemo(
() => (racks ?? []).map((r) => ({ value: r.id, label: r.name })),
[racks]
)
const handleSync = async () => {
try {
const result = await syncMutation.mutateAsync()
@ -183,53 +149,15 @@ export function InventoryObjectDetailPage() {
}
const openAddDevice = () => {
deviceForm.resetFields()
setEditDevice(null)
setDeviceModal(true)
}
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)
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) => {
try {
await deleteDevice.mutateAsync(row.id)
@ -407,7 +335,17 @@ export function InventoryObjectDetailPage() {
</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
return (
<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)} />
<Popconfirm
title="Удалить устройство?"
@ -784,102 +725,12 @@ export function InventoryObjectDetailPage() {
)}
</Modal>
{/* Add / Edit Device Modal */}
<Modal
title={editDevice ? `Редактировать: ${editDevice.hostname ?? editDevice.ip}` : 'Добавить устройство'}
<DeviceEditModal
open={deviceModal}
onCancel={() => { setDeviceModal(false); setEditDevice(null); deviceForm.resetFields() }}
onOk={handleSaveDevice}
confirmLoading={createDevice.isPending || updateDevice.isPending}
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>
device={editDevice}
objId={objId}
onClose={() => { setDeviceModal(false); setEditDevice(null) }}
/>
<DiscoveryDrawer
open={discoveryOpen}