фикс карточки устройства
This commit is contained in:
parent
d0f957a30f
commit
13ef5fed73
4 changed files with 622 additions and 89 deletions
|
|
@ -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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="Создано">
|
||||||
|
<Tooltip title={elapsedAgo(device.created_at)}>
|
||||||
{dayjs(device.created_at).format('DD.MM.YYYY HH:mm')}
|
{dayjs(device.created_at).format('DD.MM.YYYY HH:mm')}
|
||||||
|
</Tooltip>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="Обновлено">
|
<Descriptions.Item label="Обновлено">
|
||||||
|
<Tooltip title={elapsedAgo(device.updated_at)}>
|
||||||
{dayjs(device.updated_at).format('DD.MM.YYYY HH:mm')}
|
{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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue