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 = { open: 'error', acknowledged: 'warning', resolved: 'success', } const ALERT_STATUS_LABEL: Record = { open: 'Открыт', acknowledged: 'Подтверждён', resolved: 'Закрыт', } const LOCATION_LABEL: Record = { server_room: 'Серверная', rack: 'Стойка', street: 'Улица', } const SOURCE_COLOR: Record = { manual: 'blue', db_sync: 'purple', auto_ssh: 'green', csv: 'orange', sheets: 'cyan', } const SOURCE_LABEL: Record = { 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 = { 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 ( SSH / Авторизация} > {effectiveUser ? {effectiveUser} : Не задан} {device.ssh_user_override ? device : obj?.ssh_user ? объект : null} {usesKey ? ( SSH ключ {(lastAuth?.key_id != null || device.ssh_key_id_override != null) && ( key_id={String(lastAuth?.key_id ?? device.ssh_key_id_override)} )} ) : credsLoading ? ( ) : effectiveSshPassword ? ( {effectiveSshPassword} {sshPasswordSource && {sshPasswordSource}} ) : ( Не задан )} {effectivePort} {device.ssh_port_override && device} {lastAuth && ( успешно {String(lastAuth.type) === 'key' ? 'ключ' : 'пароль'} {lastAuth.username && ( {String(lastAuth.username)} )} )} ) } // ─── 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 = {} const remaining: Record = {} 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 = { 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 ( {hasKnown && ( {Object.entries(knownKeys).map(([k, v]) => ( {k.includes('url') || k.includes('rtsp') ? {v} : {v}} ))} )} {hasRemaining && (
          {JSON.stringify(remaining, null, 2)}
        
)}
) } // ─── 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 = {} const remaining: Record = {} 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 = { port: 'Порт', protocol: 'Протокол', auth_type: 'Тип авторизации', username: 'Логин', url: 'URL', api_port: 'API порт', web_port: 'Web порт', } const AUTH_COLOR: Record = { 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 ( {hasDisplayed && Object.entries(displayed).map(([k, v]) => ( {k === 'auth_type' ? {String(v)} : k === 'protocol' ? {String(v).toUpperCase()} : k === 'url' ? {String(v)} : {String(v)}} ))} {hasPassword && ( {credsLoading ? : creds?.connection_password ? {creds.connection_password} : Не задан} )} {hasRemaining && (
          {JSON.stringify(remaining, null, 2)}
        
)}
) } // ─── 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 | null | undefined if (isLoading) return if (!data || Object.keys(data).length === 0) return Нет данных return } function SystemInfoRender({ data }: { data: Record }) { 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 ( {data.os && ( {String(data.os)} {data.uptime && {String(data.uptime)}} {(data.load_1m || data.cpu_cores) && ( {data.cpu_cores && Ядра: {String(data.cpu_cores)}} {data.load_1m && Load: {String(data.load_1m)} / {String(data.load_5m ?? '?')} / {String(data.load_15m ?? '?')}} )} )} {memTotal !== undefined && memUsed !== undefined && (
RAM: {memUsed} MB / {memTotal} MB 90) ? 'exception' : 'normal'} style={{ marginTop: 2 }} />
)} {disks && disks.length > 0 && (
Диски: {disks.filter((d) => !isNaN(parseInt(d.pct))).map((d) => { const pctNum = parseInt(d.pct) return (
{d.mount} {d.used} / {d.size} ({d.pct}) 90 ? 'exception' : pctNum > 75 ? 'active' : 'normal'} showInfo={false} style={{ marginTop: 2 }} />
) })}
)} {temps && temps.length > 0 && (
Температуры: {temps.map((t, i) => {t})}
)}
) } 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 ( } /> ) } // 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 ( Системная информация (из задачи {dayjs(latestTask.created_at).format('DD.MM.YY HH:mm')}) } style={{ marginBottom: 16 }} > ) } // ─── Alerts tab ─────────────────────────────────────────────────────────────── function AlertsTab({ deviceId }: { deviceId: number }) { const [statusFilter, setStatusFilter] = useState(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) => ( ), }, { 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 ? ( {v} ) : '—', }, { title: '', key: 'actions', width: 120, render: (_: unknown, row: AlertItem) => ( {row.status === 'open' && ( } > {snapshotError ? (
{snapshotError}
) : ( Camera snapshot setSnapshotError('Не удалось загрузить снимок. Проверьте подключение и кредиалы камеры.')} onLoad={() => setSnapshotError(null)} /> )} Снимок загружается через сервер — не требует прямого доступа к камере из браузера ) } // ─── 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(null) const [selectedB, setSelectedB] = useState(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( Задача запущена.{' '} Открыть задачу , 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) => ( {v.slice(0, 12)}… ), }, { 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 Нет изменений return (
        {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 {line}
        })}
      
) } return ( <> {/* Take snapshot button */}
{snapshotConfig ? ( ) : ( )}
Сравнить снапшоты {diffLoading && } {diffData && !diffLoading && ( diffData.is_identical ? Снапшоты идентичны : renderDiff(diffData.diff) )} {/* Snapshot path modal */} setSnapshotModalOpen(false)} onOk={() => handleTakeSnapshot(snapshotPath)} confirmLoading={createTask.isPending} > Укажите путь к файлу на устройстве {device.ip} setSnapshotPath(e.target.value)} placeholder="/etc/caps/config.ini" addonBefore={} /> ) } // ─── 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 if (!deviceResult) return Нет данных return (
Статус: {deviceResult.status} {deviceResult.exit_code !== null && Код: {deviceResult.exit_code}} {deviceResult.duration_ms !== null && Время: {deviceResult.duration_ms}мс} {deviceResult.executed_at && ( Выполнено: {dayjs(deviceResult.executed_at).format('DD.MM.YY HH:mm:ss')} )} {deviceResult.stdout && (
          {deviceResult.stdout}
        
)} {deviceResult.stderr && (
          {deviceResult.stderr}
        
)} {deviceResult.data && Object.keys(deviceResult.data).length > 0 && (
          {JSON.stringify(deviceResult.data, null, 2)}
        
)}
) } function TasksTab({ deviceId }: { deviceId: number }) { const navigate = useNavigate() const { data: tasks, isLoading } = useDeviceTasks(deviceId) const columns = [ { title: 'Тип', dataIndex: 'type', key: 'type', render: (v: string) => {v}, }, { title: 'Статус', dataIndex: 'status', key: 'status', width: 140, render: (v: string) => , }, { 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) => (
( ), }} /> ) } // ─── 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( Пинг запущен.{' '} Открыть задачу , 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( Сбор информации запущен.{' '} Открыть задачу , 4, ) } catch { message.error('Не удалось запустить задачу') } } if (isLoading) return if (!device) return ( navigate(`/inventory/objects/${objId}`)}>К объекту} /> ) const isLinuxLike = ['main_server', 'vm', 'embedded', 'other'].includes(device.category) const breadcrumbItems = [ { title: navigate('/inventory/objects')}>Инвентарь }, ...(obj?.city ? [{ title: navigate('/inventory/objects')}>{obj.city} }] : []), { title: navigate(`/inventory/objects/${objId}`)}>{obj?.name ?? `Объект #${objId}`} }, { title: device.ip }, ] const tabItems = [ { key: 'info', label: 'Основное', children: ( {/* Identity */} {device.ip} {device.hostname ?? '—'} {device.mac ?? '—'} {device.vendor ?? '—'} {device.model ?? '—'} {device.firmware_version ?? '—'} {device.role !== 'other' ? device.role : '—'} {SOURCE_LABEL[device.source] ?? device.source} {device.is_active ? Да : Нет} {device.external_id && ( {device.external_id} )} {/* Location */} navigate(`/inventory/objects/${objId}`)}>{obj?.name ?? `#${objId}`} {device.rack_name ? {device.rack_name}{device.rack_type ? ` ${device.rack_type}` : ''} : '—'} {device.location ? (LOCATION_LABEL[device.location] ?? device.location) : '—'} {/* Monitoring */} {(device.last_ping_at ?? device.last_seen) ? ( {dayjs(device.last_ping_at ?? device.last_seen).format('DD.MM.YYYY HH:mm:ss')} ) : '—'} {device.last_seen ? ( {dayjs(device.last_seen).format('DD.MM.YYYY HH:mm:ss')} ) : '—'} {device.monitor_enabled ? Включён : Выключен} {/* SSH block — временно скрыт */} {/* System info (Linux-like devices) */} {isLinuxLike && } {device.notes && ( {device.notes} )} {dayjs(device.created_at).format('DD.MM.YYYY HH:mm')} {dayjs(device.updated_at).format('DD.MM.YYYY HH:mm')} {device.capabilities.length > 0 && ( {device.capabilities.map((c) => {c})} )} ), }, ...(device.category === 'camera' ? [{ key: 'camera', label: 'Камера', children: , }] : []), { key: 'alerts', label: 'Алерты', children: , }, { key: 'snapshots', label: 'Снапшоты', children: , }, { key: 'tasks', label: 'История задач', children: , }, ] return (
{device.ip} {effectivePing !== null && ( {effectivePing}мс )} {!device.is_active && Неактивно} {device.hostname && (
{device.hostname}
)} {device.role && device.role !== 'other' && {device.role}} {device.rack_name && {device.rack_name}} {device.vendor && {device.vendor}} {device.model && {device.model}} {device.firmware_version && ( {device.firmware_version} )} {webUrl && ( )} {isLinuxLike && ( )} ({ key: t.key, tab: t.label }))} activeTabKey={activeTab} onTabChange={setActiveTab} bodyStyle={{ paddingTop: 16 }} > {tabItems.find((t) => t.key === activeTab)?.children} setEditOpen(false)} /> ) }