668 lines
25 KiB
TypeScript
668 lines
25 KiB
TypeScript
import {
|
||
CheckCircleOutlined,
|
||
ClockCircleOutlined,
|
||
DeleteOutlined,
|
||
EditOutlined,
|
||
EyeOutlined,
|
||
LinkOutlined,
|
||
} 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 { getDeviceWebUrl } from '../utils/deviceUrl'
|
||
import { elapsedAgo } from '../utils/time'
|
||
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 webUrl = device ? getDeviceWebUrl(device) : 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="Последний пинг">
|
||
{(device.last_ping_at ?? device.last_seen) ? (
|
||
<Tooltip title={elapsedAgo((device.last_ping_at ?? device.last_seen)!)}>
|
||
<Space size={4} style={{ cursor: 'default' }}>
|
||
<ClockCircleOutlined style={{ color: '#999' }} />
|
||
<span>{dayjs(device.last_ping_at ?? device.last_seen).format('DD.MM.YYYY HH:mm:ss')}</span>
|
||
</Space>
|
||
</Tooltip>
|
||
) : '—'}
|
||
</Descriptions.Item>
|
||
{device.last_seen && device.last_ping_at !== device.last_seen && (
|
||
<Descriptions.Item label="Онлайн был">
|
||
<Tooltip title={elapsedAgo(device.last_seen)}>
|
||
<Space size={4} style={{ cursor: 'default' }}>
|
||
<ClockCircleOutlined style={{ color: '#52c41a' }} />
|
||
<span>{dayjs(device.last_seen).format('DD.MM.YYYY HH:mm:ss')}</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 || device.ssh_key_id_override || device.ssh_last_auth) && (
|
||
<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>
|
||
)}
|
||
{device.ssh_key_id_override != null && (
|
||
<Descriptions.Item label="SSH ключ (override)">
|
||
<Typography.Text code>key_id={device.ssh_key_id_override}</Typography.Text>
|
||
</Descriptions.Item>
|
||
)}
|
||
{device.ssh_last_auth && (
|
||
<Descriptions.Item label="Последний успешный SSH">
|
||
<Space size={4} wrap>
|
||
<Tag color="green">{String(device.ssh_last_auth.type ?? '—')}</Tag>
|
||
{device.ssh_last_auth.username && (
|
||
<Typography.Text code>{String(device.ssh_last_auth.username)}</Typography.Text>
|
||
)}
|
||
{device.ssh_last_auth.key_id != null && (
|
||
<Typography.Text type="secondary">key_id={String(device.ssh_last_auth.key_id)}</Typography.Text>
|
||
)}
|
||
{device.ssh_last_auth.cred_id != null && (
|
||
<Typography.Text type="secondary">cred_id={String(device.ssh_last_auth.cred_id)}</Typography.Text>
|
||
)}
|
||
</Space>
|
||
</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>
|
||
{webUrl && (
|
||
<Tooltip title={webUrl}>
|
||
<Button
|
||
icon={<LinkOutlined />}
|
||
href={webUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
Веб-интерфейс
|
||
</Button>
|
||
</Tooltip>
|
||
)}
|
||
<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>
|
||
)
|
||
}
|