utp_service/frontend/src/pages/InventoryObjectDetail.tsx

798 lines
30 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,
ControlOutlined,
DatabaseOutlined,
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,
Segmented,
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 { getDeviceWebUrl } from '../utils/deviceUrl'
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<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>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col><Breadcrumb items={breadcrumbItems} /></Col>
<Col>
{/* TODO (Вариант В): заменить Segmented на <Tabs> с вкладками Устройства / Задачи / Снапшоты */}
<Segmented
value="inventory"
options={[
{ label: 'Инвентарь', value: 'inventory', icon: <DatabaseOutlined /> },
{ label: 'Работа', value: 'work', icon: <ControlOutlined /> },
]}
onChange={(val) => { if (val === 'work') navigate(`/work/objects/${objId}`) }}
/>
</Col>
</Row>
<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={<ApiOutlined />} loading={discoverMutation.isPending} onClick={handleDiscover}>
SSH Discovery
</Button>
{/*
<Button icon={<SyncOutlined />} loading={syncMutation.isPending} onClick={handleSync}>
Синхронизировать из БД
</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>
)
}