+ {/* Breadcrumb + mode switcher */}
+
- 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 ?? '—'}
-
- {/*
- } loading={syncMutation.isPending} onClick={handleSync}>
- Синхронизировать из БД
-
- } onClick={() => setCsvModal(true)}>
- Импорт CSV
-
- } onClick={() => setSheetsModal(true)}>
- Google Sheets
-
- */}
- } onClick={openAddDevice}>
- Добавить устройство
-
- }
- onClick={() => navigate(`/objects/${objId}/snapshots`)}
- >
- Снапшоты
-
- } onClick={() => setRackModal(true)}>
- Стойки и зоны
-
- }
- onClick={() => openTaskModal({ type: 'object', id: objId })}
- >
- Задача на весь объект
-
-
+ {mode === 'inventory' ? (
+
+ } loading={discoverMutation.isPending} onClick={handleDiscover}>
+ SSH Discovery
+
+ } type="primary" onClick={() => { setEditDevice(null); setDeviceModal(true) }}>
+ Добавить устройство
+
+ } onClick={() => navigate(`/inventory/objects/${objId}/snapshots`)}>
+ Снапшоты
+
+ } onClick={() => setRackModal(true)}>
+ Стойки и зоны
+
+ } onClick={handleDeleteObject} loading={forceDeleteObject.isPending}>
+ Удалить объект
+
+
+ ) : (
+
+
+ Пинг объекта
+
+ }
+ onClick={() => openTaskModal({ type: 'object', id: objId }, 'Задача на весь объект')}
+ >
+ Задача на объект
+
+
+
+ )}
-
setSearchText(e.target.value)}
- />
+ {/* Search + edit toggle + delete all */}
+
+
+
+ setSearchText(e.target.value)}
+ />
+ {mode === 'inventory' && (
+
+
+
+ Режим редактирования
+
+
+ )}
+
+
+ {mode === 'inventory' && editMode && (
+
+
+ }
+ loading={deleteAllDevices.isPending}
+ disabled={!devices?.length}
+ >
+ Удалить все устройства
+
+
+
+ )}
+
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 ── */}
-
- Зоны
-
+ Зоны
-
+
- }
- onClick={handleAddZone}
- loading={createZone.isPending}
- >
+ } onClick={handleAddZone} loading={createZone.isPending}>
Добавить зону
@@ -598,17 +746,19 @@ export function ObjectDetailPage() {
- {/* ── Racks ── */}
Стойки
-
+
+
+
+
@@ -616,12 +766,7 @@ export function ObjectDetailPage() {
- }
- onClick={handleAddRack}
- loading={createRack.isPending}
- >
+ } onClick={handleAddRack} loading={createRack.isPending}>
Добавить стойку
@@ -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="Запустить"
>
-
{currentAction?.params_schema.map((p) => (
-
+
))}
+
+ { 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 (
- }
- onClick={handlePing}
- >
- {children}
-
- )
-}
-
-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 && (
- <>
-
- Пинг
-
- }
- onClick={() => openTaskModal(row.rackScope!, `Задача: ${row.label}`)}
- >
- Задача на группу
-
- >
- )}
-
- )
- }
- 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 (
- }
- onClick={() => openTaskModal({ type: 'device', id: d.id }, `Задача: ${d.hostname ?? d.ip}`)}
- >
- Задача
-
- )
- },
- },
- ]
-
- // 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 ?? '—'}
-
-
-
-
-
- Пинг объекта
-
- }
- onClick={() => openTaskModal({ type: 'object', id: objId }, 'Задача на весь объект')}
- >
- Задача на объект
-
-
-
-
-
-
-
-
setSearchText(e.target.value)}
- />
-
-