utp_service/frontend/src/pages/InventoryObjectDetail.tsx
2026-04-07 23:46:54 +03:00

718 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
ApiOutlined,
DeleteOutlined,
EditOutlined,
FileTextOutlined,
GoogleOutlined,
PlusOutlined,
SyncOutlined,
ThunderboltOutlined,
UploadOutlined,
} from '@ant-design/icons'
import {
Alert,
Breadcrumb,
Button,
Checkbox,
Col,
Descriptions,
Divider,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
Upload,
message,
} from 'antd'
import type { UploadFile } from 'antd'
import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
useCreateDevice,
useDeleteDevice,
useDiscoverObject,
useUpdateDevice,
useDevices,
useImportCSV,
useObject,
usePreviewCSV,
useSyncObject,
useSyncSheets,
type DeviceImportPreview,
type DeviceItem,
} from '../api/objects'
import { useRacks, useCreateRack, useDeleteRack, type RackItem } from '../api/racks'
import { useZones, useCreateZone, useDeleteZone, type ZoneItem } from '../api/zones'
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import { stripEmpty } from '../utils/form'
import dayjs from 'dayjs'
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 }>()
const objId = Number(id)
const navigate = useNavigate()
const { data: obj, isLoading: objLoading } = useObject(objId)
const { data: devices, isLoading: devLoading } = useDevices(objId)
const { data: racks } = useRacks(objId)
const { data: zones } = useZones(objId)
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)
const createRack = useCreateRack(objId)
const deleteRack = useDeleteRack(objId)
const createZone = useCreateZone(objId)
const deleteZone = useDeleteZone(objId)
const [deviceModal, setDeviceModal] = useState(false)
const [editDevice, setEditDevice] = useState<DeviceItem | null>(null)
const [csvModal, setCsvModal] = useState(false)
const [rackModal, setRackModal] = useState(false)
const [sheetsModal, setSheetsModal] = useState(false)
const [csvFile, setCsvFile] = useState<File | null>(null)
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 rackOptions = useMemo(
() => (racks ?? []).map((r) => ({ value: r.id, label: r.name })),
[racks]
)
const handleSync = async () => {
try {
const result = await syncMutation.mutateAsync()
message.success(`Синхронизировано: +${result.created} новых, ${result.updated} обновлено`)
} catch {
message.error('Ошибка синхронизации')
}
}
const handleDiscover = async () => {
try {
const result = await discoverMutation.mutateAsync()
const msg = `SSH Discovery: +${result.created} найдено, ${result.updated} обновлено`
if (result.errors.length > 0) {
message.warning(`${msg}. Ошибки: ${result.errors.join('; ')}`)
} else {
message.success(msg)
}
} catch {
message.error('Ошибка SSH discovery')
}
}
const handleSyncSheets = async () => {
try {
const values = await sheetsForm.validateFields()
const result = await syncSheetsMutation.mutateAsync({
spreadsheet_id: values.spreadsheet_id,
sheet_name: values.sheet_name || undefined,
})
message.success(`Google Sheets: +${result.created} создано, ${result.updated} обновлено`)
setSheetsModal(false)
sheetsForm.resetFields()
} catch (err: any) {
const detail = err?.response?.data?.detail
if (detail) message.error(detail)
}
}
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)
message.success(`Устройство ${row.ip} удалено`)
} catch {
message.error('Ошибка при удалении устройства')
}
}
const handleCsvPreview = async () => {
if (!csvFile) return
try {
const preview = await previewCSV.mutateAsync(csvFile)
setCsvPreview(preview)
} catch {
message.error('Не удалось разобрать CSV')
}
}
const handleCsvImport = async () => {
if (!csvFile) return
try {
const result = await importCSV.mutateAsync(csvFile)
message.success(`Импортировано: +${result.created} создано, ${result.updated} обновлено`)
setCsvModal(false)
setCsvFile(null)
setCsvPreview(null)
} catch {
message.error('Ошибка при импорте CSV')
}
}
const closeCsvModal = () => {
setCsvModal(false)
setCsvFile(null)
setCsvPreview(null)
}
const handleAddZone = async () => {
const values = await zoneForm.validateFields()
try {
await createZone.mutateAsync(stripEmpty(values) as any)
message.success('Зона добавлена')
zoneForm.resetFields()
} catch {
message.error('Ошибка при добавлении зоны')
}
}
const handleAddRack = async () => {
const values = await rackForm.validateFields()
try {
await createRack.mutateAsync(stripEmpty(values) as any)
message.success('Стойка добавлена')
rackForm.resetFields()
} catch {
message.error('Ошибка при добавлении стойки')
}
}
const filteredDevices = useMemo(() => {
if (!searchText) return devices ?? []
const q = searchText.toLowerCase()
return (devices ?? []).filter(
(d) => d.ip.toLowerCase().includes(q) || (d.hostname ?? '').toLowerCase().includes(q)
)
}, [devices, searchText])
if (objLoading) return <Spin />
const deviceColumns = [
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
render: (ip: string) => <code>{ip}</code>,
},
{
title: 'Hostname',
dataIndex: 'hostname',
key: 'hostname',
render: (h: string | null) => h ?? '—',
},
{
title: 'Стойка / локация',
key: 'location',
render: (_: unknown, row: DeviceItem) => {
if (row.rack_name) return <Tag color="blue">{row.rack_name}</Tag>
const loc = (row as any).location
if (loc === 'server_room') return <Tag color="purple">Серверная</Tag>
if (loc === 'street') return <Tag color="orange">Улица</Tag>
return <Typography.Text type="secondary"></Typography.Text>
},
filters: [
{ text: 'Без привязки', value: '__none__' },
{ text: 'Серверная', value: 'server_room' },
{ text: 'Улица', value: 'street' },
...(racks ?? []).map((r) => ({ text: r.name, value: `rack:${r.name}` })),
],
onFilter: (value: unknown, record: DeviceItem) => {
if (value === '__none__') return !record.rack_name && !(record as any).location
if (value === 'server_room') return (record as any).location === 'server_room'
if (value === 'street') return (record as any).location === 'street'
if (typeof value === 'string' && value.startsWith('rack:'))
return record.rack_name === value.slice(5)
return false
},
},
{
title: 'Категория',
dataIndex: 'category',
key: 'category',
render: (c: string) => <CategoryTag category={c} />,
filters: DEVICE_CATEGORIES.map((c) => ({ text: c.label, value: c.value })),
onFilter: (value: unknown, record: DeviceItem) => record.category === value,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
render: (s: string) => <DeviceStatusBadge status={s} />,
},
{
title: 'Последний ping',
dataIndex: 'last_seen',
key: 'last_seen',
render: (ts: string | null, row: DeviceItem) =>
ts ? (
<Tooltip title={dayjs(ts).format('DD.MM.YYYY HH:mm:ss')}>
<span>{row.last_ping_ms}ms</span>
</Tooltip>
) : '—',
},
{
title: 'Источник',
dataIndex: 'source',
key: 'source',
render: (s: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{s}</Typography.Text>,
},
{
title: '',
key: 'actions',
width: 100,
render: (_: unknown, row: DeviceItem) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEditDevice(row)} />
<Popconfirm
title="Удалить устройство?"
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => handleDeleteDevice(row)}
>
<Button size="small" danger icon={<DeleteOutlined />} loading={deleteDevice.isPending} />
</Popconfirm>
</Space>
),
},
]
const zoneColumns = [
{ title: 'Название', dataIndex: 'name', key: 'name' },
{ title: 'Описание', dataIndex: 'description', key: 'description', render: (v: string | null) => v ?? '—' },
{
title: '',
key: 'actions',
width: 60,
render: (_: unknown, row: ZoneItem) => (
<Popconfirm
title="Удалить зону?"
description="Стойки останутся, привязка к зоне будет снята."
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => deleteZone.mutateAsync(row.id).catch(() => message.error('Ошибка удаления зоны'))}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
]
const rackColumns = [
{ title: 'Название', dataIndex: 'name', key: 'name' },
{ title: 'Зона', dataIndex: 'zone_name', key: 'zone_name', render: (v: string | null) => v ? <Tag>{v}</Tag> : '—' },
{ title: 'Расположение', dataIndex: 'location', key: 'location', render: (v: string | null) => v ?? '—' },
{
title: '',
key: 'actions',
width: 60,
render: (_: unknown, row: RackItem) => (
<Popconfirm
title="Удалить стойку?"
description="Устройства останутся, привязка к стойке будет снята."
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => deleteRack.mutateAsync(row.id).catch(() => message.error('Ошибка удаления стойки'))}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
]
const previewCreateCols = [
{ title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> },
{ title: 'Hostname', dataIndex: 'hostname', key: 'hostname', render: (v: string) => v ?? '—' },
{ title: 'Категория', dataIndex: 'category', key: 'category' },
{ title: 'Роль', dataIndex: 'role', key: 'role' },
]
const previewUpdateCols = [
{ title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> },
{
title: 'Изменения',
dataIndex: 'changes',
key: 'changes',
render: (changes: Record<string, { from: unknown; to: unknown }>) =>
Object.entries(changes).map(([k, v]) => `${k}: ${v.from}${v.to}`).join(', ') || '—',
},
]
// Breadcrumb items: Инвентарь → [City →] Object
const breadcrumbItems = [
{ title: <a onClick={() => navigate('/inventory/objects')}>Инвентарь</a> },
...(obj?.city ? [{ title: <a onClick={() => navigate('/inventory/objects')}>{obj.city}</a> }] : []),
{ title: obj?.name },
]
return (
<div>
<Breadcrumb style={{ marginBottom: 16 }} items={breadcrumbItems} />
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col flex="auto">
<Descriptions size="small" bordered column={4}>
<Descriptions.Item label="Сервер">{obj?.server_ip ?? '—'}</Descriptions.Item>
<Descriptions.Item label="SSH">{obj?.ssh_user ?? '—'}</Descriptions.Item>
<Descriptions.Item label="БД">{obj?.db_host ?? '—'}</Descriptions.Item>
<Descriptions.Item label="Клиент">{obj?.client ?? '—'}</Descriptions.Item>
</Descriptions>
</Col>
<Col>
<Space wrap>
<Button icon={<SyncOutlined />} loading={syncMutation.isPending} onClick={handleSync}>
Синхронизировать из БД
</Button>
<Button icon={<ApiOutlined />} loading={discoverMutation.isPending} onClick={handleDiscover}>
SSH Discovery
</Button>
<Button icon={<UploadOutlined />} onClick={() => setCsvModal(true)}>
Импорт CSV
</Button>
<Button icon={<GoogleOutlined />} onClick={() => setSheetsModal(true)}>
Google Sheets
</Button>
<Button icon={<PlusOutlined />} type="primary" onClick={openAddDevice}>
Добавить устройство
</Button>
<Button icon={<FileTextOutlined />} onClick={() => navigate(`/inventory/objects/${objId}/snapshots`)}>
Снапшоты
</Button>
<Button icon={<ThunderboltOutlined />} onClick={() => setRackModal(true)}>
Стойки и зоны
</Button>
</Space>
</Col>
</Row>
<Input.Search
placeholder="Поиск по IP или hostname..."
allowClear
style={{ marginBottom: 12, maxWidth: 360 }}
onChange={(e) => setSearchText(e.target.value)}
/>
<Table
dataSource={filteredDevices}
columns={deviceColumns}
rowKey="id"
loading={devLoading}
size="middle"
pagination={{ pageSize: 50 }}
/>
{/* Rack & Zone Management Modal */}
<Modal
title="Стойки и зоны"
open={rackModal}
onCancel={() => { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields() }}
footer={null}
width={820}
>
<Typography.Title level={5} style={{ marginTop: 0 }}>Зоны</Typography.Title>
<Form form={zoneForm} layout="inline" style={{ marginBottom: 12 }}>
<Form.Item name="name" rules={[{ required: true, message: 'Введите название' }]}>
<Input placeholder="Название зоны" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="description">
<Input placeholder="Описание (опц.)" style={{ width: 180 }} />
</Form.Item>
<Form.Item>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddZone} loading={createZone.isPending}>
Добавить зону
</Button>
</Form.Item>
</Form>
<Table dataSource={zones ?? []} columns={zoneColumns} rowKey="id" size="small" pagination={false} locale={{ emptyText: 'Нет зон' }} style={{ marginBottom: 24 }} />
<Divider />
<Typography.Title level={5}>Стойки</Typography.Title>
<Form form={rackForm} layout="inline" style={{ marginBottom: 12 }}>
<Form.Item name="name" rules={[{ required: true, message: 'Введите название' }]}>
<Input placeholder="Название стойки" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="zone_id">
<Select placeholder="Зона (опц.)" allowClear style={{ width: 150 }} options={(zones ?? []).map((z) => ({ value: z.id, label: z.name }))} />
</Form.Item>
<Form.Item name="location">
<Input placeholder="Расположение (опц.)" style={{ width: 150 }} />
</Form.Item>
<Form.Item>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddRack} loading={createRack.isPending}>
Добавить стойку
</Button>
</Form.Item>
</Form>
<Table dataSource={racks ?? []} columns={rackColumns} rowKey="id" size="small" pagination={false} locale={{ emptyText: 'Нет стоек' }} />
</Modal>
{/* CSV Import Modal */}
<Modal
title="Импорт устройств из CSV"
open={csvModal}
onCancel={closeCsvModal}
width={700}
footer={
<Space>
<Button onClick={closeCsvModal}>Отмена</Button>
{!csvPreview && <Button onClick={handleCsvPreview} loading={previewCSV.isPending} disabled={!csvFile}>Предпросмотр</Button>}
{csvPreview && <Button type="primary" onClick={handleCsvImport} loading={importCSV.isPending}>Импортировать</Button>}
</Space>
}
>
{!csvPreview ? (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
Формат CSV: <code>ip, hostname, category, role, model, mac, notes</code>
</Typography.Paragraph>
<Upload.Dragger
accept=".csv"
maxCount={1}
beforeUpload={(file) => { setCsvFile(file); return false }}
onRemove={() => setCsvFile(null)}
fileList={csvFile ? [{ uid: '1', name: csvFile.name, status: 'done' } as UploadFile] : []}
>
<p className="ant-upload-drag-icon"><UploadOutlined style={{ fontSize: 32, color: '#1677ff' }} /></p>
<p className="ant-upload-text">Перетащите CSV-файл или нажмите для выбора</p>
</Upload.Dragger>
</>
) : (
<>
{csvPreview.errors.length > 0 && (
<Alert type="warning" style={{ marginBottom: 12 }} message={`${csvPreview.errors.length} строк с ошибками`} description={csvPreview.errors.map((e) => `Строка ${e.row}: ${e.error}`).join('\n')} />
)}
{csvPreview.would_create.length > 0 && (
<>
<Typography.Text strong>Будет создано: {csvPreview.would_create.length}</Typography.Text>
<Table size="small" dataSource={csvPreview.would_create} columns={previewCreateCols} rowKey="ip" pagination={false} style={{ marginTop: 8, marginBottom: 16 }} />
</>
)}
{csvPreview.would_update.length > 0 && (
<>
<Typography.Text strong>Будет обновлено: {csvPreview.would_update.length}</Typography.Text>
<Table size="small" dataSource={csvPreview.would_update} columns={previewUpdateCols} rowKey="ip" pagination={false} style={{ marginTop: 8 }} />
</>
)}
</>
)}
</Modal>
{/* Add / Edit Device Modal */}
<Modal
title={editDevice ? `Редактировать: ${editDevice.hostname ?? editDevice.ip}` : 'Добавить устройство'}
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>
{/* Google Sheets Sync Modal */}
<Modal
title="Синхронизация из Google Sheets"
open={sheetsModal}
onCancel={() => { setSheetsModal(false); sheetsForm.resetFields() }}
onOk={handleSyncSheets}
confirmLoading={syncSheetsMutation.isPending}
okText="Синхронизировать"
>
<Form form={sheetsForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="spreadsheet_id"
label="ID таблицы Google Sheets"
rules={[{ required: true, message: 'Введите ID таблицы' }]}
extra="Часть URL после /spreadsheets/d/ и перед /edit"
>
<Input placeholder="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" />
</Form.Item>
<Form.Item name="sheet_name" label="Название листа (необязательно)" extra="По умолчанию используется первый лист">
<Input placeholder="Sheet1" />
</Form.Item>
</Form>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Ожидаемые столбцы: ip, hostname, category, role, model, mac, notes
</Typography.Text>
</Modal>
</div>
)
}