803 lines
30 KiB
TypeScript
803 lines
30 KiB
TypeScript
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'])
|
||
|
||
/** Возвращает URL веб-интерфейса устройства или null, если ссылка не применима. */
|
||
function getDeviceWebUrl(device: DeviceRow): string | null {
|
||
const hostname = (device.hostname ?? '').toLowerCase()
|
||
|
||
// Proxmox — определяем по hostname
|
||
if (hostname.includes('proxmox') || hostname.includes('pve')) {
|
||
return `http://${device.ip}:8006`
|
||
}
|
||
|
||
// RTSP-прокси (go2rtc/mediamtx) — определяем по capabilities
|
||
if (device.capabilities?.includes('rtsp')) {
|
||
return `http://${device.ip}:1984`
|
||
}
|
||
|
||
switch (device.category) {
|
||
case 'main_server': return `http://${device.ip}:80`
|
||
case 'router': return `http://${device.ip}:80` // MikroTik WebFig
|
||
case 'embedded': return `http://${device.ip}:5000`
|
||
case 'camera': return `http://${device.ip}:80`
|
||
// vm без proxmox/pve в hostname — asterisk, db и прочие, ссылка не нужна
|
||
default: return null
|
||
}
|
||
}
|
||
|
||
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<DeviceItem | null>(null)
|
||
const [csvModal, setCsvModal] = useState(false)
|
||
const [rackModal, setRackModal] = useState(false)
|
||
const [sheetsModal, setSheetsModal] = useState(false)
|
||
const [discoveryTaskId, setDiscoveryTaskId] = useState<string | null>(null)
|
||
const [discoveryOpen, setDiscoveryOpen] = useState(false)
|
||
const [editRack, setEditRack] = useState<RackItem | null>(null)
|
||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(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<string, DeviceItem[]>()
|
||
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 <Spin />
|
||
|
||
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 (
|
||
<Space size={8}>
|
||
<Typography.Text strong style={{ fontSize: 13 }}>{row.label}</Typography.Text>
|
||
<Tag style={{ marginLeft: 2 }}>{row.count} устройств</Tag>
|
||
{row.onlineCount > 0 && <Tag color="green">Online: {row.onlineCount}</Tag>}
|
||
</Space>
|
||
)
|
||
}
|
||
const d = row as DeviceRow
|
||
const webUrl = getDeviceWebUrl(d)
|
||
return webUrl ? (
|
||
<Tooltip title={`Открыть ${webUrl}`}>
|
||
<a href={webUrl} target="_blank" rel="noopener noreferrer">
|
||
<code>{d.ip}</code>
|
||
</a>
|
||
</Tooltip>
|
||
) : (
|
||
<code style={{ color: 'inherit' }}>{d.ip}</code>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: 'Hostname',
|
||
dataIndex: 'hostname',
|
||
key: 'hostname',
|
||
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.hostname) return <Typography.Text type="secondary">—</Typography.Text>
|
||
return (
|
||
<a onClick={() => navigate(`/inventory/objects/${objId}/devices/${d.id}`)}>
|
||
{d.hostname}
|
||
</a>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
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 <Tag color="blue">{rackLabel(d.rack_name, d.rack_type)}</Tag>
|
||
if (d.location === 'server_room') return <Tag color="purple">Серверная</Tag>
|
||
if (d.location === 'street') return <Tag color="orange">Улица</Tag>
|
||
return <Typography.Text type="secondary">—</Typography.Text>
|
||
},
|
||
},
|
||
{
|
||
title: 'Категория',
|
||
dataIndex: 'category',
|
||
key: 'category',
|
||
onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
|
||
render: (_: unknown, row: TableRow) =>
|
||
row._type === 'group' ? null : <CategoryTag category={(row as DeviceRow).category} />,
|
||
},
|
||
{
|
||
title: 'Статус',
|
||
dataIndex: 'status',
|
||
key: 'status',
|
||
onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
|
||
render: (_: unknown, row: TableRow) =>
|
||
row._type === 'group' ? null : <DeviceStatusBadge status={(row as DeviceRow).status} />,
|
||
},
|
||
{
|
||
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 ? (
|
||
<Tooltip title={dayjs(d.last_seen).format('DD.MM.YYYY HH:mm:ss')}>
|
||
<span>{d.last_ping_ms}ms</span>
|
||
</Tooltip>
|
||
) : '—'
|
||
},
|
||
},
|
||
{
|
||
title: 'Источник',
|
||
dataIndex: 'source',
|
||
key: 'source',
|
||
onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
|
||
render: (_: unknown, row: TableRow) =>
|
||
row._type === 'group' ? null : (
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
{(row as DeviceRow).source}
|
||
</Typography.Text>
|
||
),
|
||
},
|
||
{
|
||
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 (
|
||
<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="Удалить устройство?"
|
||
okText="Удалить"
|
||
cancelText="Отмена"
|
||
okButtonProps={{ danger: true }}
|
||
onConfirm={() => handleDeleteDevice(d)}
|
||
>
|
||
<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: 'Название',
|
||
key: 'name',
|
||
render: (_: unknown, row: RackItem) => {
|
||
if (editRack?.id === row.id) {
|
||
return (
|
||
<Space>
|
||
<span>{row.name}</span>
|
||
<Form form={editRackForm} layout="inline" size="small">
|
||
<Form.Item name="rack_type" style={{ marginBottom: 0 }}>
|
||
<Input placeholder="Тип (entry, exit...)" style={{ width: 130 }} />
|
||
</Form.Item>
|
||
<Form.Item name="location" style={{ marginBottom: 0 }}>
|
||
<Input placeholder="Расположение" style={{ width: 120 }} />
|
||
</Form.Item>
|
||
</Form>
|
||
<Button size="small" type="primary" onClick={handleSaveRack} loading={updateRack.isPending}>Сохранить</Button>
|
||
<Button size="small" onClick={() => setEditRack(null)}>Отмена</Button>
|
||
</Space>
|
||
)
|
||
}
|
||
return (
|
||
<span>
|
||
{row.name}
|
||
{row.rack_type ? <Tag style={{ marginLeft: 6 }}>{row.rack_type}</Tag> : null}
|
||
</span>
|
||
)
|
||
},
|
||
},
|
||
{ 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: 90,
|
||
render: (_: unknown, row: RackItem) => (
|
||
<Space>
|
||
<Button size="small" icon={<EditOutlined />} onClick={() => openEditRack(row)} />
|
||
<Popconfirm
|
||
title="Удалить стойку?"
|
||
description="Устройства останутся, привязка к стойке будет снята."
|
||
okText="Удалить"
|
||
cancelText="Отмена"
|
||
okButtonProps={{ danger: true }}
|
||
onConfirm={() => deleteRack.mutateAsync(row.id).catch(() => message.error('Ошибка удаления стойки'))}
|
||
>
|
||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
]
|
||
|
||
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>
|
||
<Button danger icon={<DeleteOutlined />} onClick={handleDeleteObject} loading={forceDeleteObject.isPending}>
|
||
Удалить объект
|
||
</Button>
|
||
</Space>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||
<Col>
|
||
<Input.Search
|
||
placeholder="Поиск по IP или hostname..."
|
||
allowClear
|
||
style={{ width: 360 }}
|
||
onChange={(e) => setSearchText(e.target.value)}
|
||
/>
|
||
</Col>
|
||
<Col>
|
||
<Popconfirm
|
||
title="Удалить все устройства?"
|
||
description={`Будет удалено ${devices?.length ?? 0} устройств. Отменить нельзя.`}
|
||
okText="Удалить все"
|
||
cancelText="Отмена"
|
||
okButtonProps={{ danger: true }}
|
||
onConfirm={handleDeleteAllDevices}
|
||
disabled={!devices?.length}
|
||
>
|
||
<Button
|
||
danger
|
||
icon={<DeleteOutlined />}
|
||
loading={deleteAllDevices.isPending}
|
||
disabled={!devices?.length}
|
||
>
|
||
Удалить все устройства
|
||
</Button>
|
||
</Popconfirm>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Table
|
||
dataSource={groupedTableData}
|
||
columns={deviceColumns}
|
||
rowKey={(row) => row._type === 'group' ? row._key : String((row as DeviceRow).id)}
|
||
loading={devLoading}
|
||
size="middle"
|
||
pagination={{ pageSize: 50 }}
|
||
/>
|
||
|
||
{/* Rack & Zone Management Modal */}
|
||
<Modal
|
||
title="Стойки и зоны"
|
||
open={rackModal}
|
||
onCancel={() => { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields(); setEditRack(null) }}
|
||
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: 160 }} />
|
||
</Form.Item>
|
||
<Form.Item name="rack_type">
|
||
<Input placeholder="Тип (entry, exit...)" style={{ width: 140 }} />
|
||
</Form.Item>
|
||
<Form.Item name="zone_id">
|
||
<Select placeholder="Зона (опц.)" allowClear style={{ width: 130 }} 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>
|
||
|
||
<DeviceEditModal
|
||
open={deviceModal}
|
||
device={editDevice}
|
||
objId={objId}
|
||
onClose={() => { setDeviceModal(false); setEditDevice(null) }}
|
||
/>
|
||
|
||
<DiscoveryDrawer
|
||
open={discoveryOpen}
|
||
onClose={() => setDiscoveryOpen(false)}
|
||
objectName={obj?.name ?? ''}
|
||
taskId={discoveryTaskId}
|
||
onComplete={handleDiscoveryComplete}
|
||
/>
|
||
|
||
{/* 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>
|
||
)
|
||
}
|