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 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 ? ( {d.last_ping_ms}ms ) : '—' }, }, { 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="Запустить" >
))}
) }