перенос объекта на одну страницу

This commit is contained in:
dv 2026-04-10 11:21:20 +03:00
parent c9e2275e4f
commit 36a94d3a66
5 changed files with 536 additions and 1805 deletions

View file

@ -13,7 +13,9 @@
"Bash(python create_objects_template.py)", "Bash(python create_objects_template.py)",
"Bash(python -c \":*)", "Bash(python -c \":*)",
"Bash(python -c \"import sys; data = sys.stdin.buffer.read\\(\\); print\\(data.decode\\(''''utf-8'''', errors=''''replace''''\\)\\)\")", "Bash(python -c \"import sys; data = sys.stdin.buffer.read\\(\\); print\\(data.decode\\(''''utf-8'''', errors=''''replace''''\\)\\)\")",
"Bash(docker compose:*)" "Bash(docker compose:*)",
"Bash(grep -r 8000 /c/Users/Professional/Documents/VSCode/utp_service/backend/ --include=*.py --include=*.sh)",
"Bash(grep -n \"rack_type\" /c/Users/Professional/Documents/VSCode/utp_service/backend/alembic/versions/*.py)"
] ]
} }
} }

View file

@ -8,8 +8,7 @@ import { ObjectsPage } from './pages/Objects'
import { SnapshotsPage } from './pages/Snapshots' import { SnapshotsPage } from './pages/Snapshots'
import { TaskDetailPage } from './pages/TaskDetail' import { TaskDetailPage } from './pages/TaskDetail'
import { TasksPage } from './pages/Tasks' import { TasksPage } from './pages/Tasks'
import { InventoryObjectDetailPage } from './pages/InventoryObjectDetail' import { ObjectDetailPage } from './pages/ObjectDetail'
import { WorkObjectDetailPage } from './pages/WorkObjectDetail'
import { WorkDevicesPage } from './pages/WorkDevices' import { WorkDevicesPage } from './pages/WorkDevices'
import { useAuthStore } from './store/auth' import { useAuthStore } from './store/auth'
@ -34,13 +33,13 @@ export default function App() {
{/* Инвентарь */} {/* Инвентарь */}
<Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} /> <Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} />
<Route path="inventory/objects/:id" element={<InventoryObjectDetailPage />} /> <Route path="inventory/objects/:id" element={<ObjectDetailPage defaultMode="inventory" />} />
<Route path="inventory/objects/:id/devices/:deviceId" element={<DeviceDetailPage />} /> <Route path="inventory/objects/:id/devices/:deviceId" element={<DeviceDetailPage />} />
<Route path="inventory/objects/:id/snapshots" element={<SnapshotsPage />} /> <Route path="inventory/objects/:id/snapshots" element={<SnapshotsPage />} />
{/* Работа */} {/* Работа */}
<Route path="work/objects" element={<ObjectsPage mode="work" />} /> <Route path="work/objects" element={<ObjectsPage mode="work" />} />
<Route path="work/objects/:id" element={<WorkObjectDetailPage />} /> <Route path="work/objects/:id" element={<ObjectDetailPage defaultMode="work" />} />
<Route path="work/devices" element={<WorkDevicesPage />} /> <Route path="work/devices" element={<WorkDevicesPage />} />
{/* Общие */} {/* Общие */}

View file

@ -1,798 +0,0 @@
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>
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,400 +0,0 @@
import {
ControlOutlined,
DatabaseOutlined,
PlayCircleOutlined,
ThunderboltOutlined,
WifiOutlined,
} from '@ant-design/icons'
import {
Breadcrumb,
Button,
Col,
Descriptions,
Form,
Input,
Modal,
Row,
Segmented,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useDevices, useObject, type DeviceItem } from '../api/objects'
import { useRacks } from '../api/racks'
import { useActions, useCreateTask, useTask } from '../api/tasks'
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import { elapsedAgo } from '../utils/time'
import dayjs from 'dayjs'
function PingButton({
scope,
objId,
size = 'small',
children,
}: {
scope: object
objId: number
size?: 'small' | 'middle' | 'large'
children?: React.ReactNode
}) {
const [taskId, setTaskId] = useState<string | null>(null)
const queryClient = useQueryClient()
const createTask = useCreateTask()
const { data: task } = useTask(taskId ?? undefined)
useEffect(() => {
if (task && ['success', 'failed', 'partial', 'stale', 'cancelled'].includes(task.status)) {
queryClient.invalidateQueries({ queryKey: ['devices', objId] })
setTaskId(null)
}
}, [task?.status, queryClient, objId])
const handlePing = async () => {
try {
const t = await createTask.mutateAsync({ type: 'ping', target_scope: scope, params: {} })
setTaskId(t.id)
} catch {
message.error('Ошибка запуска пинга')
}
}
return (
<Button
size={size}
loading={createTask.isPending || !!taskId}
icon={<WifiOutlined />}
onClick={handlePing}
>
{children}
</Button>
)
}
const SERVER_CATEGORIES = new Set(['main_server', 'vm', 'router'])
const COL_SPAN = 7
type GroupRow = { _type: 'group'; _key: string; label: string; count: number; onlineCount: number; rackScope?: object }
type DeviceRow = DeviceItem & { _type: 'device' }
type TableRow = GroupRow | DeviceRow
export function WorkObjectDetailPage() {
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: actions } = useActions()
const createTask = useCreateTask()
const [taskModal, setTaskModal] = useState<{ scope: object; title: string } | null>(null)
const [selectedAction, setSelectedAction] = useState<string>('')
const [searchText, setSearchText] = useState('')
const [form] = Form.useForm()
const currentAction = actions?.find((a) => a.name === selectedAction)
const openTaskModal = (scope: object, title: string) => {
setTaskModal({ scope, title })
setSelectedAction('')
form.resetFields()
}
const submitTask = async () => {
const values = await form.validateFields()
const { action, ...params } = values
try {
const task = await createTask.mutateAsync({
type: action,
target_scope: taskModal!.scope,
params,
})
message.success('Задача создана')
setTaskModal(null)
navigate(`/tasks/${task.id}`)
} 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 | null, { id: number | null; items: DeviceItem[] }>()
for (const d of rest) {
const key = d.rack_name ?? null
if (!rackMap.has(key)) rackMap.set(key, { id: d.rack_id, items: [] })
rackMap.get(key)!.items.push(d)
}
const rows: TableRow[] = []
const addGroup = (label: string, key: string, items: DeviceItem[], rackScope?: object) => {
rows.push({
_type: 'group',
_key: key,
label,
count: items.length,
onlineCount: items.filter((d) => d.status === 'online').length,
rackScope,
})
items.forEach((d) => rows.push({ ...d, _type: 'device' }))
}
if (servers.length > 0) {
addGroup('Серверная инфраструктура', '__servers__', servers, {
type: 'category',
object_id: objId,
category: 'main_server',
})
}
// Sort racks by name
const rackEntries = [...rackMap.entries()].filter(([k]) => k !== null) as [string, { id: number | null; items: DeviceItem[] }][]
rackEntries.sort(([a], [b]) => a.localeCompare(b))
for (const [rackName, { id: rackId, items }] of rackEntries) {
addGroup(
rackName,
`rack:${rackName}`,
items,
rackId != null ? { type: 'device_list', ids: items.map((d) => d.id) } : undefined,
)
}
const noRack = rackMap.get(null)?.items ?? []
if (noRack.length > 0) addGroup('Без привязки', '__norack__', noRack)
return rows
}, [filteredDevices, objId])
if (objLoading) return <Spin />
const deviceColumns = [
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
onCell: (row: TableRow) =>
row._type === 'group'
? { colSpan: 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>}
{row.rackScope && (
<>
<PingButton scope={row.rackScope} objId={objId}>
Пинг
</PingButton>
<Button
size="small"
icon={<PlayCircleOutlined />}
onClick={() => openTaskModal(row.rackScope!, `Задача: ${row.label}`)}
>
Задача на группу
</Button>
</>
)}
</Space>
)
}
return <code>{(row as DeviceRow).ip}</code>
},
},
{
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: 'Категория',
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={elapsedAgo(d.last_seen)}>
<span style={{ cursor: 'default' }}>
{dayjs(d.last_seen).format('DD.MM.YYYY HH:mm:ss')}
{d.last_ping_ms != null ? <span style={{ color: '#999', marginLeft: 4 }}>{d.last_ping_ms}мс</span> : null}
</span>
</Tooltip>
) : '—'
},
},
{
title: '',
key: 'actions',
width: 110,
onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
render: (_: unknown, row: TableRow) => {
if (row._type === 'group') return null
const d = row as DeviceRow
return (
<Button
size="small"
icon={<PlayCircleOutlined />}
onClick={() => openTaskModal({ type: 'device', id: d.id }, `Задача: ${d.hostname ?? d.ip}`)}
>
Задача
</Button>
)
},
},
]
// Breadcrumb: Работа → [City →] Object
const breadcrumbItems = [
{ title: <a onClick={() => navigate('/work/objects')}>Работа</a> },
...(obj?.city ? [{ title: <a onClick={() => navigate('/work/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="work"
options={[
{ label: 'Инвентарь', value: 'inventory', icon: <DatabaseOutlined /> },
{ label: 'Работа', value: 'work', icon: <ControlOutlined /> },
]}
onChange={(val) => { if (val === 'inventory') navigate(`/inventory/objects/${objId}`) }}
/>
</Col>
</Row>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col flex="auto">
<Descriptions size="small" bordered column={3}>
<Descriptions.Item label="Сервер">{obj?.server_ip ?? '—'}</Descriptions.Item>
<Descriptions.Item label="Клиент">{obj?.client ?? '—'}</Descriptions.Item>
<Descriptions.Item label="SSH">{obj?.ssh_user ?? '—'}</Descriptions.Item>
</Descriptions>
</Col>
<Col>
<Space>
<PingButton scope={{ type: 'object', id: objId }} objId={objId} size="middle">
Пинг объекта
</PingButton>
<Button
icon={<ThunderboltOutlined />}
onClick={() => openTaskModal({ type: 'object', id: objId }, 'Задача на весь объект')}
>
Задача на объект
</Button>
<Button
onClick={() =>
openTaskModal(
{ type: 'category', object_id: objId, category: 'main_server' },
'Задача: все серверы'
)
}
>
Все серверы
</Button>
<Button
onClick={() =>
openTaskModal(
{ type: 'category', object_id: objId, category: 'bank_terminal' },
'Задача: все терминалы'
)
}
>
Все терминалы
</Button>
</Space>
</Col>
</Row>
<Input.Search
placeholder="Поиск по IP или hostname..."
allowClear
style={{ marginBottom: 12, maxWidth: 360 }}
onChange={(e) => setSearchText(e.target.value)}
/>
<Table
dataSource={groupedTableData}
columns={deviceColumns}
rowKey={(row) => (row._type === 'group' ? `group:${row._key}` : `dev:${(row as DeviceRow).id}`)}
loading={devLoading}
size="middle"
pagination={false}
showHeader={groupedTableData.some((r) => r._type === 'device')}
/>
{/* Task Modal */}
<Modal
title={taskModal?.title ?? 'Создать задачу'}
open={!!taskModal}
onCancel={() => setTaskModal(null)}
onOk={submitTask}
confirmLoading={createTask.isPending}
okText="Запустить"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="action" label="Действие" rules={[{ required: true }]}>
<Select
placeholder="Выберите действие"
options={actions?.map((a) => ({ value: a.name, label: a.display_name }))}
onChange={setSelectedAction}
/>
</Form.Item>
{currentAction?.params_schema.map((p) => (
<Form.Item key={p.name} name={p.name} label={p.label} rules={p.required ? [{ required: true }] : []}>
<Input />
</Form.Item>
))}
</Form>
</Modal>
</div>
)
}