From 36a94d3a669119f8be89ad553180a54b624709ff Mon Sep 17 00:00:00 2001 From: dv Date: Fri, 10 Apr 2026 11:21:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D1=83=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +- frontend/src/App.tsx | 7 +- frontend/src/pages/InventoryObjectDetail.tsx | 798 ------------ frontend/src/pages/ObjectDetail.tsx | 1132 ++++++++---------- frontend/src/pages/WorkObjectDetail.tsx | 400 ------- 5 files changed, 536 insertions(+), 1805 deletions(-) delete mode 100644 frontend/src/pages/InventoryObjectDetail.tsx delete mode 100644 frontend/src/pages/WorkObjectDetail.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 26b8dfc..77c80c7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,9 @@ "Bash(python create_objects_template.py)", "Bash(python -c \":*)", "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)" ] } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 48e8afd..e488693 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,8 +8,7 @@ import { ObjectsPage } from './pages/Objects' import { SnapshotsPage } from './pages/Snapshots' import { TaskDetailPage } from './pages/TaskDetail' import { TasksPage } from './pages/Tasks' -import { InventoryObjectDetailPage } from './pages/InventoryObjectDetail' -import { WorkObjectDetailPage } from './pages/WorkObjectDetail' +import { ObjectDetailPage } from './pages/ObjectDetail' import { WorkDevicesPage } from './pages/WorkDevices' import { useAuthStore } from './store/auth' @@ -34,13 +33,13 @@ export default function App() { {/* Инвентарь */} } /> - } /> + } /> } /> } /> {/* Работа */} } /> - } /> + } /> } /> {/* Общие */} diff --git a/frontend/src/pages/InventoryObjectDetail.tsx b/frontend/src/pages/InventoryObjectDetail.tsx deleted file mode 100644 index 20fdb7a..0000000 --- a/frontend/src/pages/InventoryObjectDetail.tsx +++ /dev/null @@ -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(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 - const webUrl = getDeviceWebUrl(d) - return webUrl ? ( - - - {d.ip} - - - ) : ( - {d.ip} - ) - }, - }, - { - 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 - return ( - navigate(`/inventory/objects/${objId}/devices/${d.id}`)}> - {d.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)} - if (d.location === 'server_room') return Серверная - if (d.location === '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 - -
- - ) -} diff --git a/frontend/src/pages/ObjectDetail.tsx b/frontend/src/pages/ObjectDetail.tsx index 2c2baf4..cb44858 100644 --- a/frontend/src/pages/ObjectDetail.tsx +++ b/frontend/src/pages/ObjectDetail.tsx @@ -1,27 +1,23 @@ import { + ApiOutlined, ControlOutlined, DatabaseOutlined, DeleteOutlined, EditOutlined, FileTextOutlined, - GoogleOutlined, PlayCircleOutlined, PlusOutlined, - SyncOutlined, ThunderboltOutlined, - UploadOutlined, + WifiOutlined, } from '@ant-design/icons' import { - Alert, Breadcrumb, Button, - Checkbox, Col, Descriptions, Divider, Form, Input, - InputNumber, Modal, Popconfirm, Row, @@ -29,91 +25,204 @@ import { Select, Space, Spin, + Switch, Table, Tag, Tooltip, Typography, - Upload, message, } from 'antd' -import type { UploadFile } from 'antd' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { useNavigate, useParams } from 'react-router-dom' import { - useCreateDevice, useDeleteDevice, - useUpdateDevice, + useDeleteAllDevices, + useForceDeleteObject, + useDiscoverObject, useDevices, - useImportCSV, useObject, - usePreviewCSV, - useSyncObject, - useSyncSheets, - type DeviceImportPreview, type DeviceItem, } from '../api/objects' -import { useRacks, useCreateRack, useDeleteRack, type RackItem } from '../api/racks' +import { useRacks, useCreateRack, useUpdateRack, useDeleteRack, type RackItem } from '../api/racks' import { useZones, useCreateZone, useDeleteZone, type ZoneItem } from '../api/zones' -import { useActions, useCreateTask } from '../api/tasks' +import { useActions, useCreateTask, useTask } from '../api/tasks' 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 { elapsedAgo } from '../utils/time' import dayjs from 'dayjs' -export function ObjectDetailPage() { +type Mode = 'inventory' | 'work' + +const SERVER_CATEGORIES = new Set(['main_server', 'vm', 'router']) + +const rackLabel = (name: string, type: string | null | undefined): string => + type ? `${name} ${type}` : name + +type GroupRow = { + _type: 'group' + _key: string + label: string + count: number + onlineCount: number + rackScope?: object +} +type DeviceRow = DeviceItem & { _type: 'device' } +type TableRow = GroupRow | DeviceRow + +// Inventory cols: IP, Hostname, Rack, Category, Status, Ping, Source, Actions = 8 +// Work cols: IP, Hostname, Category, Status, Ping, Actions = 6 +const COL_SPAN: Record = { inventory: 8, work: 6 } + +function PingButton({ + scope, + objId, + size = 'small', + children, +}: { + scope: object + objId: number + size?: 'small' | 'middle' | 'large' + children?: React.ReactNode +}) { + const [taskId, setTaskId] = useState(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 ( + + ) +} + +export function ObjectDetailPage({ defaultMode = 'inventory' }: { defaultMode?: Mode }) { const { id } = useParams<{ id: string }>() const objId = Number(id) const navigate = useNavigate() + const queryClient = useQueryClient() + + const [mode, setMode] = useState(defaultMode) + const [editMode, setEditMode] = useState(false) 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 syncSheetsMutation = useSyncSheets(objId) const { data: actions } = useActions() - const createTask = useCreateTask() - const createDevice = useCreateDevice(objId) - const updateDevice = useUpdateDevice(objId) + + const discoverMutation = useDiscoverObject(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 createTask = useCreateTask() - const [taskModal, setTaskModal] = useState<{ scope: object } | null>(null) const [deviceModal, setDeviceModal] = useState(false) const [editDevice, setEditDevice] = useState(null) - const [csvModal, setCsvModal] = useState(false) const [rackModal, setRackModal] = useState(false) - const [sheetsModal, setSheetsModal] = useState(false) - const [csvFile, setCsvFile] = useState(null) - const [csvPreview, setCsvPreview] = useState(null) + const [discoveryTaskId, setDiscoveryTaskId] = useState(null) + const [discoveryOpen, setDiscoveryOpen] = useState(false) + const [editRack, setEditRack] = useState(null) const [searchText, setSearchText] = useState('') + const [taskModal, setTaskModal] = useState<{ scope: object; title: string } | null>(null) + const [selectedAction, setSelectedAction] = useState('') - const [form] = Form.useForm() - const [deviceForm] = Form.useForm() const [rackForm] = Form.useForm() const [zoneForm] = Form.useForm() - const [sheetsForm] = Form.useForm() - const [selectedAction, setSelectedAction] = useState('') + const [taskForm] = Form.useForm() + const [editRackForm] = Form.useForm() const currentAction = actions?.find((a) => a.name === selectedAction) - const rackOptions = useMemo( - () => (racks ?? []).map((r) => ({ value: r.id, label: r.name })), - [racks] - ) + const handleDiscover = async () => { + try { + const { task_id } = await discoverMutation.mutateAsync() + setDiscoveryTaskId(task_id) + setDiscoveryOpen(true) + } catch { + message.error('Ошибка SSH discovery') + } + } - const openTaskModal = (scope: object) => { - setTaskModal({ scope }) + const handleDiscoveryComplete = () => { + queryClient.invalidateQueries({ queryKey: ['devices', objId] }) + } + + 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 openTaskModal = (scope: object, title: string) => { + setTaskModal({ scope, title }) setSelectedAction('') - form.resetFields() + taskForm.resetFields() } const submitTask = async () => { - const values = await form.validateFields() + const values = await taskForm.validateFields() const { action, ...params } = values try { const task = await createTask.mutateAsync({ @@ -129,122 +238,10 @@ export function ObjectDetailPage() { } } - const handleSync = async () => { - try { - const result = await syncMutation.mutateAsync() - message.success(`Синхронизировано: +${result.created} новых, ${result.updated} обновлено`) - } catch { - message.error('Ошибка синхронизации') - } - } - - 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) - // validation errors are shown inline — no extra message needed - } - } - - const openAddDevice = () => { - deviceForm.resetFields() - setEditDevice(null) - setDeviceModal(true) - } - - const openEditDevice = (row: DeviceItem) => { - deviceForm.resetFields() - deviceForm.setFieldsValue({ - ip: row.ip, - hostname: row.hostname ?? '', - category: row.category, - role: row.role, - model: row.model ?? '', - mac: row.mac ?? '', - rack_id: row.rack_id ?? undefined, - monitor_enabled: row.monitor_enabled ?? false, - ssh_user_override: row.ssh_user_override ?? '', - ssh_port_override: row.ssh_port_override ?? undefined, - }) - setEditDevice(row) - setDeviceModal(true) - } - - const handleSaveDevice = async () => { - const values = await deviceForm.validateFields() - const payload = stripEmpty(values) - // rack_id=null means "detach rack" — allow explicit null - if ('rack_id' in values && values.rack_id === undefined) { - payload.rack_id = null - } - try { - if (editDevice) { - await updateDevice.mutateAsync({ id: editDevice.id, body: payload as any }) - message.success('Устройство обновлено') - } else { - await createDevice.mutateAsync(payload as any) - message.success('Устройство добавлено') - } - setDeviceModal(false) - setEditDevice(null) - deviceForm.resetFields() - } catch { - message.error(editDevice ? 'Ошибка при обновлении устройства' : 'Ошибка при добавлении устройства') - } - } - - const handleDeleteDevice = async (row: DeviceItem) => { - try { - await deleteDevice.mutateAsync(row.id) - message.success(`Устройство ${row.ip} удалено`) - } catch { - message.error('Ошибка при удалении устройства') - } - } - - const handleCsvPreview = async () => { - if (!csvFile) return - try { - const preview = await previewCSV.mutateAsync(csvFile) - setCsvPreview(preview) - } catch { - message.error('Не удалось разобрать CSV') - } - } - - const handleCsvImport = async () => { - if (!csvFile) return - try { - const result = await importCSV.mutateAsync(csvFile) - message.success(`Импортировано: +${result.created} создано, ${result.updated} обновлено`) - setCsvModal(false) - setCsvFile(null) - setCsvPreview(null) - } catch { - message.error('Ошибка при импорте CSV') - } - } - - const closeCsvModal = () => { - setCsvModal(false) - setCsvFile(null) - setCsvPreview(null) - } - const handleAddZone = async () => { const values = await zoneForm.validateFields() - const payload = stripEmpty(values) try { - await createZone.mutateAsync(payload as any) + await createZone.mutateAsync(stripEmpty(values) as any) message.success('Зона добавлена') zoneForm.resetFields() } catch { @@ -254,9 +251,8 @@ export function ObjectDetailPage() { const handleAddRack = async () => { const values = await rackForm.validateFields() - const payload = stripEmpty(values) try { - await createRack.mutateAsync(payload as any) + await createRack.mutateAsync(stripEmpty(values) as any) message.success('Стойка добавлена') rackForm.resetFields() } catch { @@ -264,6 +260,23 @@ export function ObjectDetailPage() { } } + 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() @@ -272,178 +285,301 @@ export function ObjectDetailPage() { ) }, [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, { rackId: d.rack_id, type: d.rack_type, items: [] }) + } + rackMap.get(d.rack_name)!.items.push(d) + } else { + noRack.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', + }) + } + + const sortedRacks = [...rackMap.entries()].sort(([a], [b]) => a.localeCompare(b)) + for (const [rackName, { rackId, type, items }] of sortedRacks) { + addGroup( + rackLabel(rackName, type), + `rack:${rackName}`, + items, + rackId != null ? { type: 'device_list', ids: items.map((d) => d.id) } : undefined, + ) + } + + if (noRack.length > 0) addGroup('Без привязки', '__norack__', noRack) + + return rows + }, [filteredDevices, objId]) + if (objLoading) return + const colSpan = COL_SPAN[mode] + const deviceColumns = [ { title: 'IP', dataIndex: 'ip', key: 'ip', - render: (ip: string) => {ip}, + onCell: (row: TableRow) => + row._type === 'group' + ? { colSpan, 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}} + {mode === 'work' && row.rackScope && ( + <> + Пинг + + + )} + + ) + } + const d = row as DeviceRow + const webUrl = getDeviceWebUrl(d) + return webUrl ? ( + + + {d.ip} + + + ) : ( + {d.ip} + ) + }, }, { title: 'Hostname', dataIndex: 'hostname', key: 'hostname', - render: (h: string | null) => h ?? '—', - }, - { - title: 'Стойка', - dataIndex: 'rack_name', - key: 'rack_name', - render: (name: string | null) => - name ? {name} : , - filters: [ - { text: 'Без стойки', value: '__none__' }, - ...(racks ?? []).map((r) => ({ text: r.name, value: r.name })), - ], - onFilter: (value: unknown, record: DeviceItem) => { - if (value === '__none__') return record.rack_name === null - return record.rack_name === value + 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 + return ( + navigate(`/inventory/objects/${objId}/devices/${d.id}`)}> + {d.hostname} + + ) }, }, + ...(mode === 'inventory' ? [{ + 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)} + if (d.location === 'server_room') return Серверная + if (d.location === 'street') return Улица + return + }, + }] : []), { title: 'Категория', dataIndex: 'category', key: 'category', - render: (c: string) => , - filters: ['raspberry', 'mikrotik', 'camera', 'embedded', 'server', 'other'].map((v) => ({ - text: v, - value: v, - })), - onFilter: (value: unknown, record: DeviceItem) => record.category === value, - }, - { - title: 'Роль', - dataIndex: 'role', - key: 'role', - render: (r: string) => {r}, + onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, + render: (_: unknown, row: TableRow) => + row._type === 'group' ? null : , }, { title: 'Статус', dataIndex: 'status', key: 'status', - render: (s: string) => , + onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, + render: (_: unknown, row: TableRow) => + row._type === 'group' ? null : , }, { title: 'Последний ping', dataIndex: 'last_seen', key: 'last_seen', - render: (ts: string | null, row: DeviceItem) => - ts ? ( - - {row.last_ping_ms}ms + 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.last_seen) return '—' + return ( + + + {dayjs(d.last_seen).format('DD.MM.YYYY HH:mm')} + - ) : ( - '—' - ), + ) + }, }, + ...(mode === 'inventory' ? [{ + 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: 150, - render: (_: unknown, row: DeviceItem) => ( - - - + ) + } + if (!editMode) return null + 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: 80, + width: 90, render: (_: unknown, row: RackItem) => ( - -
- navigate('/work/objects')}>Объекты }, - { title: obj?.name }, - ]} - /> - - - {/* TODO (Вариант В): заменить Segmented на с вкладками Устройства / Задачи / Снапшоты */} }, { label: 'Работа', value: 'work', icon: }, ]} - onChange={(val) => { if (val === 'inventory') navigate(`/inventory/objects/${objId}`) }} + onChange={(val) => setMode(val as Mode)} /> + {/* Object info + action buttons */} - + {obj?.server_ip ?? '—'} {obj?.ssh_user ?? '—'} {obj?.db_host ?? '—'} + {obj?.client ?? '—'} - - {/* - - - - */} - - - - - + {mode === 'inventory' ? ( + + + + + + + + ) : ( + + + Пинг объекта + + + + + )} - setSearchText(e.target.value)} - /> + {/* Search + edit toggle + delete all */} + + + + setSearchText(e.target.value)} + /> + {mode === 'inventory' && ( + + + + Режим редактирования + + + )} + + + {mode === 'inventory' && editMode && ( + + + + + + )} +
row._type === 'group' ? row._key : String((row as DeviceRow).id)} loading={devLoading} size="middle" - pagination={{ pageSize: 50 }} + pagination={mode === 'inventory' ? { pageSize: 50 } : false} + showHeader={groupedTableData.some((r) => r._type === 'device')} /> - {/* ── Rack & Zone Management Modal ── */} + {/* Rack & Zone Modal */} { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields() }} + onCancel={() => { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields(); setEditRack(null) }} footer={null} width={820} > - {/* ── Zones ── */} - - Зоны - + Зоны
- + - @@ -598,17 +746,19 @@ export function ObjectDetailPage() { - {/* ── Racks ── */} Стойки - + + + + - @@ -636,243 +781,16 @@ export function ObjectDetailPage() { />
- {/* ── CSV Import Modal ── */} + {/* Task Modal */} - - {!csvPreview && ( - - )} - {csvPreview && ( - - )} - - } - > - {!csvPreview ? ( - <> - - Формат CSV: ip, hostname, category, role, model, mac, notes - - { setCsvFile(file); return false }} - onRemove={() => setCsvFile(null)} - fileList={csvFile ? [{ uid: '1', name: csvFile.name, status: 'done' } as UploadFile] : []} - > -

- -

-

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

-
- - ) : ( - <> - {csvPreview.errors.length > 0 && ( - `Строка ${e.row}: ${e.error}`).join('\n')} - /> - )} - {csvPreview.would_create.length > 0 && ( - <> - - Будет создано: {csvPreview.would_create.length} - -
- - )} - {csvPreview.would_update.length > 0 && ( - <> - - Будет обновлено: {csvPreview.would_update.length} - -
- - )} - - )} - - - {/* ── Add / Edit Device Modal ── */} - { setDeviceModal(false); setEditDevice(null); deviceForm.resetFields() }} - onOk={handleSaveDevice} - confirmLoading={createDevice.isPending || updateDevice.isPending} - okText={editDevice ? 'Сохранить' : 'Добавить'} - width={600} - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Мониторинг (автоматический ping) - - - - SSH (переопределить настройки объекта) - - - - - - - - - - - - - - - - - - - - - - - {/* ── Google Sheets Sync Modal ── */} - { setSheetsModal(false); sheetsForm.resetFields() }} - onOk={handleSyncSheets} - confirmLoading={syncSheetsMutation.isPending} - okText="Синхронизировать" - > -
- - - - - - - - - Ожидаемые столбцы: ip, hostname, category, role, model, mac, notes - -
- - {/* ── Task Modal ── */} - setTaskModal(null)} onOk={submitTask} confirmLoading={createTask.isPending} okText="Запустить" > -
+ ))}
+ + { setDeviceModal(false); setEditDevice(null) }} + /> + + setDiscoveryOpen(false)} + objectName={obj?.name ?? ''} + taskId={discoveryTaskId} + onComplete={handleDiscoveryComplete} + /> ) } diff --git a/frontend/src/pages/WorkObjectDetail.tsx b/frontend/src/pages/WorkObjectDetail.tsx deleted file mode 100644 index cc3cc33..0000000 --- a/frontend/src/pages/WorkObjectDetail.tsx +++ /dev/null @@ -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(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 ( - - ) -} - -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('') - 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() - 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 - - 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 ( - - {row.label} - {row.count} устройств - {row.onlineCount > 0 && Online: {row.onlineCount}} - {row.rackScope && ( - <> - - Пинг - - - - )} - - ) - } - return {(row as DeviceRow).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: 'Категория', - 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 ? ( - - - {dayjs(d.last_seen).format('DD.MM.YYYY HH:mm:ss')} - {d.last_ping_ms != null ? {d.last_ping_ms}мс : null} - - - ) : '—' - }, - }, - { - 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 ( - - ) - }, - }, - ] - - // Breadcrumb: Работа → [City →] Object - const breadcrumbItems = [ - { title: navigate('/work/objects')}>Работа }, - ...(obj?.city ? [{ title: navigate('/work/objects')}>{obj.city} }] : []), - { title: obj?.name }, - ] - - return ( -
- -
- - {/* TODO (Вариант В): заменить Segmented на с вкладками Устройства / Задачи / Снапшоты */} - }, - { label: 'Работа', value: 'work', icon: }, - ]} - onChange={(val) => { if (val === 'inventory') navigate(`/inventory/objects/${objId}`) }} - /> - - - - - - - {obj?.server_ip ?? '—'} - {obj?.client ?? '—'} - {obj?.ssh_user ?? '—'} - - - - - - Пинг объекта - - - - - - - - - setSearchText(e.target.value)} - /> - -
(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 */} - setTaskModal(null)} - onOk={submitTask} - confirmLoading={createTask.isPending} - okText="Запустить" - > -
- - - - ))} - -
- - ) -}