From 13ef5fed738ae3a59b1084aaee72defb5e54d517 Mon Sep 17 00:00:00 2001 From: dv Date: Tue, 14 Apr 2026 13:37:50 +0300 Subject: [PATCH] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D1=83=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +- backend/app/routers/devices.py | 13 + frontend/src/api/objects.ts | 11 + frontend/src/pages/DeviceDetail.tsx | 683 ++++++++++++++++++++++++---- 4 files changed, 622 insertions(+), 89 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 096c26a..2398331 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,9 @@ "Bash(grep -r 8000 /c/Users/Professional/Documents/VSCode/utp_service/backend/ --include=*.py --include=*.sh)", "Bash(grep -n \"rack_type\" /c/Users/Professional/Documents/VSCode/utp_service/backend/alembic/versions/*.py)", "Bash(python3 -c \":*)", - "Bash(python -c ':*)" + "Bash(python -c ':*)", + "Bash(node_modules/.bin/tsc --noEmit)", + "Bash(./node_modules/.bin/tsc --noEmit)" ] } } diff --git a/backend/app/routers/devices.py b/backend/app/routers/devices.py index be28f03..217c84a 100644 --- a/backend/app/routers/devices.py +++ b/backend/app/routers/devices.py @@ -231,6 +231,19 @@ async def get_device( return DeviceRead.from_orm_with_rack(device) +@router.get("/{device_id}/credentials") +async def get_device_credentials( + obj_id: int, + device_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + """Return decrypted SSH password override for the device (if set).""" + device = await _get_device(db, obj_id, device_id) + password = crypto.decrypt(device.ssh_pass_override_enc) if device.ssh_pass_override_enc else None + return {"ssh_password": password} + + @router.patch("/{device_id}", response_model=DeviceRead) async def update_device( obj_id: int, diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index 0d43d56..8ef9ae3 100644 --- a/frontend/src/api/objects.ts +++ b/frontend/src/api/objects.ts @@ -351,6 +351,17 @@ export const useImportObjectsXLSX = () => { }) } +export const useDeviceCredentials = (objId: number, deviceId: number, enabled: boolean) => + useQuery({ + queryKey: ['device-credentials', objId, deviceId], + queryFn: () => + api + .get<{ ssh_password: string | null }>(`/api/v1/objects/${objId}/devices/${deviceId}/credentials`) + .then((r) => r.data), + enabled: enabled && !!objId && !!deviceId, + staleTime: 60_000, + }) + export const useSyncSheets = (objId: number) => { const qc = useQueryClient() return useMutation({ diff --git a/frontend/src/pages/DeviceDetail.tsx b/frontend/src/pages/DeviceDetail.tsx index 0830bc3..be2274d 100644 --- a/frontend/src/pages/DeviceDetail.tsx +++ b/frontend/src/pages/DeviceDetail.tsx @@ -4,7 +4,12 @@ import { DeleteOutlined, EditOutlined, EyeOutlined, + EyeInvisibleOutlined, LinkOutlined, + ThunderboltOutlined, + FileSearchOutlined, + InfoCircleOutlined, + CameraOutlined, } from '@ant-design/icons' import { Badge, @@ -13,7 +18,10 @@ import { Card, Col, Descriptions, + Input, + Modal, Popconfirm, + Progress, Result, Row, Select, @@ -30,33 +38,411 @@ import dayjs from 'dayjs' import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useAlerts, useAcknowledgeAlert, useResolveAlert, type AlertItem } from '../api/alerts' -import { useDevice, useDeleteDevice, useObject } from '../api/objects' +import { useDevice, useDeleteDevice, useObject, useDeviceCredentials, type DeviceItem } from '../api/objects' import { getDeviceWebUrl } from '../utils/deviceUrl' import { elapsedAgo } from '../utils/time' import { useSnapshots, useSnapshotDiff } from '../api/snapshots' -import { useDeviceTasks, useTask, type Task } from '../api/tasks' +import { 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', +} -// ─── Alerts tab ───────────────────────────────────────────────────────────── +// 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, objId }: { device: DeviceItem; objId: number }) { + const [showPassword, setShowPassword] = useState(false) + const { data: creds, isFetching: credsFetching } = useDeviceCredentials(objId, device.id, showPassword) + + const hasOverride = !!(device.ssh_user_override || device.ssh_port_override || device.ssh_key_id_override) + const lastAuth = device.ssh_last_auth + + return ( + SSH / Авторизация} + > + + {device.ssh_user_override + ? <>{device.ssh_user_override} override + : С объекта} + + + + {device.ssh_key_id_override != null ? ( + — (используется ключ) + ) : ( + + {showPassword ? ( + credsFetching + ? + : creds?.ssh_password + ? {creds.ssh_password} + : Не задан (используется с объекта) + ) : ( + •••••••• + )} + + + )} + + + + {device.ssh_port_override + ? <>{device.ssh_port_override} override + : 22 (по умолчанию)} + + + {device.ssh_key_id_override != null && ( + + key_id={device.ssh_key_id_override} + + )} + + {!hasOverride && ( + + Используются настройки объекта + + )} + + {lastAuth && ( + + + {String(lastAuth.type ?? '—')} + {lastAuth.username && {String(lastAuth.username)}} + {lastAuth.key_id != null && key_id={String(lastAuth.key_id)}} + {lastAuth.cred_id != null && cred_id={String(lastAuth.cred_id)}} + + + )} + + ) +} + +// ─── 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 }: { device: DeviceItem }) { + 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 (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 + + return ( + + {hasDisplayed && ( + + {Object.entries(displayed).map(([k, v]) => ( + + {k === 'auth_type' + ? {String(v)} + : k === 'protocol' + ? {String(v).toUpperCase()} + : k === 'url' + ? {String(v)} + : {String(v)}} + + ))} + + )} + {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.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) @@ -65,11 +451,7 @@ function AlertsTab({ deviceId }: { deviceId: number }) { const resolve = useResolveAlert() const columns = [ - { - title: 'Сообщение', - dataIndex: 'message', - key: 'message', - }, + { title: 'Сообщение', dataIndex: 'message', key: 'message' }, { title: 'Статус', dataIndex: 'status', @@ -159,13 +541,55 @@ function AlertsTab({ deviceId }: { deviceId: number }) { ) } -// ─── Snapshots tab ─────────────────────────────────────────────────────────── +// ─── Snapshots tab ──────────────────────────────────────────────────────────── -function SnapshotsTab({ objId, deviceId }: { objId: number; deviceId: number }) { - const { data: snapshots, isLoading } = useSnapshots(objId, { device_id: deviceId }) +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, @@ -214,9 +638,7 @@ function SnapshotsTab({ objId, deviceId }: { objId: number; deviceId: number }) if (line.startsWith('+') && !line.startsWith('+++')) color = '#237804' else if (line.startsWith('-') && !line.startsWith('---')) color = '#a8071a' else if (line.startsWith('@@')) color = '#096dd9' - return ( - {line} - ) + return {line} })} ) @@ -224,6 +646,34 @@ function SnapshotsTab({ objId, deviceId }: { objId: number; deviceId: number }) 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 ─────────────────────────────────────────────────────────────── +// ─── Tasks tab ──────────────────────────────────────────────────────────────── function TaskResultRow({ taskId, deviceId }: { taskId: string; deviceId: number }) { const { data: detail, isLoading } = useTask(taskId) @@ -347,11 +819,7 @@ function TasksTab({ deviceId }: { deviceId: number }) { width: 80, render: (_: unknown, row: Task) => ( - } /> + if (!device) return ( + navigate(`/inventory/objects/${objId}`)}>К объекту} + /> + ) + + const isLinuxLike = ['main_server', 'vm', 'embedded', 'other'].includes(device.category) const breadcrumbItems = [ { title: navigate('/inventory/objects')}>Инвентарь }, @@ -432,6 +948,7 @@ export function DeviceDetailPage() { children: ( + {/* Identity */} {device.ip} {device.hostname ?? '—'} @@ -442,7 +959,14 @@ export function DeviceDetailPage() { {device.role !== 'other' ? device.role : '—'} - {device.source} + + {SOURCE_LABEL[device.source] ?? device.source} + + + + {device.is_active + ? Да + : Нет} {device.external_id && ( @@ -451,18 +975,22 @@ export function DeviceDetailPage() { )} + {/* Location */} navigate(`/inventory/objects/${objId}`)}>{obj?.name ?? `#${objId}`} - {device.rack_name ? {device.rack_name}{device.rack_type ? ` ${device.rack_type}` : ''} : '—'} + {device.rack_name + ? {device.rack_name}{device.rack_type ? ` ${device.rack_type}` : ''} + : '—'} {device.location ? (LOCATION_LABEL[device.location] ?? device.location) : '—'} + {/* Monitoring */} @@ -492,37 +1020,11 @@ export function DeviceDetailPage() { - {(device.ssh_user_override || device.ssh_port_override || device.ssh_key_id_override || device.ssh_last_auth) && ( - - {device.ssh_user_override && ( - {device.ssh_user_override} - )} - {device.ssh_port_override && ( - {device.ssh_port_override} - )} - {device.ssh_key_id_override != null && ( - - key_id={device.ssh_key_id_override} - - )} - {device.ssh_last_auth && ( - - - {String(device.ssh_last_auth.type ?? '—')} - {device.ssh_last_auth.username && ( - {String(device.ssh_last_auth.username)} - )} - {device.ssh_last_auth.key_id != null && ( - key_id={String(device.ssh_last_auth.key_id)} - )} - {device.ssh_last_auth.cred_id != null && ( - cred_id={String(device.ssh_last_auth.cred_id)} - )} - - - )} - - )} + {/* SSH block — always shown */} + + + {/* System info (Linux-like devices) */} + {isLinuxLike && } {device.notes && ( @@ -532,10 +1034,14 @@ export function DeviceDetailPage() { - {dayjs(device.created_at).format('DD.MM.YYYY HH:mm')} + + {dayjs(device.created_at).format('DD.MM.YYYY HH:mm')} + - {dayjs(device.updated_at).format('DD.MM.YYYY HH:mm')} + + {dayjs(device.updated_at).format('DD.MM.YYYY HH:mm')} + @@ -548,22 +1054,8 @@ export function DeviceDetailPage() { )} - - {device.device_meta && Object.keys(device.device_meta).length > 0 && ( - -
-                  {JSON.stringify(device.device_meta, null, 2)}
-                
-
- )} - - {device.connection_params && Object.keys(device.connection_params).length > 0 && ( - -
-                  {JSON.stringify(device.connection_params, null, 2)}
-                
-
- )} + + ), @@ -576,7 +1068,7 @@ export function DeviceDetailPage() { { key: 'snapshots', label: 'Снапшоты', - children: , + children: , }, { key: 'tasks', @@ -592,7 +1084,7 @@ export function DeviceDetailPage() {
- + {device.ip} @@ -600,6 +1092,7 @@ export function DeviceDetailPage() { {effectivePing !== null && ( {effectivePing}мс )} + {!device.is_active && Неактивно} {device.hostname && (
@@ -612,25 +1105,39 @@ export function DeviceDetailPage() { {device.rack_name && {device.rack_name}} {device.vendor && {device.vendor}} {device.model && {device.model}} - {device.capabilities.map((c) => ( - {c} - ))} + {device.firmware_version && ( + + + {device.firmware_version} + + )}
- + {webUrl && ( - )} + + + + {isLinuxLike && ( + + + + )}