1427 lines
52 KiB
TypeScript
1427 lines
52 KiB
TypeScript
import {
|
||
CheckCircleOutlined,
|
||
ClockCircleOutlined,
|
||
DeleteOutlined,
|
||
EditOutlined,
|
||
EyeOutlined,
|
||
LinkOutlined,
|
||
ThunderboltOutlined,
|
||
FileSearchOutlined,
|
||
InfoCircleOutlined,
|
||
CameraOutlined,
|
||
ReloadOutlined,
|
||
} from '@ant-design/icons'
|
||
import {
|
||
Badge,
|
||
Breadcrumb,
|
||
Button,
|
||
Card,
|
||
Col,
|
||
Descriptions,
|
||
Input,
|
||
Modal,
|
||
Popconfirm,
|
||
Progress,
|
||
Result,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Spin,
|
||
Table,
|
||
Tag,
|
||
Tooltip,
|
||
Typography,
|
||
message,
|
||
} from 'antd'
|
||
import { useQueryClient } from '@tanstack/react-query'
|
||
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, useDeviceCredentials, type DeviceItem, type ObjectItem, type DeviceCredentials } from '../api/objects'
|
||
import { getDeviceWebUrl } from '../utils/deviceUrl'
|
||
import { elapsedAgo } from '../utils/time'
|
||
import { useSnapshots, useSnapshotDiff } from '../api/snapshots'
|
||
import { useDeviceTasks, useTask, useCreateTask, type Task } from '../api/tasks'
|
||
import { DeviceEditModal } from '../components/DeviceEditModal'
|
||
import { CategoryTag, DeviceStatusBadge, TaskStatusBadge } from '../components/StatusBadge'
|
||
import { useMonitorSSE } from '../hooks/useMonitorSSE'
|
||
|
||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||
|
||
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: 'Серверная',
|
||
rack: 'Стойка',
|
||
street: 'Улица',
|
||
}
|
||
const SOURCE_COLOR: Record<string, string> = {
|
||
manual: 'blue',
|
||
db_sync: 'purple',
|
||
auto_ssh: 'green',
|
||
csv: 'orange',
|
||
sheets: 'cyan',
|
||
}
|
||
const SOURCE_LABEL: Record<string, string> = {
|
||
manual: 'Вручную',
|
||
db_sync: 'Синхронизация БД',
|
||
auto_ssh: 'SSH Discovery',
|
||
csv: 'CSV импорт',
|
||
sheets: 'Google Sheets',
|
||
}
|
||
|
||
// Known snapshot action per device category.
|
||
// Mikrotik is detected separately via vendor string.
|
||
const CATEGORY_SNAPSHOT: Record<string, { type: string; needPath: boolean; defaultPath: string }> = {
|
||
main_server: { type: 'get_file', needPath: true, defaultPath: '/etc/caps/config.ini' },
|
||
vm: { type: 'get_file', needPath: true, defaultPath: '/etc/caps/config.ini' },
|
||
embedded: { type: 'get_file', needPath: true, defaultPath: '/etc/caps/config.ini' },
|
||
io_board: { type: 'get_file', needPath: true, defaultPath: '/etc/caps/config.ini' },
|
||
other: { type: 'get_file', needPath: true, defaultPath: '/etc/caps/config.ini' },
|
||
}
|
||
|
||
function getSnapshotConfig(device: DeviceItem) {
|
||
if (
|
||
device.category === 'router' ||
|
||
device.vendor?.toLowerCase().includes('mikrotik')
|
||
) {
|
||
return { type: 'mikrotik_get_config', needPath: false, defaultPath: '' }
|
||
}
|
||
return CATEGORY_SNAPSHOT[device.category] ?? null
|
||
}
|
||
|
||
// ─── SSH / Credentials block ──────────────────────────────────────────────────
|
||
|
||
function SshBlock({
|
||
device,
|
||
obj,
|
||
creds,
|
||
credsLoading,
|
||
}: {
|
||
device: DeviceItem
|
||
obj: ObjectItem | undefined
|
||
creds: DeviceCredentials | undefined
|
||
credsLoading: boolean
|
||
}) {
|
||
const lastAuth = device.ssh_last_auth
|
||
|
||
const effectiveUser =
|
||
lastAuth?.username
|
||
? String(lastAuth.username)
|
||
: device.ssh_user_override ?? obj?.ssh_user ?? null
|
||
|
||
const effectivePort = device.ssh_port_override ?? 22
|
||
|
||
const usesKey = String(lastAuth?.type) === 'key' || device.ssh_key_id_override != null
|
||
|
||
// Effective SSH password: device override first, then object level
|
||
const effectiveSshPassword = creds?.ssh_password_device ?? creds?.ssh_password_object ?? null
|
||
const sshPasswordSource = creds?.ssh_password_device
|
||
? 'device'
|
||
: creds?.ssh_password_object
|
||
? 'объект'
|
||
: null
|
||
|
||
return (
|
||
<Descriptions
|
||
bordered
|
||
size="small"
|
||
column={1}
|
||
labelStyle={{ width: 160 }}
|
||
style={{ marginTop: 16 }}
|
||
title={<Typography.Text strong style={{ fontSize: 13 }}>SSH / Авторизация</Typography.Text>}
|
||
>
|
||
<Descriptions.Item label="Логин">
|
||
<Space size={6}>
|
||
{effectiveUser
|
||
? <Typography.Text code copyable>{effectiveUser}</Typography.Text>
|
||
: <Typography.Text type="secondary">Не задан</Typography.Text>}
|
||
{device.ssh_user_override
|
||
? <Tag color="blue" style={{ fontSize: 10 }}>device</Tag>
|
||
: obj?.ssh_user
|
||
? <Tag style={{ fontSize: 10 }}>объект</Tag>
|
||
: null}
|
||
</Space>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="Пароль">
|
||
{usesKey ? (
|
||
<Space size={6}>
|
||
<Tag color="purple">SSH ключ</Tag>
|
||
{(lastAuth?.key_id != null || device.ssh_key_id_override != null) && (
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
key_id={String(lastAuth?.key_id ?? device.ssh_key_id_override)}
|
||
</Typography.Text>
|
||
)}
|
||
</Space>
|
||
) : credsLoading ? (
|
||
<Spin size="small" />
|
||
) : effectiveSshPassword ? (
|
||
<Space size={6}>
|
||
<Typography.Text code copyable>{effectiveSshPassword}</Typography.Text>
|
||
{sshPasswordSource && <Tag style={{ fontSize: 10 }}>{sshPasswordSource}</Tag>}
|
||
</Space>
|
||
) : (
|
||
<Typography.Text type="secondary">Не задан</Typography.Text>
|
||
)}
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="Порт">
|
||
<Space size={6}>
|
||
<Typography.Text code>{effectivePort}</Typography.Text>
|
||
{device.ssh_port_override && <Tag color="blue" style={{ fontSize: 10 }}>device</Tag>}
|
||
</Space>
|
||
</Descriptions.Item>
|
||
|
||
{lastAuth && (
|
||
<Descriptions.Item label="Последнее подкл.">
|
||
<Space size={4} wrap>
|
||
<Tag color="green">успешно</Tag>
|
||
<Tag>{String(lastAuth.type) === 'key' ? 'ключ' : 'пароль'}</Tag>
|
||
{lastAuth.username && (
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
{String(lastAuth.username)}
|
||
</Typography.Text>
|
||
)}
|
||
</Space>
|
||
</Descriptions.Item>
|
||
)}
|
||
</Descriptions>
|
||
)
|
||
}
|
||
|
||
// ─── Device meta smart render ─────────────────────────────────────────────────
|
||
|
||
function DeviceMetaBlock({ device }: { device: DeviceItem }) {
|
||
const meta = device.device_meta
|
||
if (!meta || Object.keys(meta).length === 0) return null
|
||
|
||
// Keys already shown in SystemInfoBlock — skip them here
|
||
const systemInfoKeys = new Set([
|
||
'os', 'uptime', 'load_1m', 'load_5m', 'load_15m', 'cpu_cores',
|
||
'mem_total_mb', 'mem_used_mb', 'mem_free_mb', 'mem_percent',
|
||
'disks', 'top_processes', 'temps',
|
||
])
|
||
|
||
const category = device.category
|
||
|
||
// Structured fields per category
|
||
const knownKeys: Record<string, string> = {}
|
||
const remaining: Record<string, unknown> = {}
|
||
|
||
if (category === 'camera') {
|
||
const cameraKeys = ['plugin', 'cls', 'stream_url', 'rtsp_url', 'channel', 'brand']
|
||
for (const [k, v] of Object.entries(meta)) {
|
||
if (cameraKeys.includes(k)) knownKeys[k] = String(v)
|
||
else if (!systemInfoKeys.has(k)) remaining[k] = v
|
||
}
|
||
} else if (category === 'router' || device.vendor?.toLowerCase().includes('mikrotik')) {
|
||
const routerKeys = ['identity', 'board_name', 'ros_version', 'architecture', 'platform']
|
||
for (const [k, v] of Object.entries(meta)) {
|
||
if (routerKeys.includes(k)) knownKeys[k] = String(v)
|
||
else if (!systemInfoKeys.has(k)) remaining[k] = v
|
||
}
|
||
} else {
|
||
for (const [k, v] of Object.entries(meta)) {
|
||
if (!systemInfoKeys.has(k)) remaining[k] = v
|
||
}
|
||
}
|
||
|
||
const LABEL_MAP: Record<string, string> = {
|
||
plugin: 'Plugin',
|
||
cls: 'Тип',
|
||
stream_url: 'Stream URL',
|
||
rtsp_url: 'RTSP URL',
|
||
channel: 'Канал',
|
||
brand: 'Бренд',
|
||
identity: 'Identity',
|
||
board_name: 'Board',
|
||
ros_version: 'RouterOS',
|
||
architecture: 'Архитектура',
|
||
platform: 'Платформа',
|
||
}
|
||
|
||
const hasKnown = Object.keys(knownKeys).length > 0
|
||
const hasRemaining = Object.keys(remaining).length > 0
|
||
|
||
return (
|
||
<Card size="small" title="Метаданные устройства" style={{ marginBottom: 16 }}>
|
||
{hasKnown && (
|
||
<Descriptions size="small" column={1} style={{ marginBottom: hasRemaining ? 8 : 0 }}>
|
||
{Object.entries(knownKeys).map(([k, v]) => (
|
||
<Descriptions.Item key={k} label={LABEL_MAP[k] ?? k}>
|
||
{k.includes('url') || k.includes('rtsp')
|
||
? <Typography.Text code copyable style={{ fontSize: 11 }}>{v}</Typography.Text>
|
||
: <Typography.Text>{v}</Typography.Text>}
|
||
</Descriptions.Item>
|
||
))}
|
||
</Descriptions>
|
||
)}
|
||
{hasRemaining && (
|
||
<pre style={{ fontSize: 11, margin: 0, maxHeight: 200, overflow: 'auto', background: '#fafafa', padding: 8, borderRadius: 4, border: '1px solid #f0f0f0' }}>
|
||
{JSON.stringify(remaining, null, 2)}
|
||
</pre>
|
||
)}
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
// ─── Connection params smart render ──────────────────────────────────────────
|
||
|
||
function ConnectionParamsBlock({
|
||
device,
|
||
creds,
|
||
credsLoading,
|
||
}: {
|
||
device: DeviceItem
|
||
creds: DeviceCredentials | undefined
|
||
credsLoading: boolean
|
||
}) {
|
||
const params = device.connection_params
|
||
if (!params || Object.keys(params).length === 0) return null
|
||
|
||
const knownKeys = ['port', 'protocol', 'auth_type', 'username', 'url', 'api_port', 'web_port']
|
||
const displayed: Record<string, unknown> = {}
|
||
const remaining: Record<string, unknown> = {}
|
||
|
||
for (const [k, v] of Object.entries(params)) {
|
||
if (k === 'password') continue // shown separately, decrypted
|
||
if (knownKeys.includes(k)) displayed[k] = v
|
||
else remaining[k] = v
|
||
}
|
||
|
||
const LABEL_MAP: Record<string, string> = {
|
||
port: 'Порт',
|
||
protocol: 'Протокол',
|
||
auth_type: 'Тип авторизации',
|
||
username: 'Логин',
|
||
url: 'URL',
|
||
api_port: 'API порт',
|
||
web_port: 'Web порт',
|
||
}
|
||
const AUTH_COLOR: Record<string, string> = {
|
||
basic: 'blue',
|
||
digest: 'orange',
|
||
bearer: 'green',
|
||
none: 'default',
|
||
}
|
||
|
||
const hasDisplayed = Object.keys(displayed).length > 0
|
||
const hasRemaining = Object.keys(remaining).length > 0
|
||
const hasPassword = 'password' in params
|
||
|
||
return (
|
||
<Card size="small" title="Параметры подключения">
|
||
<Descriptions size="small" column={1} style={{ marginBottom: hasRemaining ? 8 : 0 }}>
|
||
{hasDisplayed && Object.entries(displayed).map(([k, v]) => (
|
||
<Descriptions.Item key={k} label={LABEL_MAP[k] ?? k}>
|
||
{k === 'auth_type'
|
||
? <Tag color={AUTH_COLOR[String(v)] ?? 'default'}>{String(v)}</Tag>
|
||
: k === 'protocol'
|
||
? <Tag color={String(v) === 'https' ? 'green' : 'blue'}>{String(v).toUpperCase()}</Tag>
|
||
: k === 'url'
|
||
? <Typography.Text code copyable style={{ fontSize: 11 }}>{String(v)}</Typography.Text>
|
||
: <Typography.Text code>{String(v)}</Typography.Text>}
|
||
</Descriptions.Item>
|
||
))}
|
||
{hasPassword && (
|
||
<Descriptions.Item label="Пароль">
|
||
{credsLoading
|
||
? <Spin size="small" />
|
||
: creds?.connection_password
|
||
? <Typography.Text code copyable>{creds.connection_password}</Typography.Text>
|
||
: <Typography.Text type="secondary">Не задан</Typography.Text>}
|
||
</Descriptions.Item>
|
||
)}
|
||
</Descriptions>
|
||
{hasRemaining && (
|
||
<pre style={{ fontSize: 11, margin: 0, maxHeight: 200, overflow: 'auto', background: '#fafafa', padding: 8, borderRadius: 4, border: '1px solid #f0f0f0' }}>
|
||
{JSON.stringify(remaining, null, 2)}
|
||
</pre>
|
||
)}
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
// ─── System Info block (from device_meta or latest debian_system_info task) ──
|
||
|
||
function SystemInfoFromTask({ taskId, deviceId }: { taskId: string; deviceId: number }) {
|
||
const { data: detail, isLoading } = useTask(taskId)
|
||
const dr = detail?.device_results.find((r) => r.device_id === deviceId)
|
||
const data = dr?.data as Record<string, unknown> | null | undefined
|
||
|
||
if (isLoading) return <Spin size="small" />
|
||
if (!data || Object.keys(data).length === 0) return <Typography.Text type="secondary">Нет данных</Typography.Text>
|
||
|
||
return <SystemInfoRender data={data} />
|
||
}
|
||
|
||
function SystemInfoRender({ data }: { data: Record<string, unknown> }) {
|
||
const memTotal = data.mem_total_mb as number | undefined
|
||
const memUsed = data.mem_used_mb as number | undefined
|
||
const memPct = data.mem_percent as number | undefined
|
||
const disks = data.disks as { mount: string; size: string; used: string; avail: string; pct: string }[] | undefined
|
||
const temps = data.temps as string[] | undefined
|
||
|
||
return (
|
||
<Space direction="vertical" style={{ width: '100%' }} size={8}>
|
||
{data.os && (
|
||
<Descriptions size="small" column={1}>
|
||
<Descriptions.Item label="ОС">{String(data.os)}</Descriptions.Item>
|
||
{data.uptime && <Descriptions.Item label="Uptime">{String(data.uptime)}</Descriptions.Item>}
|
||
{(data.load_1m || data.cpu_cores) && (
|
||
<Descriptions.Item label="CPU">
|
||
<Space size={4}>
|
||
{data.cpu_cores && <span>Ядра: <b>{String(data.cpu_cores)}</b></span>}
|
||
{data.load_1m && <span>Load: <b>{String(data.load_1m)}</b> / {String(data.load_5m ?? '?')} / {String(data.load_15m ?? '?')}</span>}
|
||
</Space>
|
||
</Descriptions.Item>
|
||
)}
|
||
</Descriptions>
|
||
)}
|
||
|
||
{memTotal !== undefined && memUsed !== undefined && (
|
||
<div>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
RAM: {memUsed} MB / {memTotal} MB
|
||
</Typography.Text>
|
||
<Progress
|
||
percent={memPct ?? Math.round((memUsed / memTotal) * 100)}
|
||
size="small"
|
||
status={((memPct ?? 0) > 90) ? 'exception' : 'normal'}
|
||
style={{ marginTop: 2 }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{disks && disks.length > 0 && (
|
||
<div>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Диски:</Typography.Text>
|
||
{disks.filter((d) => !isNaN(parseInt(d.pct))).map((d) => {
|
||
const pctNum = parseInt(d.pct)
|
||
return (
|
||
<div key={d.mount} style={{ marginBottom: 6 }}>
|
||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||
<Typography.Text code style={{ fontSize: 11 }}>{d.mount}</Typography.Text>
|
||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{d.used} / {d.size} ({d.pct})</Typography.Text>
|
||
</Space>
|
||
<Progress
|
||
percent={isNaN(pctNum) ? 0 : pctNum}
|
||
size="small"
|
||
status={pctNum > 90 ? 'exception' : pctNum > 75 ? 'active' : 'normal'}
|
||
showInfo={false}
|
||
style={{ marginTop: 2 }}
|
||
/>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{temps && temps.length > 0 && (
|
||
<div>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>Температуры: </Typography.Text>
|
||
{temps.map((t, i) => <Tag key={i} color="orange" style={{ fontSize: 11 }}>{t}</Tag>)}
|
||
</div>
|
||
)}
|
||
</Space>
|
||
)
|
||
}
|
||
|
||
function SystemInfoBlock({ device, tasks }: { device: DeviceItem; tasks: Task[] | undefined }) {
|
||
// Check device_meta first (manually stored)
|
||
const metaHasSystemInfo =
|
||
device.device_meta &&
|
||
('os' in device.device_meta || 'mem_total_mb' in device.device_meta || 'disks' in device.device_meta)
|
||
|
||
if (metaHasSystemInfo) {
|
||
return (
|
||
<Card size="small" title="Системная информация" style={{ marginBottom: 16 }}>
|
||
<SystemInfoRender data={device.device_meta as Record<string, unknown>} />
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
// Fallback: find most recent successful debian_system_info task
|
||
const latestTask = tasks
|
||
?.filter((t) => t.type === 'debian_system_info' && t.status === 'success')
|
||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
|
||
|
||
if (!latestTask) return null
|
||
|
||
return (
|
||
<Card
|
||
size="small"
|
||
title={
|
||
<Space>
|
||
Системная информация
|
||
<Typography.Text type="secondary" style={{ fontSize: 11, fontWeight: 'normal' }}>
|
||
(из задачи {dayjs(latestTask.created_at).format('DD.MM.YY HH:mm')})
|
||
</Typography.Text>
|
||
</Space>
|
||
}
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
<SystemInfoFromTask taskId={latestTask.id} deviceId={device.id} />
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
// ─── 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: 'Нет алертов' }}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ─── Camera tab ───────────────────────────────────────────────────────────────
|
||
|
||
function CameraStatusInfo({ deviceId, tasks }: { deviceId: number; tasks: Task[] | undefined }) {
|
||
const latestTask = tasks
|
||
?.filter((t) => t.type === 'camera_get_status' && t.status === 'success')
|
||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
|
||
|
||
if (!latestTask) return null
|
||
|
||
return <CameraStatusFromTask taskId={latestTask.id} deviceId={deviceId} taskDate={latestTask.created_at} />
|
||
}
|
||
|
||
function CameraStatusFromTask({ taskId, deviceId, taskDate }: { taskId: string; deviceId: number; taskDate: string }) {
|
||
const { data: detail, isLoading } = useTask(taskId)
|
||
const dr = detail?.device_results.find((r) => r.device_id === deviceId)
|
||
const data = dr?.data as { device_info?: Record<string, unknown>; system_time?: Record<string, unknown> } | undefined
|
||
|
||
if (isLoading) return <Spin size="small" />
|
||
if (!data?.device_info) return null
|
||
|
||
const di = data.device_info
|
||
const st = data.system_time ?? {}
|
||
|
||
return (
|
||
<Card
|
||
size="small"
|
||
title={
|
||
<Space>
|
||
Последний статус
|
||
<Typography.Text type="secondary" style={{ fontSize: 11, fontWeight: 'normal' }}>
|
||
({dayjs(taskDate).format('DD.MM.YY HH:mm')})
|
||
</Typography.Text>
|
||
</Space>
|
||
}
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
<Descriptions size="small" column={2}>
|
||
{di.model && <Descriptions.Item label="Модель">{String(di.model)}</Descriptions.Item>}
|
||
{di.firmwareVersion && <Descriptions.Item label="Прошивка">{String(di.firmwareVersion)}</Descriptions.Item>}
|
||
{di.serialNumber && <Descriptions.Item label="Серийный №">{String(di.serialNumber)}</Descriptions.Item>}
|
||
{di.macAddress && <Descriptions.Item label="MAC">{String(di.macAddress)}</Descriptions.Item>}
|
||
{st.localTime && <Descriptions.Item label="Время камеры">{String(st.localTime)}</Descriptions.Item>}
|
||
{st.timeZone && <Descriptions.Item label="Часовой пояс">{String(st.timeZone)}</Descriptions.Item>}
|
||
</Descriptions>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function CameraTab({
|
||
device,
|
||
objId,
|
||
tasks,
|
||
creds,
|
||
}: {
|
||
device: DeviceItem
|
||
objId: number
|
||
tasks: Task[] | undefined
|
||
creds: DeviceCredentials | undefined
|
||
}) {
|
||
const [snapshotKey, setSnapshotKey] = useState(0)
|
||
const [snapshotError, setSnapshotError] = useState<string | null>(null)
|
||
const [channel, setChannel] = useState(1)
|
||
const createTask = useCreateTask()
|
||
|
||
const params = device.connection_params ?? {}
|
||
const meta = device.device_meta ?? {}
|
||
const httpPort = meta.http_port ?? 80
|
||
const streamUrl: string | null = (params.stream_url as string) ?? null
|
||
const authType: string | null = (params.auth_type as string) ?? null
|
||
const webUser: string | null = (params.username as string) ?? device.ssh_user_override ?? null
|
||
|
||
// Build default RTSP URL if not in connection_params
|
||
const rtspUrl = streamUrl ?? `rtsp://${device.ip}:554/Streaming/Channels/${channel}01`
|
||
|
||
const snapshotSrc = `/api/v1/objects/${objId}/devices/${device.id}/camera/snapshot?channel=${channel}&_k=${snapshotKey}`
|
||
|
||
const handleGetStatus = async () => {
|
||
try {
|
||
const task = await createTask.mutateAsync({
|
||
type: 'camera_get_status',
|
||
target_scope: { type: 'device', id: device.id },
|
||
params: {},
|
||
})
|
||
message.success(
|
||
<span>Задача запущена. <a href={`/tasks/${task.id}`} target="_blank" rel="noopener noreferrer">Открыть</a></span>,
|
||
5,
|
||
)
|
||
} catch {
|
||
message.error('Не удалось запустить задачу')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Row gutter={24}>
|
||
<Col xs={24} lg={14}>
|
||
{/* Connection parameters */}
|
||
<Card size="small" title="Параметры подключения" style={{ marginBottom: 16 }}>
|
||
<Descriptions size="small" column={1}>
|
||
<Descriptions.Item label="HTTP порт">
|
||
<Typography.Text code>{String(httpPort)}</Typography.Text>
|
||
</Descriptions.Item>
|
||
{authType && (
|
||
<Descriptions.Item label="Авторизация">
|
||
<Tag color={authType === 'digest' ? 'orange' : 'blue'}>{authType}</Tag>
|
||
</Descriptions.Item>
|
||
)}
|
||
{webUser && (
|
||
<Descriptions.Item label="Логин (веб)">
|
||
<Typography.Text code copyable>{webUser}</Typography.Text>
|
||
</Descriptions.Item>
|
||
)}
|
||
{creds?.connection_password && (
|
||
<Descriptions.Item label="Пароль (веб)">
|
||
<Typography.Text code copyable>{creds.connection_password}</Typography.Text>
|
||
</Descriptions.Item>
|
||
)}
|
||
<Descriptions.Item label="RTSP поток">
|
||
<Space>
|
||
<Typography.Text code copyable style={{ fontSize: 11 }}>{rtspUrl}</Typography.Text>
|
||
<Tooltip title="Открыть в VLC (если установлен)">
|
||
<Button
|
||
size="small"
|
||
type="link"
|
||
onClick={() => { window.open(`vlc://${rtspUrl}`) }}
|
||
>
|
||
VLC
|
||
</Button>
|
||
</Tooltip>
|
||
</Space>
|
||
</Descriptions.Item>
|
||
{params.frame_url && (
|
||
<Descriptions.Item label="Frame URL">
|
||
<Typography.Text code copyable style={{ fontSize: 11 }}>{String(params.frame_url)}</Typography.Text>
|
||
</Descriptions.Item>
|
||
)}
|
||
</Descriptions>
|
||
</Card>
|
||
|
||
{/* Last camera status */}
|
||
<CameraStatusInfo deviceId={device.id} tasks={tasks} />
|
||
|
||
<Button
|
||
icon={<InfoCircleOutlined />}
|
||
loading={createTask.isPending}
|
||
onClick={handleGetStatus}
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
Получить статус камеры
|
||
</Button>
|
||
</Col>
|
||
|
||
<Col xs={24} lg={10}>
|
||
{/* Live snapshot */}
|
||
<Card
|
||
size="small"
|
||
title="Снимок с камеры"
|
||
extra={
|
||
<Space size={8}>
|
||
<Select
|
||
size="small"
|
||
value={channel}
|
||
onChange={setChannel}
|
||
options={[
|
||
{ value: 1, label: 'Канал 1' },
|
||
{ value: 2, label: 'Канал 2' },
|
||
{ value: 3, label: 'Канал 3' },
|
||
]}
|
||
style={{ width: 90 }}
|
||
/>
|
||
<Button
|
||
size="small"
|
||
icon={<ReloadOutlined />}
|
||
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
|
||
>
|
||
Обновить
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
{snapshotError ? (
|
||
<div style={{ padding: '16px 0', textAlign: 'center' }}>
|
||
<Typography.Text type="danger">{snapshotError}</Typography.Text>
|
||
<br />
|
||
<Button
|
||
size="small"
|
||
style={{ marginTop: 8 }}
|
||
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
|
||
>
|
||
Повторить
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<img
|
||
key={snapshotKey}
|
||
src={snapshotSrc}
|
||
alt="Camera snapshot"
|
||
style={{ width: '100%', borderRadius: 4, display: 'block' }}
|
||
onError={() => setSnapshotError('Не удалось загрузить снимок. Проверьте подключение и кредиалы камеры.')}
|
||
onLoad={() => setSnapshotError(null)}
|
||
/>
|
||
)}
|
||
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 8 }}>
|
||
Снимок загружается через сервер — не требует прямого доступа к камере из браузера
|
||
</Typography.Text>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
)
|
||
}
|
||
|
||
// ─── Snapshots tab ────────────────────────────────────────────────────────────
|
||
|
||
function SnapshotsTab({ objId, deviceId, device }: { objId: number; deviceId: number; device: DeviceItem }) {
|
||
const { data: snapshots, isLoading, refetch } = 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 createTask = useCreateTask()
|
||
const [snapshotModalOpen, setSnapshotModalOpen] = useState(false)
|
||
const [snapshotPath, setSnapshotPath] = useState('')
|
||
|
||
const snapshotConfig = getSnapshotConfig(device)
|
||
|
||
const handleOpenSnapshotModal = () => {
|
||
if (!snapshotConfig) return
|
||
if (!snapshotConfig.needPath) {
|
||
// Mikrotik — run directly
|
||
handleTakeSnapshot('')
|
||
return
|
||
}
|
||
setSnapshotPath(snapshotConfig.defaultPath)
|
||
setSnapshotModalOpen(true)
|
||
}
|
||
|
||
const handleTakeSnapshot = async (path: string) => {
|
||
if (!snapshotConfig) return
|
||
try {
|
||
const params = snapshotConfig.needPath ? { path } : {}
|
||
const task = await createTask.mutateAsync({
|
||
type: snapshotConfig.type,
|
||
target_scope: { type: 'device', id: deviceId },
|
||
params,
|
||
})
|
||
setSnapshotModalOpen(false)
|
||
message.success(
|
||
<span>
|
||
Задача запущена.{' '}
|
||
<a href={`/tasks/${task.id}`} target="_blank" rel="noopener noreferrer">
|
||
Открыть задачу
|
||
</a>
|
||
</span>,
|
||
6,
|
||
)
|
||
// Refresh snapshots after a short delay
|
||
setTimeout(() => refetch(), 5000)
|
||
} catch {
|
||
message.error('Не удалось запустить задачу')
|
||
}
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
{/* Take snapshot button */}
|
||
<div style={{ marginBottom: 16 }}>
|
||
{snapshotConfig ? (
|
||
<Tooltip
|
||
title={
|
||
snapshotConfig.needPath
|
||
? `Снять файл: ${snapshotConfig.defaultPath}`
|
||
: 'Экспортировать конфигурацию RouterOS'
|
||
}
|
||
>
|
||
<Button
|
||
type="primary"
|
||
icon={<CameraOutlined />}
|
||
loading={createTask.isPending}
|
||
onClick={handleOpenSnapshotModal}
|
||
>
|
||
Снять снапшот
|
||
</Button>
|
||
</Tooltip>
|
||
) : (
|
||
<Tooltip title="Для данной категории устройств снапшоты не поддерживаются">
|
||
<Button icon={<CameraOutlined />} disabled>
|
||
Снять снапшот
|
||
</Button>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
|
||
<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)
|
||
)}
|
||
|
||
{/* Snapshot path modal */}
|
||
<Modal
|
||
open={snapshotModalOpen}
|
||
title="Снять снапшот файла"
|
||
okText="Запустить"
|
||
cancelText="Отмена"
|
||
onCancel={() => setSnapshotModalOpen(false)}
|
||
onOk={() => handleTakeSnapshot(snapshotPath)}
|
||
confirmLoading={createTask.isPending}
|
||
>
|
||
<Space direction="vertical" style={{ width: '100%' }}>
|
||
<Typography.Text type="secondary">
|
||
Укажите путь к файлу на устройстве <code>{device.ip}</code>
|
||
</Typography.Text>
|
||
<Input
|
||
value={snapshotPath}
|
||
onChange={(e) => setSnapshotPath(e.target.value)}
|
||
placeholder="/etc/caps/config.ini"
|
||
addonBefore={<FileSearchOutlined />}
|
||
/>
|
||
</Space>
|
||
</Modal>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ─── 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 { data: tasks } = useDeviceTasks(deviceId)
|
||
const { data: creds, isLoading: credsLoading } = useDeviceCredentials(objId, deviceId)
|
||
const deleteDevice = useDeleteDevice(objId)
|
||
const createTask = useCreateTask()
|
||
|
||
const qc = useQueryClient()
|
||
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 })
|
||
qc.invalidateQueries({ queryKey: ['device', objId, deviceId] })
|
||
}
|
||
},
|
||
})
|
||
|
||
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('Ошибка при удалении')
|
||
}
|
||
}
|
||
|
||
const handlePing = async () => {
|
||
try {
|
||
const task = await createTask.mutateAsync({
|
||
type: 'ping',
|
||
target_scope: { type: 'device', id: deviceId },
|
||
params: {},
|
||
})
|
||
message.success(
|
||
<span>
|
||
Пинг запущен.{' '}
|
||
<a href={`/tasks/${task.id}`} target="_blank" rel="noopener noreferrer">Открыть задачу</a>
|
||
</span>,
|
||
4,
|
||
)
|
||
} catch {
|
||
message.error('Не удалось запустить пинг')
|
||
}
|
||
}
|
||
|
||
const handleSystemInfo = async () => {
|
||
try {
|
||
const task = await createTask.mutateAsync({
|
||
type: 'debian_system_info',
|
||
target_scope: { type: 'device', id: deviceId },
|
||
params: {},
|
||
})
|
||
message.success(
|
||
<span>
|
||
Сбор информации запущен.{' '}
|
||
<a href={`/tasks/${task.id}`} target="_blank" rel="noopener noreferrer">Открыть задачу</a>
|
||
</span>,
|
||
4,
|
||
)
|
||
} 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 isLinuxLike = ['main_server', 'vm', 'embedded', 'other'].includes(device.category)
|
||
|
||
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}>
|
||
{/* Identity */}
|
||
<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="Источник">
|
||
<Tag color={SOURCE_COLOR[device.source] ?? 'default'}>
|
||
{SOURCE_LABEL[device.source] ?? device.source}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="Активно">
|
||
{device.is_active
|
||
? <Tag color="green">Да</Tag>
|
||
: <Tag color="red">Нет</Tag>}
|
||
</Descriptions.Item>
|
||
{device.external_id && (
|
||
<Descriptions.Item label="External ID">
|
||
<code>{device.external_id}</code>
|
||
</Descriptions.Item>
|
||
)}
|
||
</Descriptions>
|
||
|
||
{/* Location */}
|
||
<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>
|
||
|
||
{/* Monitoring */}
|
||
<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>
|
||
<Descriptions.Item label="Онлайн был">
|
||
{device.last_seen ? (
|
||
<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>
|
||
|
||
{/* SSH block — временно скрыт */}
|
||
|
||
{/* System info (Linux-like devices) */}
|
||
{isLinuxLike && <SystemInfoBlock device={device} tasks={tasks} />}
|
||
|
||
{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="Создано">
|
||
<Tooltip title={elapsedAgo(device.created_at)}>
|
||
{dayjs(device.created_at).format('DD.MM.YYYY HH:mm')}
|
||
</Tooltip>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="Обновлено">
|
||
<Tooltip title={elapsedAgo(device.updated_at)}>
|
||
{dayjs(device.updated_at).format('DD.MM.YYYY HH:mm')}
|
||
</Tooltip>
|
||
</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>
|
||
)}
|
||
<DeviceMetaBlock device={device} />
|
||
<ConnectionParamsBlock device={device} creds={creds} credsLoading={credsLoading} />
|
||
</Col>
|
||
</Row>
|
||
),
|
||
},
|
||
...(device.category === 'camera' ? [{
|
||
key: 'camera',
|
||
label: 'Камера',
|
||
children: <CameraTab device={device} objId={objId} tasks={tasks} creds={creds} />,
|
||
}] : []),
|
||
{
|
||
key: 'alerts',
|
||
label: 'Алерты',
|
||
children: <AlertsTab deviceId={deviceId} />,
|
||
},
|
||
{
|
||
key: 'snapshots',
|
||
label: 'Снапшоты',
|
||
children: <SnapshotsTab objId={objId} deviceId={deviceId} device={device} />,
|
||
},
|
||
{
|
||
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} wrap>
|
||
<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>
|
||
)}
|
||
{!device.is_active && <Tag color="red">Неактивно</Tag>}
|
||
</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.firmware_version && (
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
<InfoCircleOutlined style={{ marginRight: 4 }} />
|
||
{device.firmware_version}
|
||
</Typography.Text>
|
||
)}
|
||
</Space>
|
||
</Col>
|
||
<Col>
|
||
<Space wrap>
|
||
{webUrl && (
|
||
<Tooltip title={webUrl}>
|
||
<Button icon={<LinkOutlined />} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||
Веб-интерфейс
|
||
</Button>
|
||
</Tooltip>
|
||
)}
|
||
<Tooltip title="Запустить пинг">
|
||
<Button
|
||
icon={<ThunderboltOutlined />}
|
||
loading={createTask.isPending}
|
||
onClick={handlePing}
|
||
>
|
||
Пинг
|
||
</Button>
|
||
</Tooltip>
|
||
{isLinuxLike && (
|
||
<Tooltip title="Собрать системную информацию (ОС, RAM, диски)">
|
||
<Button icon={<InfoCircleOutlined />} onClick={handleSystemInfo}>
|
||
Sys Info
|
||
</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>
|
||
)
|
||
}
|