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(null) const [csvModal, setCsvModal] = useState(false) const [rackModal, setRackModal] = useState(false) const [sheetsModal, setSheetsModal] = useState(false) const [csvFile, setCsvFile] = useState(null) const [csvPreview, setCsvPreview] = useState(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 const deviceColumns = [ { title: 'IP', dataIndex: 'ip', key: 'ip', render: (ip: string) => {ip}, }, { title: 'Hostname', dataIndex: 'hostname', key: 'hostname', render: (h: string | null) => h ?? '—', }, { title: 'Стойка / локация', key: 'location', render: (_: unknown, row: DeviceItem) => { if (row.rack_name) return {row.rack_name} const loc = (row as any).location if (loc === 'server_room') return Серверная if (loc === 'street') return Улица return }, 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) => , 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) => , }, { title: 'Последний ping', dataIndex: 'last_seen', key: 'last_seen', render: (ts: string | null, row: DeviceItem) => ts ? ( {row.last_ping_ms}ms ) : '—', }, { title: 'Источник', dataIndex: 'source', key: 'source', render: (s: string) => {s}, }, { title: '', key: 'actions', width: 100, render: (_: unknown, row: DeviceItem) => ( setSearchText(e.target.value)} /> {/* Rack & Zone Management Modal */} { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields() }} footer={null} width={820} > Зоны
Стойки
{/* CSV Import Modal */} {!csvPreview && } {csvPreview && } } > {!csvPreview ? ( <> Формат CSV: ip, hostname, category, role, model, mac, notes { setCsvFile(file); return false }} onRemove={() => setCsvFile(null)} fileList={csvFile ? [{ uid: '1', name: csvFile.name, status: 'done' } as UploadFile] : []} >

Перетащите CSV-файл или нажмите для выбора

) : ( <> {csvPreview.errors.length > 0 && ( `Строка ${e.row}: ${e.error}`).join('\n')} /> )} {csvPreview.would_create.length > 0 && ( <> Будет создано: {csvPreview.would_create.length}
)} {csvPreview.would_update.length > 0 && ( <> Будет обновлено: {csvPreview.would_update.length}
)} )} {/* Add / Edit Device Modal */} { setDeviceModal(false); setEditDevice(null); deviceForm.resetFields() }} onOk={handleSaveDevice} confirmLoading={createDevice.isPending || updateDevice.isPending} okText={editDevice ? 'Сохранить' : 'Добавить'} width={640} >
Мониторинг (автоматический ping) SSH (переопределить настройки объекта) {/* Google Sheets Sync Modal */} { setSheetsModal(false); sheetsForm.resetFields() }} onOk={handleSyncSheets} confirmLoading={syncSheetsMutation.isPending} okText="Синхронизировать" >
Ожидаемые столбцы: ip, hostname, category, role, model, mac, notes
) }