396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
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>
|
||
)
|
||
}
|