utp_service/frontend/src/pages/DeviceDetail.tsx
2026-04-14 14:12:01 +03:00

1427 lines
52 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,
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>
)
}