utp_service/frontend/src/pages/WorkObjectDetail.tsx
2026-04-09 14:47:05 +03:00

396 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
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<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={dayjs(d.last_seen).format('DD.MM.YYYY HH:mm:ss')}>
<span>{d.last_ping_ms}ms</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>
)
}