фикс карточки устройства 3
This commit is contained in:
parent
a322b612f8
commit
ee38abbecb
3 changed files with 102 additions and 78 deletions
|
|
@ -238,10 +238,27 @@ async def get_device_credentials(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(get_current_user),
|
_: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Return decrypted SSH password override for the device (if set)."""
|
"""Return all decrypted passwords for the device: SSH (device override + object level) and connection_params."""
|
||||||
device = await _get_device(db, obj_id, device_id)
|
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
|
obj = await _get_object(db, obj_id)
|
||||||
return {"ssh_password": password}
|
|
||||||
|
ssh_password_device = crypto.decrypt(device.ssh_pass_override_enc) if device.ssh_pass_override_enc else None
|
||||||
|
ssh_password_object = crypto.decrypt(obj.ssh_pass_enc) if obj.ssh_pass_enc else None
|
||||||
|
|
||||||
|
# connection_params.password may be a Fernet ciphertext — try to decrypt, fall back to raw
|
||||||
|
connection_password: str | None = None
|
||||||
|
raw_conn_pass = (device.connection_params or {}).get("password")
|
||||||
|
if raw_conn_pass:
|
||||||
|
try:
|
||||||
|
connection_password = crypto.decrypt(str(raw_conn_pass))
|
||||||
|
except Exception:
|
||||||
|
connection_password = str(raw_conn_pass)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ssh_password_device": ssh_password_device,
|
||||||
|
"ssh_password_object": ssh_password_object,
|
||||||
|
"connection_password": connection_password,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{device_id}", response_model=DeviceRead)
|
@router.patch("/{device_id}", response_model=DeviceRead)
|
||||||
|
|
|
||||||
|
|
@ -351,14 +351,20 @@ export const useImportObjectsXLSX = () => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDeviceCredentials = (objId: number, deviceId: number, enabled: boolean) =>
|
export interface DeviceCredentials {
|
||||||
|
ssh_password_device: string | null
|
||||||
|
ssh_password_object: string | null
|
||||||
|
connection_password: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeviceCredentials = (objId: number, deviceId: number) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['device-credentials', objId, deviceId],
|
queryKey: ['device-credentials', objId, deviceId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api
|
api
|
||||||
.get<{ ssh_password: string | null }>(`/api/v1/objects/${objId}/devices/${deviceId}/credentials`)
|
.get<DeviceCredentials>(`/api/v1/objects/${objId}/devices/${deviceId}/credentials`)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
enabled: enabled && !!objId && !!deviceId,
|
enabled: !!objId && !!deviceId,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import {
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
EyeInvisibleOutlined,
|
|
||||||
LinkOutlined,
|
LinkOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
FileSearchOutlined,
|
FileSearchOutlined,
|
||||||
|
|
@ -38,7 +37,7 @@ 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, useDeviceCredentials, type DeviceItem, type ObjectItem } from '../api/objects'
|
import { useDevice, useDeleteDevice, useObject, useDeviceCredentials, type DeviceItem, type ObjectItem, type DeviceCredentials } 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'
|
||||||
|
|
@ -101,13 +100,19 @@ function getSnapshotConfig(device: DeviceItem) {
|
||||||
|
|
||||||
// ─── SSH / Credentials block ──────────────────────────────────────────────────
|
// ─── SSH / Credentials block ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function SshBlock({ device, objId, obj }: { device: DeviceItem; objId: number; obj: ObjectItem | undefined }) {
|
function SshBlock({
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
device,
|
||||||
const { data: creds, isFetching: credsFetching } = useDeviceCredentials(objId, device.id, showPassword)
|
obj,
|
||||||
|
creds,
|
||||||
|
credsLoading,
|
||||||
|
}: {
|
||||||
|
device: DeviceItem
|
||||||
|
obj: ObjectItem | undefined
|
||||||
|
creds: DeviceCredentials | undefined
|
||||||
|
credsLoading: boolean
|
||||||
|
}) {
|
||||||
const lastAuth = device.ssh_last_auth
|
const lastAuth = device.ssh_last_auth
|
||||||
|
|
||||||
// Resolve effective values: device override → object default → fallback
|
|
||||||
const effectiveUser =
|
const effectiveUser =
|
||||||
lastAuth?.username
|
lastAuth?.username
|
||||||
? String(lastAuth.username)
|
? String(lastAuth.username)
|
||||||
|
|
@ -115,29 +120,29 @@ function SshBlock({ device, objId, obj }: { device: DeviceItem; objId: number; o
|
||||||
|
|
||||||
const effectivePort = device.ssh_port_override ?? 22
|
const effectivePort = device.ssh_port_override ?? 22
|
||||||
|
|
||||||
// Determine auth method from last_auth
|
const usesKey = String(lastAuth?.type) === 'key' || device.ssh_key_id_override != null
|
||||||
const authType = lastAuth?.type ? String(lastAuth.type) : null
|
|
||||||
const usesKey = authType === 'key' || device.ssh_key_id_override != null
|
// Effective SSH password: device override first, then object level
|
||||||
const credSource: 'device' | 'object' | 'unknown' =
|
const effectiveSshPassword = creds?.ssh_password_device ?? creds?.ssh_password_object ?? null
|
||||||
lastAuth?.cred_id != null ? 'object'
|
const sshPasswordSource = creds?.ssh_password_device
|
||||||
: lastAuth?.key_id != null || device.ssh_key_id_override != null ? 'device'
|
? 'device'
|
||||||
: device.ssh_user_override ? 'device'
|
: creds?.ssh_password_object
|
||||||
: 'object'
|
? 'объект'
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
size="small"
|
size="small"
|
||||||
column={1}
|
column={1}
|
||||||
labelStyle={{ width: 200 }}
|
labelStyle={{ width: 160 }}
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
title={<Typography.Text strong style={{ fontSize: 13 }}>SSH / Авторизация</Typography.Text>}
|
title={<Typography.Text strong style={{ fontSize: 13 }}>SSH / Авторизация</Typography.Text>}
|
||||||
>
|
>
|
||||||
{/* Effective login */}
|
|
||||||
<Descriptions.Item label="Логин">
|
<Descriptions.Item label="Логин">
|
||||||
<Space size={6}>
|
<Space size={6}>
|
||||||
{effectiveUser
|
{effectiveUser
|
||||||
? <Typography.Text code>{effectiveUser}</Typography.Text>
|
? <Typography.Text code copyable>{effectiveUser}</Typography.Text>
|
||||||
: <Typography.Text type="secondary">Не задан</Typography.Text>}
|
: <Typography.Text type="secondary">Не задан</Typography.Text>}
|
||||||
{device.ssh_user_override
|
{device.ssh_user_override
|
||||||
? <Tag color="blue" style={{ fontSize: 10 }}>device</Tag>
|
? <Tag color="blue" style={{ fontSize: 10 }}>device</Tag>
|
||||||
|
|
@ -147,8 +152,7 @@ function SshBlock({ device, objId, obj }: { device: DeviceItem; objId: number; o
|
||||||
</Space>
|
</Space>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
{/* Effective auth method + password */}
|
<Descriptions.Item label="Пароль">
|
||||||
<Descriptions.Item label="Авторизация">
|
|
||||||
{usesKey ? (
|
{usesKey ? (
|
||||||
<Space size={6}>
|
<Space size={6}>
|
||||||
<Tag color="purple">SSH ключ</Tag>
|
<Tag color="purple">SSH ключ</Tag>
|
||||||
|
|
@ -158,39 +162,18 @@ function SshBlock({ device, objId, obj }: { device: DeviceItem; objId: number; o
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : credsLoading ? (
|
||||||
<Space size={8}>
|
<Spin size="small" />
|
||||||
<Tag color="orange">Пароль</Tag>
|
) : effectiveSshPassword ? (
|
||||||
{credSource === 'object' ? (
|
<Space size={6}>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text code copyable>{effectiveSshPassword}</Typography.Text>
|
||||||
с объекта{lastAuth?.cred_id != null ? ` (cred_id=${String(lastAuth.cred_id)})` : ''}
|
{sshPasswordSource && <Tag style={{ fontSize: 10 }}>{sshPasswordSource}</Tag>}
|
||||||
</Typography.Text>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{showPassword ? (
|
|
||||||
credsFetching
|
|
||||||
? <Spin size="small" />
|
|
||||||
: creds?.ssh_password
|
|
||||||
? <Typography.Text code copyable>{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>
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">Не задан</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
{/* Port */}
|
|
||||||
<Descriptions.Item label="Порт">
|
<Descriptions.Item label="Порт">
|
||||||
<Space size={6}>
|
<Space size={6}>
|
||||||
<Typography.Text code>{effectivePort}</Typography.Text>
|
<Typography.Text code>{effectivePort}</Typography.Text>
|
||||||
|
|
@ -198,14 +181,15 @@ function SshBlock({ device, objId, obj }: { device: DeviceItem; objId: number; o
|
||||||
</Space>
|
</Space>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
{/* Last successful connection */}
|
|
||||||
{lastAuth && (
|
{lastAuth && (
|
||||||
<Descriptions.Item label="Последнее подключение">
|
<Descriptions.Item label="Последнее подкл.">
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
<Tag color="green">успешно</Tag>
|
<Tag color="green">успешно</Tag>
|
||||||
{authType && <Tag>{authType === 'key' ? 'ключ' : 'пароль'}</Tag>}
|
<Tag>{String(lastAuth.type) === 'key' ? 'ключ' : 'пароль'}</Tag>
|
||||||
{lastAuth.username && (
|
{lastAuth.username && (
|
||||||
<Typography.Text code style={{ fontSize: 12 }}>{String(lastAuth.username)}</Typography.Text>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{String(lastAuth.username)}
|
||||||
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
@ -292,7 +276,15 @@ function DeviceMetaBlock({ device }: { device: DeviceItem }) {
|
||||||
|
|
||||||
// ─── Connection params smart render ──────────────────────────────────────────
|
// ─── Connection params smart render ──────────────────────────────────────────
|
||||||
|
|
||||||
function ConnectionParamsBlock({ device }: { device: DeviceItem }) {
|
function ConnectionParamsBlock({
|
||||||
|
device,
|
||||||
|
creds,
|
||||||
|
credsLoading,
|
||||||
|
}: {
|
||||||
|
device: DeviceItem
|
||||||
|
creds: DeviceCredentials | undefined
|
||||||
|
credsLoading: boolean
|
||||||
|
}) {
|
||||||
const params = device.connection_params
|
const params = device.connection_params
|
||||||
if (!params || Object.keys(params).length === 0) return null
|
if (!params || Object.keys(params).length === 0) return null
|
||||||
|
|
||||||
|
|
@ -301,6 +293,7 @@ function ConnectionParamsBlock({ device }: { device: DeviceItem }) {
|
||||||
const remaining: Record<string, unknown> = {}
|
const remaining: Record<string, unknown> = {}
|
||||||
|
|
||||||
for (const [k, v] of Object.entries(params)) {
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (k === 'password') continue // shown separately, decrypted
|
||||||
if (knownKeys.includes(k)) displayed[k] = v
|
if (knownKeys.includes(k)) displayed[k] = v
|
||||||
else remaining[k] = v
|
else remaining[k] = v
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +307,6 @@ function ConnectionParamsBlock({ device }: { device: DeviceItem }) {
|
||||||
api_port: 'API порт',
|
api_port: 'API порт',
|
||||||
web_port: 'Web порт',
|
web_port: 'Web порт',
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTH_COLOR: Record<string, string> = {
|
const AUTH_COLOR: Record<string, string> = {
|
||||||
basic: 'blue',
|
basic: 'blue',
|
||||||
digest: 'orange',
|
digest: 'orange',
|
||||||
|
|
@ -324,24 +316,32 @@ function ConnectionParamsBlock({ device }: { device: DeviceItem }) {
|
||||||
|
|
||||||
const hasDisplayed = Object.keys(displayed).length > 0
|
const hasDisplayed = Object.keys(displayed).length > 0
|
||||||
const hasRemaining = Object.keys(remaining).length > 0
|
const hasRemaining = Object.keys(remaining).length > 0
|
||||||
|
const hasPassword = 'password' in params
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card size="small" title="Параметры подключения">
|
<Card size="small" title="Параметры подключения">
|
||||||
{hasDisplayed && (
|
<Descriptions size="small" column={1} style={{ marginBottom: hasRemaining ? 8 : 0 }}>
|
||||||
<Descriptions size="small" column={1} style={{ marginBottom: hasRemaining ? 8 : 0 }}>
|
{hasDisplayed && Object.entries(displayed).map(([k, v]) => (
|
||||||
{Object.entries(displayed).map(([k, v]) => (
|
<Descriptions.Item key={k} label={LABEL_MAP[k] ?? k}>
|
||||||
<Descriptions.Item key={k} label={LABEL_MAP[k] ?? k}>
|
{k === 'auth_type'
|
||||||
{k === 'auth_type'
|
? <Tag color={AUTH_COLOR[String(v)] ?? 'default'}>{String(v)}</Tag>
|
||||||
? <Tag color={AUTH_COLOR[String(v)] ?? 'default'}>{String(v)}</Tag>
|
: k === 'protocol'
|
||||||
: k === 'protocol'
|
? <Tag color={String(v) === 'https' ? 'green' : 'blue'}>{String(v).toUpperCase()}</Tag>
|
||||||
? <Tag color={String(v) === 'https' ? 'green' : 'blue'}>{String(v).toUpperCase()}</Tag>
|
: k === 'url'
|
||||||
: k === 'url'
|
? <Typography.Text code copyable style={{ fontSize: 11 }}>{String(v)}</Typography.Text>
|
||||||
? <Typography.Text code copyable style={{ fontSize: 11 }}>{String(v)}</Typography.Text>
|
: <Typography.Text code>{String(v)}</Typography.Text>}
|
||||||
: <Typography.Text code>{String(v)}</Typography.Text>}
|
</Descriptions.Item>
|
||||||
</Descriptions.Item>
|
))}
|
||||||
))}
|
{hasPassword && (
|
||||||
</Descriptions>
|
<Descriptions.Item label="Пароль">
|
||||||
)}
|
{credsLoading
|
||||||
|
? <Spin size="small" />
|
||||||
|
: creds?.connection_password
|
||||||
|
? <Typography.Text code copyable>{creds.connection_password}</Typography.Text>
|
||||||
|
: <Typography.Text type="secondary">Не задан</Typography.Text>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
{hasRemaining && (
|
{hasRemaining && (
|
||||||
<pre style={{ fontSize: 11, margin: 0, maxHeight: 200, overflow: 'auto', background: '#fafafa', padding: 8, borderRadius: 4, border: '1px solid #f0f0f0' }}>
|
<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)}
|
{JSON.stringify(remaining, null, 2)}
|
||||||
|
|
@ -887,6 +887,7 @@ 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 { data: tasks } = useDeviceTasks(deviceId)
|
||||||
|
const { data: creds, isLoading: credsLoading } = useDeviceCredentials(objId, deviceId)
|
||||||
const deleteDevice = useDeleteDevice(objId)
|
const deleteDevice = useDeleteDevice(objId)
|
||||||
const createTask = useCreateTask()
|
const createTask = useCreateTask()
|
||||||
|
|
||||||
|
|
@ -1054,7 +1055,7 @@ export function DeviceDetailPage() {
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
{/* SSH block — always shown */}
|
{/* SSH block — always shown */}
|
||||||
<SshBlock device={device} objId={objId} obj={obj} />
|
<SshBlock device={device} obj={obj} creds={creds} credsLoading={credsLoading} />
|
||||||
|
|
||||||
{/* System info (Linux-like devices) */}
|
{/* System info (Linux-like devices) */}
|
||||||
{isLinuxLike && <SystemInfoBlock device={device} tasks={tasks} />}
|
{isLinuxLike && <SystemInfoBlock device={device} tasks={tasks} />}
|
||||||
|
|
@ -1088,7 +1089,7 @@ export function DeviceDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
<DeviceMetaBlock device={device} />
|
<DeviceMetaBlock device={device} />
|
||||||
<ConnectionParamsBlock device={device} />
|
<ConnectionParamsBlock device={device} creds={creds} credsLoading={credsLoading} />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue