import { ApiOutlined, DeleteOutlined, EditOutlined, EyeOutlined, FileTextOutlined, GoogleOutlined, PlusOutlined, SyncOutlined, ThunderboltOutlined, UploadOutlined, } from '@ant-design/icons' import { Alert, Breadcrumb, Button, Col, Descriptions, Divider, Form, Input, 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 { useQueryClient } from '@tanstack/react-query' import { useNavigate, useParams } from 'react-router-dom' import { useDeleteDevice, useDeleteAllDevices, useForceDeleteObject, useDiscoverObject, useDevices, useImportCSV, useObject, usePreviewCSV, useSyncObject, useSyncSheets, type DeviceImportPreview, type DeviceItem, } from '../api/objects' 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' const SERVER_CATEGORIES = new Set(['main_server', 'vm', 'router']) const rackLabel = (name: string, type: string | null | undefined): string => type ? `${name} ${type}` : name const DEVICE_COL_SPAN = 8 type GroupRow = { _type: 'group'; _key: string; label: string; count: number; onlineCount: number } type DeviceRow = DeviceItem & { _type: 'device' } type TableRow = GroupRow | DeviceRow export function InventoryObjectDetailPage() { const { id } = useParams<{ id: string }>() const objId = Number(id) const navigate = useNavigate() const queryClient = useQueryClient() 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 deleteDevice = useDeleteDevice(objId) const previewCSV = usePreviewCSV(objId) const importCSV = useImportCSV(objId) const createRack = useCreateRack(objId) const updateRack = useUpdateRack(objId) const deleteRack = useDeleteRack(objId) const createZone = useCreateZone(objId) const deleteZone = useDeleteZone(objId) const deleteAllDevices = useDeleteAllDevices(objId) const forceDeleteObject = useForceDeleteObject() 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 [discoveryTaskId, setDiscoveryTaskId] = useState(null) const [discoveryOpen, setDiscoveryOpen] = useState(false) const [editRack, setEditRack] = useState(null) const [csvFile, setCsvFile] = useState(null) const [csvPreview, setCsvPreview] = useState(null) const [searchText, setSearchText] = useState('') const [rackForm] = Form.useForm() const [zoneForm] = Form.useForm() const [sheetsForm] = Form.useForm() const [editRackForm] = Form.useForm() const handleSync = async () => { try { const result = await syncMutation.mutateAsync() message.success(`Синхронизировано: +${result.created} новых, ${result.updated} обновлено`) } catch { message.error('Ошибка синхронизации') } } const handleDiscover = async () => { try { const { task_id } = await discoverMutation.mutateAsync() setDiscoveryTaskId(task_id) setDiscoveryOpen(true) } catch { message.error('Ошибка SSH discovery') } } const handleDiscoveryComplete = () => { queryClient.invalidateQueries({ queryKey: ['devices', objId] }) } 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 = () => { setEditDevice(null) setDeviceModal(true) } const openEditDevice = (row: DeviceItem) => { setEditDevice(row) setDeviceModal(true) } const handleDeleteDevice = async (row: DeviceItem) => { try { await deleteDevice.mutateAsync(row.id) message.success(`Устройство ${row.ip} удалено`) } catch { message.error('Ошибка при удалении устройства') } } const handleDeleteAllDevices = async () => { try { await deleteAllDevices.mutateAsync() message.success('Все устройства удалены') } catch { message.error('Ошибка при удалении устройств') } } const handleDeleteObject = () => { Modal.confirm({ title: `Удалить объект «${obj?.name}»?`, content: `Будут удалены все устройства (${devices?.length ?? 0} шт.) и сам объект. Это действие необратимо.`, okText: 'Удалить', cancelText: 'Отмена', okButtonProps: { danger: true }, onOk: async () => { try { await forceDeleteObject.mutateAsync(objId) message.success('Объект удалён') navigate('/inventory/objects') } 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 openEditRack = (row: RackItem) => { editRackForm.setFieldsValue({ rack_type: row.rack_type ?? '', location: row.location ?? '', }) setEditRack(row) } const handleSaveRack = async () => { if (!editRack) return const values = editRackForm.getFieldsValue() try { await updateRack.mutateAsync({ id: editRack.id, body: stripEmpty(values) as any }) message.success('Стойка обновлена') setEditRack(null) } 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]) const groupedTableData = useMemo((): TableRow[] => { const servers = filteredDevices.filter((d) => SERVER_CATEGORIES.has(d.category)) const rest = filteredDevices.filter((d) => !SERVER_CATEGORIES.has(d.category)) const rackMap = new Map() const noRack: DeviceItem[] = [] for (const d of rest) { if (d.rack_name) { if (!rackMap.has(d.rack_name)) rackMap.set(d.rack_name, []) rackMap.get(d.rack_name)!.push(d) } else { noRack.push(d) } } const rows: TableRow[] = [] const addGroup = (label: string, key: string, items: DeviceItem[]) => { rows.push({ _type: 'group', _key: key, label, count: items.length, onlineCount: items.filter((d) => d.status === 'online').length, }) items.forEach((d) => rows.push({ ...d, _type: 'device' })) } if (servers.length > 0) addGroup('Серверная инфраструктура', '__servers__', servers) const sortedRacks = [...rackMap.entries()].sort(([a], [b]) => a.localeCompare(b)) for (const [rackName, items] of sortedRacks) { const label = rackLabel(rackName, items[0]?.rack_type) addGroup(label, `rack:${rackName}`, items) } if (noRack.length > 0) addGroup('Без привязки', '__norack__', noRack) return rows }, [filteredDevices]) if (objLoading) return const deviceColumns = [ { title: 'IP', dataIndex: 'ip', key: 'ip', onCell: (row: TableRow) => row._type === 'group' ? { colSpan: DEVICE_COL_SPAN, style: { background: '#f5f5f5', padding: '6px 16px', borderBottom: '1px solid #e8e8e8' } } : {}, render: (_: unknown, row: TableRow) => { if (row._type === 'group') { return ( {row.label} {row.count} устройств {row.onlineCount > 0 && Online: {row.onlineCount}} ) } const d = row as DeviceRow return ( navigate(`/inventory/objects/${objId}/devices/${d.id}`)} > {d.ip} ) }, }, { title: 'Hostname', dataIndex: 'hostname', key: 'hostname', onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, render: (_: unknown, row: TableRow) => row._type === 'group' ? null : ((row as DeviceRow).hostname ?? '—'), }, { title: 'Стойка / локация', key: 'location', onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, render: (_: unknown, row: TableRow) => { if (row._type === 'group') return null const d = row as DeviceRow if (d.rack_name) return {rackLabel(d.rack_name, d.rack_type)} const loc = (d as any).location if (loc === 'server_room') return Серверная if (loc === 'street') return Улица return }, }, { title: 'Категория', dataIndex: 'category', key: 'category', onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, render: (_: unknown, row: TableRow) => row._type === 'group' ? null : , }, { title: 'Статус', dataIndex: 'status', key: 'status', onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, render: (_: unknown, row: TableRow) => row._type === 'group' ? null : , }, { title: 'Последний ping', dataIndex: 'last_seen', key: 'last_seen', onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, render: (_: unknown, row: TableRow) => { if (row._type === 'group') return null const d = row as DeviceRow return d.last_seen ? ( {d.last_ping_ms}ms ) : '—' }, }, { title: 'Источник', dataIndex: 'source', key: 'source', onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, render: (_: unknown, row: TableRow) => row._type === 'group' ? null : ( {(row as DeviceRow).source} ), }, { title: '', key: 'actions', width: 100, onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, render: (_: unknown, row: TableRow) => { if (row._type === 'group') return null const d = row as DeviceRow return ( ) } return ( {row.name} {row.rack_type ? {row.rack_type} : null} ) }, }, { title: 'Зона', dataIndex: 'zone_name', key: 'zone_name', render: (v: string | null) => v ? {v} : '—' }, { title: 'Расположение', dataIndex: 'location', key: 'location', render: (v: string | null) => v ?? '—' }, { title: '', key: 'actions', width: 90, render: (_: unknown, row: RackItem) => ( setSearchText(e.target.value)} /> row._type === 'group' ? row._key : String((row as DeviceRow).id)} loading={devLoading} size="middle" pagination={{ pageSize: 50 }} /> {/* Rack & Zone Management Modal */} { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields(); setEditRack(null) }} 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}
)} )} { setDeviceModal(false); setEditDevice(null) }} /> setDiscoveryOpen(false)} objectName={obj?.name ?? ''} taskId={discoveryTaskId} onComplete={handleDiscoveryComplete} /> {/* Google Sheets Sync Modal */} { setSheetsModal(false); sheetsForm.resetFields() }} onOk={handleSyncSheets} confirmLoading={syncSheetsMutation.isPending} okText="Синхронизировать" >
Ожидаемые столбцы: ip, hostname, category, role, model, mac, notes
) }