utp_service/frontend/src/pages/DeviceDetail.tsx
2026-04-09 11:23:27 +03:00

621 lines
22 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 {
CheckCircleOutlined,
ClockCircleOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
} from '@ant-design/icons'
import {
Badge,
Breadcrumb,
Button,
Card,
Col,
Descriptions,
Popconfirm,
Result,
Row,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd'
import dayjs from 'dayjs'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useAlerts, useAcknowledgeAlert, useResolveAlert, type AlertItem } from '../api/alerts'
import { useDevice, useDeleteDevice, useObject } from '../api/objects'
import { useSnapshots, useSnapshotDiff } from '../api/snapshots'
import { useDeviceTasks, useTask, type Task } from '../api/tasks'
import { DeviceEditModal } from '../components/DeviceEditModal'
import { CategoryTag, DeviceStatusBadge, TaskStatusBadge } from '../components/StatusBadge'
import { useMonitorSSE } from '../hooks/useMonitorSSE'
const ALERT_STATUS_COLOR: Record<string, string> = {
open: 'error',
acknowledged: 'warning',
resolved: 'success',
}
const ALERT_STATUS_LABEL: Record<string, string> = {
open: 'Открыт',
acknowledged: 'Подтверждён',
resolved: 'Закрыт',
}
const LOCATION_LABEL: Record<string, string> = {
server_room: 'Серверная',
street: 'Улица',
}
// ─── Alerts tab ─────────────────────────────────────────────────────────────
function AlertsTab({ deviceId }: { deviceId: number }) {
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
const { data: alerts, isLoading } = useAlerts({ device_id: deviceId, status: statusFilter })
const acknowledge = useAcknowledgeAlert()
const resolve = useResolveAlert()
const columns = [
{
title: 'Сообщение',
dataIndex: 'message',
key: 'message',
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 140,
render: (s: string) => (
<Badge status={ALERT_STATUS_COLOR[s] as any} text={ALERT_STATUS_LABEL[s] ?? s} />
),
},
{
title: 'Создан',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (v: string) => dayjs(v).format('DD.MM.YY HH:mm'),
},
{
title: 'Подтверждён',
dataIndex: 'acknowledged_by_username',
key: 'ack',
width: 140,
render: (v: string | null, row: AlertItem) =>
v ? (
<Tooltip title={row.acknowledged_at ? dayjs(row.acknowledged_at).format('DD.MM.YY HH:mm') : ''}>
<Typography.Text type="secondary">{v}</Typography.Text>
</Tooltip>
) : '—',
},
{
title: '',
key: 'actions',
width: 120,
render: (_: unknown, row: AlertItem) => (
<Space size={4}>
{row.status === 'open' && (
<Tooltip title="Подтвердить">
<Button
size="small"
icon={<CheckCircleOutlined />}
loading={acknowledge.isPending}
onClick={() => acknowledge.mutateAsync(row.id).catch(() => message.error('Ошибка'))}
/>
</Tooltip>
)}
{row.status !== 'resolved' && (
<Tooltip title="Закрыть">
<Button
size="small"
danger
icon={<DeleteOutlined />}
loading={resolve.isPending}
onClick={() => resolve.mutateAsync(row.id).catch(() => message.error('Ошибка'))}
/>
</Tooltip>
)}
</Space>
),
},
]
return (
<>
<div style={{ marginBottom: 12 }}>
<Select
placeholder="Все статусы"
allowClear
style={{ width: 180 }}
value={statusFilter}
onChange={setStatusFilter}
options={[
{ value: 'open', label: 'Открытые' },
{ value: 'acknowledged', label: 'Подтверждённые' },
{ value: 'resolved', label: 'Закрытые' },
]}
/>
</div>
<Table
dataSource={alerts ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="small"
pagination={{ pageSize: 20 }}
locale={{ emptyText: 'Нет алертов' }}
/>
</>
)
}
// ─── Snapshots tab ───────────────────────────────────────────────────────────
function SnapshotsTab({ objId, deviceId }: { objId: number; deviceId: number }) {
const { data: snapshots, isLoading } = useSnapshots(objId, { device_id: deviceId })
const [selectedA, setSelectedA] = useState<number | null>(null)
const [selectedB, setSelectedB] = useState<number | null>(null)
const { data: diffData, isFetching: diffLoading } = useSnapshotDiff(objId, selectedA, selectedB)
const snapshotOptions = (snapshots ?? []).map((s) => ({
value: s.id,
label: `${dayjs(s.created_at).format('DD.MM.YY HH:mm')}${s.sha256.slice(0, 8)}`,
}))
const columns = [
{
title: 'Дата',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (v: string) => dayjs(v).format('DD.MM.YY HH:mm:ss'),
},
{
title: 'SHA-256',
dataIndex: 'sha256',
key: 'sha256',
render: (v: string) => (
<Tooltip title={v}>
<code>{v.slice(0, 12)}</code>
</Tooltip>
),
},
{
title: 'Размер',
dataIndex: 'size',
key: 'size',
width: 100,
render: (v: number) => `${v} Б`,
},
{
title: 'Метка',
dataIndex: 'label',
key: 'label',
render: (v: string | null) => v ?? '—',
},
]
const renderDiff = (diff: string) => {
if (!diff) return <Typography.Text type="secondary">Нет изменений</Typography.Text>
return (
<pre style={{ fontSize: 12, maxHeight: 500, overflow: 'auto', background: '#fafafa', padding: 12, border: '1px solid #e8e8e8', borderRadius: 6 }}>
{diff.split('\n').map((line, i) => {
let color = 'inherit'
if (line.startsWith('+') && !line.startsWith('+++')) color = '#237804'
else if (line.startsWith('-') && !line.startsWith('---')) color = '#a8071a'
else if (line.startsWith('@@')) color = '#096dd9'
return (
<span key={i} style={{ color, display: 'block' }}>{line}</span>
)
})}
</pre>
)
}
return (
<>
<Table
dataSource={snapshots ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="small"
pagination={{ pageSize: 20 }}
locale={{ emptyText: 'Снапшоты не найдены' }}
style={{ marginBottom: 24 }}
/>
<Typography.Title level={5} style={{ marginBottom: 12 }}>Сравнить снапшоты</Typography.Title>
<Space style={{ marginBottom: 16 }} wrap>
<Select
placeholder="Снапшот A (старый)"
style={{ width: 280 }}
options={snapshotOptions}
value={selectedA}
onChange={setSelectedA}
allowClear
/>
<Select
placeholder="Снапшот B (новый)"
style={{ width: 280 }}
options={snapshotOptions}
value={selectedB}
onChange={setSelectedB}
allowClear
/>
</Space>
{diffLoading && <Spin />}
{diffData && !diffLoading && (
diffData.is_identical
? <Typography.Text type="secondary">Снапшоты идентичны</Typography.Text>
: renderDiff(diffData.diff)
)}
</>
)
}
// ─── Tasks tab ───────────────────────────────────────────────────────────────
function TaskResultRow({ taskId, deviceId }: { taskId: string; deviceId: number }) {
const { data: detail, isLoading } = useTask(taskId)
const deviceResult = detail?.device_results.find((r) => r.device_id === deviceId)
if (isLoading) return <Spin size="small" />
if (!deviceResult) return <Typography.Text type="secondary">Нет данных</Typography.Text>
return (
<div style={{ padding: '8px 16px', background: '#fafafa', borderRadius: 4 }}>
<Space size={16} style={{ marginBottom: 8 }}>
<span><b>Статус:</b> {deviceResult.status}</span>
{deviceResult.exit_code !== null && <span><b>Код:</b> {deviceResult.exit_code}</span>}
{deviceResult.duration_ms !== null && <span><b>Время:</b> {deviceResult.duration_ms}мс</span>}
{deviceResult.executed_at && (
<span><b>Выполнено:</b> {dayjs(deviceResult.executed_at).format('DD.MM.YY HH:mm:ss')}</span>
)}
</Space>
{deviceResult.stdout && (
<pre style={{ fontSize: 11, maxHeight: 200, overflow: 'auto', background: '#fff', padding: 8, border: '1px solid #e8e8e8', borderRadius: 4, marginBottom: 4 }}>
{deviceResult.stdout}
</pre>
)}
{deviceResult.stderr && (
<pre style={{ fontSize: 11, maxHeight: 100, overflow: 'auto', background: '#fff4f4', padding: 8, border: '1px solid #ffa39e', borderRadius: 4, color: '#a8071a' }}>
{deviceResult.stderr}
</pre>
)}
{deviceResult.data && Object.keys(deviceResult.data).length > 0 && (
<pre style={{ fontSize: 11, maxHeight: 200, overflow: 'auto', background: '#f6ffed', padding: 8, border: '1px solid #b7eb8f', borderRadius: 4 }}>
{JSON.stringify(deviceResult.data, null, 2)}
</pre>
)}
</div>
)
}
function TasksTab({ deviceId }: { deviceId: number }) {
const navigate = useNavigate()
const { data: tasks, isLoading } = useDeviceTasks(deviceId)
const columns = [
{
title: 'Тип',
dataIndex: 'type',
key: 'type',
render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code>,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 140,
render: (v: string) => <TaskStatusBadge status={v} />,
},
{
title: 'Цель',
dataIndex: 'target_label',
key: 'target_label',
render: (v: string | null) => v ?? '—',
},
{
title: 'Создана',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (v: string) => dayjs(v).format('DD.MM.YY HH:mm'),
},
{
title: 'Завершена',
dataIndex: 'finished_at',
key: 'finished_at',
width: 160,
render: (v: string | null) => v ? dayjs(v).format('DD.MM.YY HH:mm') : '—',
},
{
title: '',
key: 'actions',
width: 80,
render: (_: unknown, row: Task) => (
<Tooltip title="Открыть задачу">
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => navigate(`/tasks/${row.id}`)}
/>
</Tooltip>
),
},
]
return (
<Table
dataSource={tasks ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="small"
pagination={{ pageSize: 20 }}
locale={{ emptyText: 'Задачи не найдены' }}
expandable={{
expandedRowRender: (row: Task) => (
<TaskResultRow taskId={row.id} deviceId={deviceId} />
),
}}
/>
)
}
// ─── Main page ───────────────────────────────────────────────────────────────
export function DeviceDetailPage() {
const { id: objIdStr, deviceId: deviceIdStr } = useParams<{ id: string; deviceId: string }>()
const objId = Number(objIdStr)
const deviceId = Number(deviceIdStr)
const navigate = useNavigate()
const { data: obj } = useObject(objId)
const { data: device, isLoading } = useDevice(objId, deviceId)
const deleteDevice = useDeleteDevice(objId)
const [editOpen, setEditOpen] = useState(false)
const [activeTab, setActiveTab] = useState('info')
const [liveStatus, setLiveStatus] = useState<{ status: string; ping_ms: number | null } | null>(null)
useMonitorSSE({
onDeviceStatus: (ev) => {
if (ev.device_id === deviceId) {
setLiveStatus({ status: ev.status, ping_ms: ev.ping_ms })
}
},
})
const effectiveStatus = liveStatus?.status ?? device?.status ?? 'unknown'
const effectivePing = liveStatus !== null ? liveStatus.ping_ms : (device?.last_ping_ms ?? null)
const handleDelete = async () => {
try {
await deleteDevice.mutateAsync(deviceId)
message.success('Устройство удалено')
navigate(`/inventory/objects/${objId}`)
} catch {
message.error('Ошибка при удалении')
}
}
if (isLoading) return <Spin style={{ display: 'block', marginTop: 48 }} />
if (!device) return <Result status="404" title="Устройство не найдено" extra={<Button onClick={() => navigate(`/inventory/objects/${objId}`)}>К объекту</Button>} />
const breadcrumbItems = [
{ title: <a onClick={() => navigate('/inventory/objects')}>Инвентарь</a> },
...(obj?.city ? [{ title: <a onClick={() => navigate('/inventory/objects')}>{obj.city}</a> }] : []),
{ title: <a onClick={() => navigate(`/inventory/objects/${objId}`)}>{obj?.name ?? `Объект #${objId}`}</a> },
{ title: device.ip },
]
const tabItems = [
{
key: 'info',
label: 'Основное',
children: (
<Row gutter={24}>
<Col xs={24} lg={14}>
<Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }}>
<Descriptions.Item label="IP"><code>{device.ip}</code></Descriptions.Item>
<Descriptions.Item label="Hostname">{device.hostname ?? '—'}</Descriptions.Item>
<Descriptions.Item label="MAC">{device.mac ?? '—'}</Descriptions.Item>
<Descriptions.Item label="Производитель">{device.vendor ?? '—'}</Descriptions.Item>
<Descriptions.Item label="Модель">{device.model ?? '—'}</Descriptions.Item>
<Descriptions.Item label="Прошивка">{device.firmware_version ?? '—'}</Descriptions.Item>
<Descriptions.Item label="Категория"><CategoryTag category={device.category} /></Descriptions.Item>
<Descriptions.Item label="Роль">{device.role !== 'other' ? device.role : '—'}</Descriptions.Item>
<Descriptions.Item label="Источник">
<Typography.Text type="secondary">{device.source}</Typography.Text>
</Descriptions.Item>
{device.external_id && (
<Descriptions.Item label="External ID">
<code>{device.external_id}</code>
</Descriptions.Item>
)}
</Descriptions>
<Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }} style={{ marginTop: 16 }}>
<Descriptions.Item label="Объект">
<a onClick={() => navigate(`/inventory/objects/${objId}`)}>{obj?.name ?? `#${objId}`}</a>
</Descriptions.Item>
<Descriptions.Item label="Стойка">
{device.rack_name ? <Tag color="blue">{device.rack_name}{device.rack_type ? ` ${device.rack_type}` : ''}</Tag> : '—'}
</Descriptions.Item>
<Descriptions.Item label="Локация">
{device.location ? (LOCATION_LABEL[device.location] ?? device.location) : '—'}
</Descriptions.Item>
</Descriptions>
<Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }} style={{ marginTop: 16 }}>
<Descriptions.Item label="Статус"><DeviceStatusBadge status={effectiveStatus} /></Descriptions.Item>
<Descriptions.Item label="Последний ping">
{device.last_seen ? (
<Tooltip title={dayjs(device.last_seen).format('DD.MM.YYYY HH:mm:ss')}>
<Space size={4}>
<ClockCircleOutlined style={{ color: '#999' }} />
<span>{effectivePing !== null ? `${effectivePing}мс` : '—'}</span>
</Space>
</Tooltip>
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="Мониторинг">
{device.monitor_enabled
? <Tag color="green">Включён</Tag>
: <Tag>Выключен</Tag>}
</Descriptions.Item>
</Descriptions>
{(device.ssh_user_override || device.ssh_port_override) && (
<Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }} style={{ marginTop: 16 }}>
{device.ssh_user_override && (
<Descriptions.Item label="SSH логин (override)">{device.ssh_user_override}</Descriptions.Item>
)}
{device.ssh_port_override && (
<Descriptions.Item label="SSH порт (override)">{device.ssh_port_override}</Descriptions.Item>
)}
</Descriptions>
)}
{device.notes && (
<Card size="small" title="Заметки" style={{ marginTop: 16 }}>
<Typography.Text>{device.notes}</Typography.Text>
</Card>
)}
<Descriptions bordered size="small" column={2} style={{ marginTop: 16 }}>
<Descriptions.Item label="Создано">
{dayjs(device.created_at).format('DD.MM.YYYY HH:mm')}
</Descriptions.Item>
<Descriptions.Item label="Обновлено">
{dayjs(device.updated_at).format('DD.MM.YYYY HH:mm')}
</Descriptions.Item>
</Descriptions>
</Col>
<Col xs={24} lg={10}>
{device.capabilities.length > 0 && (
<Card size="small" title="Возможности" style={{ marginBottom: 16 }}>
<Space wrap>
{device.capabilities.map((c) => <Tag key={c}>{c}</Tag>)}
</Space>
</Card>
)}
{device.device_meta && Object.keys(device.device_meta).length > 0 && (
<Card size="small" title="Метаданные устройства" style={{ marginBottom: 16 }}>
<pre style={{ fontSize: 11, margin: 0, maxHeight: 300, overflow: 'auto' }}>
{JSON.stringify(device.device_meta, null, 2)}
</pre>
</Card>
)}
{device.connection_params && Object.keys(device.connection_params).length > 0 && (
<Card size="small" title="Параметры подключения">
<pre style={{ fontSize: 11, margin: 0, maxHeight: 300, overflow: 'auto' }}>
{JSON.stringify(device.connection_params, null, 2)}
</pre>
</Card>
)}
</Col>
</Row>
),
},
{
key: 'alerts',
label: 'Алерты',
children: <AlertsTab deviceId={deviceId} />,
},
{
key: 'snapshots',
label: 'Снапшоты',
children: <SnapshotsTab objId={objId} deviceId={deviceId} />,
},
{
key: 'tasks',
label: 'История задач',
children: <TasksTab deviceId={deviceId} />,
},
]
return (
<div>
<Breadcrumb style={{ marginBottom: 16 }} items={breadcrumbItems} />
<Card style={{ marginBottom: 16 }}>
<Row justify="space-between" align="top">
<Col>
<Space align="baseline" size={12}>
<Typography.Title level={3} style={{ margin: 0 }}>
<code>{device.ip}</code>
</Typography.Title>
<DeviceStatusBadge status={effectiveStatus} />
{effectivePing !== null && (
<Typography.Text type="secondary">{effectivePing}мс</Typography.Text>
)}
</Space>
{device.hostname && (
<div>
<Typography.Text type="secondary" style={{ fontSize: 14 }}>{device.hostname}</Typography.Text>
</div>
)}
<Space style={{ marginTop: 8 }} wrap>
<CategoryTag category={device.category} />
{device.role && device.role !== 'other' && <Tag>{device.role}</Tag>}
{device.rack_name && <Tag color="blue">{device.rack_name}</Tag>}
{device.vendor && <Tag color="default">{device.vendor}</Tag>}
{device.model && <Typography.Text type="secondary" style={{ fontSize: 13 }}>{device.model}</Typography.Text>}
{device.capabilities.map((c) => (
<Tag key={c} color="geekblue" style={{ fontSize: 11 }}>{c}</Tag>
))}
</Space>
</Col>
<Col>
<Space>
<Button icon={<EditOutlined />} onClick={() => setEditOpen(true)}>
Редактировать
</Button>
<Popconfirm
title="Удалить устройство?"
description="Это действие необратимо."
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={handleDelete}
>
<Button danger icon={<DeleteOutlined />} loading={deleteDevice.isPending}>
Удалить
</Button>
</Popconfirm>
</Space>
</Col>
</Row>
</Card>
<Card
tabList={tabItems.map((t) => ({ key: t.key, tab: t.label }))}
activeTabKey={activeTab}
onTabChange={setActiveTab}
bodyStyle={{ paddingTop: 16 }}
>
{tabItems.find((t) => t.key === activeTab)?.children}
</Card>
<DeviceEditModal
open={editOpen}
device={device}
objId={objId}
onClose={() => setEditOpen(false)}
/>
</div>
)
}