фикс карточки устройства

This commit is contained in:
dv 2026-04-14 13:37:50 +03:00
parent d0f957a30f
commit 13ef5fed73
4 changed files with 622 additions and 89 deletions

View file

@ -17,7 +17,9 @@
"Bash(grep -r 8000 /c/Users/Professional/Documents/VSCode/utp_service/backend/ --include=*.py --include=*.sh)", "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(grep -n \"rack_type\" /c/Users/Professional/Documents/VSCode/utp_service/backend/alembic/versions/*.py)",
"Bash(python3 -c \":*)", "Bash(python3 -c \":*)",
"Bash(python -c ':*)" "Bash(python -c ':*)",
"Bash(node_modules/.bin/tsc --noEmit)",
"Bash(./node_modules/.bin/tsc --noEmit)"
] ]
} }
} }

View file

@ -231,6 +231,19 @@ async def get_device(
return DeviceRead.from_orm_with_rack(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) @router.patch("/{device_id}", response_model=DeviceRead)
async def update_device( async def update_device(
obj_id: int, obj_id: int,

View file

@ -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) => { export const useSyncSheets = (objId: number) => {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({

View file

@ -4,7 +4,12 @@ import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
EyeOutlined, EyeOutlined,
EyeInvisibleOutlined,
LinkOutlined, LinkOutlined,
ThunderboltOutlined,
FileSearchOutlined,
InfoCircleOutlined,
CameraOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { import {
Badge, Badge,
@ -13,7 +18,10 @@ import {
Card, Card,
Col, Col,
Descriptions, Descriptions,
Input,
Modal,
Popconfirm, Popconfirm,
Progress,
Result, Result,
Row, Row,
Select, Select,
@ -30,33 +38,411 @@ import dayjs from 'dayjs'
import { useState } from 'react' import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useAlerts, useAcknowledgeAlert, useResolveAlert, type AlertItem } from '../api/alerts' 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 { getDeviceWebUrl } from '../utils/deviceUrl'
import { elapsedAgo } from '../utils/time' import { elapsedAgo } from '../utils/time'
import { useSnapshots, useSnapshotDiff } from '../api/snapshots' 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 { DeviceEditModal } from '../components/DeviceEditModal'
import { CategoryTag, DeviceStatusBadge, TaskStatusBadge } from '../components/StatusBadge' import { CategoryTag, DeviceStatusBadge, TaskStatusBadge } from '../components/StatusBadge'
import { useMonitorSSE } from '../hooks/useMonitorSSE' import { useMonitorSSE } from '../hooks/useMonitorSSE'
// ─── Constants ────────────────────────────────────────────────────────────────
const ALERT_STATUS_COLOR: Record<string, string> = { const ALERT_STATUS_COLOR: Record<string, string> = {
open: 'error', open: 'error',
acknowledged: 'warning', acknowledged: 'warning',
resolved: 'success', resolved: 'success',
} }
const ALERT_STATUS_LABEL: Record<string, string> = { const ALERT_STATUS_LABEL: Record<string, string> = {
open: 'Открыт', open: 'Открыт',
acknowledged: 'Подтверждён', acknowledged: 'Подтверждён',
resolved: 'Закрыт', resolved: 'Закрыт',
} }
const LOCATION_LABEL: Record<string, string> = { const LOCATION_LABEL: Record<string, string> = {
server_room: 'Серверная', server_room: 'Серверная',
rack: 'Стойка',
street: 'Улица', 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',
}
// ─── Alerts tab ───────────────────────────────────────────────────────────── // 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, 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 (
<Descriptions
bordered
size="small"
column={1}
labelStyle={{ width: 200 }}
style={{ marginTop: 16 }}
title={<Typography.Text strong style={{ fontSize: 13 }}>SSH / Авторизация</Typography.Text>}
>
<Descriptions.Item label="Логин">
{device.ssh_user_override
? <><Typography.Text code>{device.ssh_user_override}</Typography.Text> <Tag color="blue" style={{ fontSize: 10 }}>override</Tag></>
: <Typography.Text type="secondary">С объекта</Typography.Text>}
</Descriptions.Item>
<Descriptions.Item label="Пароль">
{device.ssh_key_id_override != null ? (
<Typography.Text type="secondary"> (используется ключ)</Typography.Text>
) : (
<Space size={8}>
{showPassword ? (
credsFetching
? <Spin size="small" />
: creds?.ssh_password
? <Typography.Text code>{creds.ssh_password}</Typography.Text>
: <Typography.Text type="secondary">Не задан (используется с объекта)</Typography.Text>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
<Button
size="small"
type="text"
icon={showPassword ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? 'Скрыть' : 'Показать'}
</Button>
</Space>
)}
</Descriptions.Item>
<Descriptions.Item label="Порт">
{device.ssh_port_override
? <><Typography.Text code>{device.ssh_port_override}</Typography.Text> <Tag color="blue" style={{ fontSize: 10 }}>override</Tag></>
: <Typography.Text type="secondary">22 (по умолчанию)</Typography.Text>}
</Descriptions.Item>
{device.ssh_key_id_override != null && (
<Descriptions.Item label="SSH ключ (override)">
<Typography.Text code>key_id={device.ssh_key_id_override}</Typography.Text>
</Descriptions.Item>
)}
{!hasOverride && (
<Descriptions.Item label="Кредиалы">
<Typography.Text type="secondary">Используются настройки объекта</Typography.Text>
</Descriptions.Item>
)}
{lastAuth && (
<Descriptions.Item label="Последний успешный SSH">
<Space size={4} wrap>
<Tag color="green">{String(lastAuth.type ?? '—')}</Tag>
{lastAuth.username && <Typography.Text code>{String(lastAuth.username)}</Typography.Text>}
{lastAuth.key_id != null && <Typography.Text type="secondary">key_id={String(lastAuth.key_id)}</Typography.Text>}
{lastAuth.cred_id != null && <Typography.Text type="secondary">cred_id={String(lastAuth.cred_id)}</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 }: { 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<string, unknown> = {}
const remaining: Record<string, unknown> = {}
for (const [k, v] of Object.entries(params)) {
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
return (
<Card size="small" title="Параметры подключения">
{hasDisplayed && (
<Descriptions size="small" column={1} style={{ marginBottom: hasRemaining ? 8 : 0 }}>
{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>
))}
</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.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 }) { function AlertsTab({ deviceId }: { deviceId: number }) {
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined) const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
@ -65,11 +451,7 @@ function AlertsTab({ deviceId }: { deviceId: number }) {
const resolve = useResolveAlert() const resolve = useResolveAlert()
const columns = [ const columns = [
{ { title: 'Сообщение', dataIndex: 'message', key: 'message' },
title: 'Сообщение',
dataIndex: 'message',
key: 'message',
},
{ {
title: 'Статус', title: 'Статус',
dataIndex: 'status', dataIndex: 'status',
@ -159,13 +541,55 @@ function AlertsTab({ deviceId }: { deviceId: number }) {
) )
} }
// ─── Snapshots tab ─────────────────────────────────────────────────────────── // ─── Snapshots tab ───────────────────────────────────────────────────────────
function SnapshotsTab({ objId, deviceId }: { objId: number; deviceId: number }) { function SnapshotsTab({ objId, deviceId, device }: { objId: number; deviceId: number; device: DeviceItem }) {
const { data: snapshots, isLoading } = useSnapshots(objId, { device_id: deviceId }) const { data: snapshots, isLoading, refetch } = useSnapshots(objId, { device_id: deviceId })
const [selectedA, setSelectedA] = useState<number | null>(null) const [selectedA, setSelectedA] = useState<number | null>(null)
const [selectedB, setSelectedB] = useState<number | null>(null) const [selectedB, setSelectedB] = useState<number | null>(null)
const { data: diffData, isFetching: diffLoading } = useSnapshotDiff(objId, selectedA, selectedB) 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) => ({ const snapshotOptions = (snapshots ?? []).map((s) => ({
value: s.id, value: s.id,
@ -214,9 +638,7 @@ function SnapshotsTab({ objId, deviceId }: { objId: number; deviceId: number })
if (line.startsWith('+') && !line.startsWith('+++')) color = '#237804' if (line.startsWith('+') && !line.startsWith('+++')) color = '#237804'
else if (line.startsWith('-') && !line.startsWith('---')) color = '#a8071a' else if (line.startsWith('-') && !line.startsWith('---')) color = '#a8071a'
else if (line.startsWith('@@')) color = '#096dd9' else if (line.startsWith('@@')) color = '#096dd9'
return ( return <span key={i} style={{ color, display: 'block' }}>{line}</span>
<span key={i} style={{ color, display: 'block' }}>{line}</span>
)
})} })}
</pre> </pre>
) )
@ -224,6 +646,34 @@ function SnapshotsTab({ objId, deviceId }: { objId: number; deviceId: number })
return ( 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 <Table
dataSource={snapshots ?? []} dataSource={snapshots ?? []}
columns={columns} columns={columns}
@ -254,18 +704,40 @@ function SnapshotsTab({ objId, deviceId }: { objId: number; deviceId: number })
allowClear allowClear
/> />
</Space> </Space>
{diffLoading && <Spin />} {diffLoading && <Spin />}
{diffData && !diffLoading && ( {diffData && !diffLoading && (
diffData.is_identical diffData.is_identical
? <Typography.Text type="secondary">Снапшоты идентичны</Typography.Text> ? <Typography.Text type="secondary">Снапшоты идентичны</Typography.Text>
: renderDiff(diffData.diff) : 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 ─────────────────────────────────────────────────────────────── // ─── Tasks tab ───────────────────────────────────────────────────────────────
function TaskResultRow({ taskId, deviceId }: { taskId: string; deviceId: number }) { function TaskResultRow({ taskId, deviceId }: { taskId: string; deviceId: number }) {
const { data: detail, isLoading } = useTask(taskId) const { data: detail, isLoading } = useTask(taskId)
@ -347,11 +819,7 @@ function TasksTab({ deviceId }: { deviceId: number }) {
width: 80, width: 80,
render: (_: unknown, row: Task) => ( render: (_: unknown, row: Task) => (
<Tooltip title="Открыть задачу"> <Tooltip title="Открыть задачу">
<Button <Button size="small" icon={<EyeOutlined />} onClick={() => navigate(`/tasks/${row.id}`)} />
size="small"
icon={<EyeOutlined />}
onClick={() => navigate(`/tasks/${row.id}`)}
/>
</Tooltip> </Tooltip>
), ),
}, },
@ -375,7 +843,7 @@ function TasksTab({ deviceId }: { deviceId: number }) {
) )
} }
// ─── Main page ─────────────────────────────────────────────────────────────── // ─── Main page ───────────────────────────────────────────────────────────────
export function DeviceDetailPage() { export function DeviceDetailPage() {
const { id: objIdStr, deviceId: deviceIdStr } = useParams<{ id: string; deviceId: string }>() const { id: objIdStr, deviceId: deviceIdStr } = useParams<{ id: string; deviceId: string }>()
@ -385,7 +853,9 @@ export function DeviceDetailPage() {
const { data: obj } = useObject(objId) const { data: obj } = useObject(objId)
const { data: device, isLoading } = useDevice(objId, deviceId) const { data: device, isLoading } = useDevice(objId, deviceId)
const { data: tasks } = useDeviceTasks(deviceId)
const deleteDevice = useDeleteDevice(objId) const deleteDevice = useDeleteDevice(objId)
const createTask = useCreateTask()
const qc = useQueryClient() const qc = useQueryClient()
const [editOpen, setEditOpen] = useState(false) const [editOpen, setEditOpen] = useState(false)
@ -415,8 +885,54 @@ export function DeviceDetailPage() {
} }
} }
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 (isLoading) return <Spin style={{ display: 'block', marginTop: 48 }} />
if (!device) return <Result status="404" title="Устройство не найдено" extra={<Button onClick={() => navigate(`/inventory/objects/${objId}`)}>К объекту</Button>} /> 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 = [ const breadcrumbItems = [
{ title: <a onClick={() => navigate('/inventory/objects')}>Инвентарь</a> }, { title: <a onClick={() => navigate('/inventory/objects')}>Инвентарь</a> },
@ -432,6 +948,7 @@ export function DeviceDetailPage() {
children: ( children: (
<Row gutter={24}> <Row gutter={24}>
<Col xs={24} lg={14}> <Col xs={24} lg={14}>
{/* Identity */}
<Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }}> <Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }}>
<Descriptions.Item label="IP"><code>{device.ip}</code></Descriptions.Item> <Descriptions.Item label="IP"><code>{device.ip}</code></Descriptions.Item>
<Descriptions.Item label="Hostname">{device.hostname ?? '—'}</Descriptions.Item> <Descriptions.Item label="Hostname">{device.hostname ?? '—'}</Descriptions.Item>
@ -442,7 +959,14 @@ export function DeviceDetailPage() {
<Descriptions.Item label="Категория"><CategoryTag category={device.category} /></Descriptions.Item> <Descriptions.Item label="Категория"><CategoryTag category={device.category} /></Descriptions.Item>
<Descriptions.Item label="Роль">{device.role !== 'other' ? device.role : '—'}</Descriptions.Item> <Descriptions.Item label="Роль">{device.role !== 'other' ? device.role : '—'}</Descriptions.Item>
<Descriptions.Item label="Источник"> <Descriptions.Item label="Источник">
<Typography.Text type="secondary">{device.source}</Typography.Text> <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> </Descriptions.Item>
{device.external_id && ( {device.external_id && (
<Descriptions.Item label="External ID"> <Descriptions.Item label="External ID">
@ -451,18 +975,22 @@ export function DeviceDetailPage() {
)} )}
</Descriptions> </Descriptions>
{/* Location */}
<Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }} style={{ marginTop: 16 }}> <Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }} style={{ marginTop: 16 }}>
<Descriptions.Item label="Объект"> <Descriptions.Item label="Объект">
<a onClick={() => navigate(`/inventory/objects/${objId}`)}>{obj?.name ?? `#${objId}`}</a> <a onClick={() => navigate(`/inventory/objects/${objId}`)}>{obj?.name ?? `#${objId}`}</a>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="Стойка"> <Descriptions.Item label="Стойка">
{device.rack_name ? <Tag color="blue">{device.rack_name}{device.rack_type ? ` ${device.rack_type}` : ''}</Tag> : '—'} {device.rack_name
? <Tag color="blue">{device.rack_name}{device.rack_type ? ` ${device.rack_type}` : ''}</Tag>
: '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="Локация"> <Descriptions.Item label="Локация">
{device.location ? (LOCATION_LABEL[device.location] ?? device.location) : '—'} {device.location ? (LOCATION_LABEL[device.location] ?? device.location) : '—'}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
{/* Monitoring */}
<Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }} style={{ marginTop: 16 }}> <Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }} style={{ marginTop: 16 }}>
<Descriptions.Item label="Статус"><DeviceStatusBadge status={effectiveStatus} /></Descriptions.Item> <Descriptions.Item label="Статус"><DeviceStatusBadge status={effectiveStatus} /></Descriptions.Item>
<Descriptions.Item label="Последний пинг"> <Descriptions.Item label="Последний пинг">
@ -492,37 +1020,11 @@ export function DeviceDetailPage() {
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
{(device.ssh_user_override || device.ssh_port_override || device.ssh_key_id_override || device.ssh_last_auth) && ( {/* SSH block — always shown */}
<Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }} style={{ marginTop: 16 }}> <SshBlock device={device} objId={objId} />
{device.ssh_user_override && (
<Descriptions.Item label="SSH логин (override)">{device.ssh_user_override}</Descriptions.Item> {/* System info (Linux-like devices) */}
)} {isLinuxLike && <SystemInfoBlock device={device} tasks={tasks} />}
{device.ssh_port_override && (
<Descriptions.Item label="SSH порт (override)">{device.ssh_port_override}</Descriptions.Item>
)}
{device.ssh_key_id_override != null && (
<Descriptions.Item label="SSH ключ (override)">
<Typography.Text code>key_id={device.ssh_key_id_override}</Typography.Text>
</Descriptions.Item>
)}
{device.ssh_last_auth && (
<Descriptions.Item label="Последний успешный SSH">
<Space size={4} wrap>
<Tag color="green">{String(device.ssh_last_auth.type ?? '—')}</Tag>
{device.ssh_last_auth.username && (
<Typography.Text code>{String(device.ssh_last_auth.username)}</Typography.Text>
)}
{device.ssh_last_auth.key_id != null && (
<Typography.Text type="secondary">key_id={String(device.ssh_last_auth.key_id)}</Typography.Text>
)}
{device.ssh_last_auth.cred_id != null && (
<Typography.Text type="secondary">cred_id={String(device.ssh_last_auth.cred_id)}</Typography.Text>
)}
</Space>
</Descriptions.Item>
)}
</Descriptions>
)}
{device.notes && ( {device.notes && (
<Card size="small" title="Заметки" style={{ marginTop: 16 }}> <Card size="small" title="Заметки" style={{ marginTop: 16 }}>
@ -532,10 +1034,14 @@ export function DeviceDetailPage() {
<Descriptions bordered size="small" column={2} style={{ marginTop: 16 }}> <Descriptions bordered size="small" column={2} style={{ marginTop: 16 }}>
<Descriptions.Item label="Создано"> <Descriptions.Item label="Создано">
{dayjs(device.created_at).format('DD.MM.YYYY HH:mm')} <Tooltip title={elapsedAgo(device.created_at)}>
{dayjs(device.created_at).format('DD.MM.YYYY HH:mm')}
</Tooltip>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="Обновлено"> <Descriptions.Item label="Обновлено">
{dayjs(device.updated_at).format('DD.MM.YYYY HH:mm')} <Tooltip title={elapsedAgo(device.updated_at)}>
{dayjs(device.updated_at).format('DD.MM.YYYY HH:mm')}
</Tooltip>
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Col> </Col>
@ -548,22 +1054,8 @@ export function DeviceDetailPage() {
</Space> </Space>
</Card> </Card>
)} )}
<DeviceMetaBlock device={device} />
{device.device_meta && Object.keys(device.device_meta).length > 0 && ( <ConnectionParamsBlock device={device} />
<Card size="small" title="Метаданные устройства" style={{ marginBottom: 16 }}>
<pre style={{ fontSize: 11, margin: 0, maxHeight: 300, overflow: 'auto' }}>
{JSON.stringify(device.device_meta, null, 2)}
</pre>
</Card>
)}
{device.connection_params && Object.keys(device.connection_params).length > 0 && (
<Card size="small" title="Параметры подключения">
<pre style={{ fontSize: 11, margin: 0, maxHeight: 300, overflow: 'auto' }}>
{JSON.stringify(device.connection_params, null, 2)}
</pre>
</Card>
)}
</Col> </Col>
</Row> </Row>
), ),
@ -576,7 +1068,7 @@ export function DeviceDetailPage() {
{ {
key: 'snapshots', key: 'snapshots',
label: 'Снапшоты', label: 'Снапшоты',
children: <SnapshotsTab objId={objId} deviceId={deviceId} />, children: <SnapshotsTab objId={objId} deviceId={deviceId} device={device} />,
}, },
{ {
key: 'tasks', key: 'tasks',
@ -592,7 +1084,7 @@ export function DeviceDetailPage() {
<Card style={{ marginBottom: 16 }}> <Card style={{ marginBottom: 16 }}>
<Row justify="space-between" align="top"> <Row justify="space-between" align="top">
<Col> <Col>
<Space align="baseline" size={12}> <Space align="baseline" size={12} wrap>
<Typography.Title level={3} style={{ margin: 0 }}> <Typography.Title level={3} style={{ margin: 0 }}>
<code>{device.ip}</code> <code>{device.ip}</code>
</Typography.Title> </Typography.Title>
@ -600,6 +1092,7 @@ export function DeviceDetailPage() {
{effectivePing !== null && ( {effectivePing !== null && (
<Typography.Text type="secondary">{effectivePing}мс</Typography.Text> <Typography.Text type="secondary">{effectivePing}мс</Typography.Text>
)} )}
{!device.is_active && <Tag color="red">Неактивно</Tag>}
</Space> </Space>
{device.hostname && ( {device.hostname && (
<div> <div>
@ -612,25 +1105,39 @@ export function DeviceDetailPage() {
{device.rack_name && <Tag color="blue">{device.rack_name}</Tag>} {device.rack_name && <Tag color="blue">{device.rack_name}</Tag>}
{device.vendor && <Tag color="default">{device.vendor}</Tag>} {device.vendor && <Tag color="default">{device.vendor}</Tag>}
{device.model && <Typography.Text type="secondary" style={{ fontSize: 13 }}>{device.model}</Typography.Text>} {device.model && <Typography.Text type="secondary" style={{ fontSize: 13 }}>{device.model}</Typography.Text>}
{device.capabilities.map((c) => ( {device.firmware_version && (
<Tag key={c} color="geekblue" style={{ fontSize: 11 }}>{c}</Tag> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
))} <InfoCircleOutlined style={{ marginRight: 4 }} />
{device.firmware_version}
</Typography.Text>
)}
</Space> </Space>
</Col> </Col>
<Col> <Col>
<Space> <Space wrap>
{webUrl && ( {webUrl && (
<Tooltip title={webUrl}> <Tooltip title={webUrl}>
<Button <Button icon={<LinkOutlined />} href={webUrl} target="_blank" rel="noopener noreferrer">
icon={<LinkOutlined />}
href={webUrl}
target="_blank"
rel="noopener noreferrer"
>
Веб-интерфейс Веб-интерфейс
</Button> </Button>
</Tooltip> </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 icon={<EditOutlined />} onClick={() => setEditOpen(true)}>
Редактировать Редактировать
</Button> </Button>