Compare commits
2 commits
13ef5fed73
...
a322b612f8
| Author | SHA1 | Date | |
|---|---|---|---|
| a322b612f8 | |||
| 1e9592f013 |
3 changed files with 99 additions and 94 deletions
|
|
@ -111,8 +111,8 @@ def _parse(output: str) -> dict:
|
||||||
disk_lines = sections.get("DISK", [])
|
disk_lines = sections.get("DISK", [])
|
||||||
disks = []
|
disks = []
|
||||||
for line in disk_lines:
|
for line in disk_lines:
|
||||||
# Skip header lines
|
# Skip header lines ("Filesystem ...", "target ...", "Mounted on ...")
|
||||||
if line.startswith("Filesystem") or line.startswith("target"):
|
if line.startswith(("Filesystem", "target", "Mounted")):
|
||||||
continue
|
continue
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) >= 5:
|
if len(parts) >= 5:
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,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 } from '../api/objects'
|
import { useDevice, useDeleteDevice, useObject, useDeviceCredentials, type DeviceItem, type ObjectItem } 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 +101,29 @@ function getSnapshotConfig(device: DeviceItem) {
|
||||||
|
|
||||||
// ─── SSH / Credentials block ──────────────────────────────────────────────────
|
// ─── SSH / Credentials block ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function SshBlock({ device, objId }: { device: DeviceItem; objId: number }) {
|
function SshBlock({ device, objId, obj }: { device: DeviceItem; objId: number; obj: ObjectItem | undefined }) {
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const { data: creds, isFetching: credsFetching } = useDeviceCredentials(objId, device.id, showPassword)
|
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
|
const lastAuth = device.ssh_last_auth
|
||||||
|
|
||||||
|
// Resolve effective values: device override → object default → fallback
|
||||||
|
const effectiveUser =
|
||||||
|
lastAuth?.username
|
||||||
|
? String(lastAuth.username)
|
||||||
|
: device.ssh_user_override ?? obj?.ssh_user ?? null
|
||||||
|
|
||||||
|
const effectivePort = device.ssh_port_override ?? 22
|
||||||
|
|
||||||
|
// Determine auth method from last_auth
|
||||||
|
const authType = lastAuth?.type ? String(lastAuth.type) : null
|
||||||
|
const usesKey = authType === 'key' || device.ssh_key_id_override != null
|
||||||
|
const credSource: 'device' | 'object' | 'unknown' =
|
||||||
|
lastAuth?.cred_id != null ? 'object'
|
||||||
|
: lastAuth?.key_id != null || device.ssh_key_id_override != null ? 'device'
|
||||||
|
: device.ssh_user_override ? 'device'
|
||||||
|
: 'object'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
|
|
@ -117,63 +133,80 @@ function SshBlock({ device, objId }: { device: DeviceItem; objId: number }) {
|
||||||
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="Логин">
|
||||||
{device.ssh_user_override
|
<Space size={6}>
|
||||||
? <><Typography.Text code>{device.ssh_user_override}</Typography.Text> <Tag color="blue" style={{ fontSize: 10 }}>override</Tag></>
|
{effectiveUser
|
||||||
: <Typography.Text type="secondary">С объекта</Typography.Text>}
|
? <Typography.Text code>{effectiveUser}</Typography.Text>
|
||||||
|
: <Typography.Text type="secondary">Не задан</Typography.Text>}
|
||||||
|
{device.ssh_user_override
|
||||||
|
? <Tag color="blue" style={{ fontSize: 10 }}>device</Tag>
|
||||||
|
: obj?.ssh_user
|
||||||
|
? <Tag style={{ fontSize: 10 }}>объект</Tag>
|
||||||
|
: null}
|
||||||
|
</Space>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label="Пароль">
|
{/* Effective auth method + password */}
|
||||||
{device.ssh_key_id_override != null ? (
|
<Descriptions.Item label="Авторизация">
|
||||||
<Typography.Text type="secondary">— (используется ключ)</Typography.Text>
|
{usesKey ? (
|
||||||
|
<Space size={6}>
|
||||||
|
<Tag color="purple">SSH ключ</Tag>
|
||||||
|
{(lastAuth?.key_id != null || device.ssh_key_id_override != null) && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
key_id={String(lastAuth?.key_id ?? device.ssh_key_id_override)}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
<Space size={8}>
|
<Space size={8}>
|
||||||
{showPassword ? (
|
<Tag color="orange">Пароль</Tag>
|
||||||
credsFetching
|
{credSource === 'object' ? (
|
||||||
? <Spin size="small" />
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
: creds?.ssh_password
|
с объекта{lastAuth?.cred_id != null ? ` (cred_id=${String(lastAuth.cred_id)})` : ''}
|
||||||
? <Typography.Text code>{creds.ssh_password}</Typography.Text>
|
</Typography.Text>
|
||||||
: <Typography.Text type="secondary">Не задан (используется с объекта)</Typography.Text>
|
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text type="secondary">••••••••</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>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="text"
|
|
||||||
icon={showPassword ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
|
||||||
>
|
|
||||||
{showPassword ? 'Скрыть' : 'Показать'}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
|
{/* Port */}
|
||||||
<Descriptions.Item label="Порт">
|
<Descriptions.Item label="Порт">
|
||||||
{device.ssh_port_override
|
<Space size={6}>
|
||||||
? <><Typography.Text code>{device.ssh_port_override}</Typography.Text> <Tag color="blue" style={{ fontSize: 10 }}>override</Tag></>
|
<Typography.Text code>{effectivePort}</Typography.Text>
|
||||||
: <Typography.Text type="secondary">22 (по умолчанию)</Typography.Text>}
|
{device.ssh_port_override && <Tag color="blue" style={{ fontSize: 10 }}>device</Tag>}
|
||||||
|
</Space>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
{device.ssh_key_id_override != null && (
|
{/* Last successful connection */}
|
||||||
<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 && (
|
{lastAuth && (
|
||||||
<Descriptions.Item label="Последний успешный SSH">
|
<Descriptions.Item label="Последнее подключение">
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
<Tag color="green">{String(lastAuth.type ?? '—')}</Tag>
|
<Tag color="green">успешно</Tag>
|
||||||
{lastAuth.username && <Typography.Text code>{String(lastAuth.username)}</Typography.Text>}
|
{authType && <Tag>{authType === 'key' ? 'ключ' : 'пароль'}</Tag>}
|
||||||
{lastAuth.key_id != null && <Typography.Text type="secondary">key_id={String(lastAuth.key_id)}</Typography.Text>}
|
{lastAuth.username && (
|
||||||
{lastAuth.cred_id != null && <Typography.Text type="secondary">cred_id={String(lastAuth.cred_id)}</Typography.Text>}
|
<Typography.Text code style={{ fontSize: 12 }}>{String(lastAuth.username)}</Typography.Text>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
|
|
@ -372,7 +405,7 @@ function SystemInfoRender({ data }: { data: Record<string, unknown> }) {
|
||||||
{disks && disks.length > 0 && (
|
{disks && disks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Диски:</Typography.Text>
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Диски:</Typography.Text>
|
||||||
{disks.map((d) => {
|
{disks.filter((d) => !isNaN(parseInt(d.pct))).map((d) => {
|
||||||
const pctNum = parseInt(d.pct)
|
const pctNum = parseInt(d.pct)
|
||||||
return (
|
return (
|
||||||
<div key={d.mount} style={{ marginBottom: 6 }}>
|
<div key={d.mount} style={{ marginBottom: 6 }}>
|
||||||
|
|
@ -1021,7 +1054,7 @@ export function DeviceDetailPage() {
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
{/* SSH block — always shown */}
|
{/* SSH block — always shown */}
|
||||||
<SshBlock device={device} objId={objId} />
|
<SshBlock device={device} objId={objId} obj={obj} />
|
||||||
|
|
||||||
{/* System info (Linux-like devices) */}
|
{/* System info (Linux-like devices) */}
|
||||||
{isLinuxLike && <SystemInfoBlock device={device} tasks={tasks} />}
|
{isLinuxLike && <SystemInfoBlock device={device} tasks={tasks} />}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import {
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
Switch,
|
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
|
@ -72,9 +71,9 @@ type GroupRow = {
|
||||||
type DeviceRow = DeviceItem & { _type: 'device' }
|
type DeviceRow = DeviceItem & { _type: 'device' }
|
||||||
type TableRow = GroupRow | DeviceRow
|
type TableRow = GroupRow | DeviceRow
|
||||||
|
|
||||||
// Inventory cols: IP, Hostname, Rack, Category, Ping+Status, Source, Actions = 7
|
// Inventory cols: IP, Hostname, Category, Ping+Status, Source, Actions = 6
|
||||||
// Work cols: IP, Hostname, Category, Ping+Status, Actions = 5
|
// Work cols: IP, Hostname, Category, Ping+Status, Actions = 5
|
||||||
const COL_SPAN: Record<Mode, number> = { inventory: 7, work: 5 }
|
const COL_SPAN: Record<Mode, number> = { inventory: 6, work: 5 }
|
||||||
|
|
||||||
function PingButton({
|
function PingButton({
|
||||||
scope,
|
scope,
|
||||||
|
|
@ -127,7 +126,6 @@ export function ObjectDetailPage({ defaultMode = 'inventory' }: { defaultMode?:
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [mode, setMode] = useState<Mode>(defaultMode)
|
const [mode, setMode] = useState<Mode>(defaultMode)
|
||||||
const [editMode, setEditMode] = useState(false)
|
|
||||||
|
|
||||||
const toggleMode = () => setMode((m) => (m === 'inventory' ? 'work' : 'inventory'))
|
const toggleMode = () => setMode((m) => (m === 'inventory' ? 'work' : 'inventory'))
|
||||||
|
|
||||||
|
|
@ -397,19 +395,6 @@ export function ObjectDetailPage({ defaultMode = 'inventory' }: { defaultMode?:
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(mode === 'inventory' ? [{
|
|
||||||
title: 'Стойка / локация',
|
|
||||||
key: 'location',
|
|
||||||
onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
|
|
||||||
render: (_: unknown, row: TableRow) => {
|
|
||||||
if (row._type === 'group') return null
|
|
||||||
const d = row as DeviceRow
|
|
||||||
if (d.rack_name) return <Tag color="blue">{rackLabel(d.rack_name, d.rack_type)}</Tag>
|
|
||||||
if (d.location === 'server_room') return <Tag color="purple">Серверная</Tag>
|
|
||||||
if (d.location === 'street') return <Tag color="orange">Улица</Tag>
|
|
||||||
return <Typography.Text type="secondary">—</Typography.Text>
|
|
||||||
},
|
|
||||||
}] : []),
|
|
||||||
{
|
{
|
||||||
title: 'Категория',
|
title: 'Категория',
|
||||||
dataIndex: 'category',
|
dataIndex: 'category',
|
||||||
|
|
@ -509,7 +494,6 @@ export function ObjectDetailPage({ defaultMode = 'inventory' }: { defaultMode?:
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!editMode) return null
|
|
||||||
return (
|
return (
|
||||||
<Space>
|
<Space>
|
||||||
<Button size="small" icon={<EditOutlined />} onClick={() => { setEditDevice(d); setDeviceModal(true) }} />
|
<Button size="small" icon={<EditOutlined />} onClick={() => { setEditDevice(d); setDeviceModal(true) }} />
|
||||||
|
|
@ -622,16 +606,14 @@ export function ObjectDetailPage({ defaultMode = 'inventory' }: { defaultMode?:
|
||||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||||
<Col><Breadcrumb items={breadcrumbItems} /></Col>
|
<Col><Breadcrumb items={breadcrumbItems} /></Col>
|
||||||
<Col>
|
<Col>
|
||||||
<div onClick={toggleMode} style={{ cursor: 'pointer' }}>
|
<Segmented
|
||||||
<Segmented
|
value={mode}
|
||||||
value={mode}
|
options={[
|
||||||
options={[
|
{ label: 'Инвентарь', value: 'inventory', icon: <DatabaseOutlined /> },
|
||||||
{ label: 'Инвентарь', value: 'inventory', icon: <DatabaseOutlined /> },
|
{ label: 'Работа', value: 'work', icon: <ControlOutlined /> },
|
||||||
{ label: 'Работа', value: 'work', icon: <ControlOutlined /> },
|
]}
|
||||||
]}
|
onChange={(v) => setMode(v as Mode)}
|
||||||
onChange={() => {}}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
|
@ -703,25 +685,15 @@ export function ObjectDetailPage({ defaultMode = 'inventory' }: { defaultMode?:
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Search + edit toggle + delete all */}
|
{/* Search */}
|
||||||
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||||||
<Col>
|
<Col>
|
||||||
<Space>
|
<Input.Search
|
||||||
<Input.Search
|
placeholder="Поиск по IP или hostname..."
|
||||||
placeholder="Поиск по IP или hostname..."
|
allowClear
|
||||||
allowClear
|
style={{ width: 360 }}
|
||||||
style={{ width: 360 }}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
/>
|
||||||
/>
|
|
||||||
{mode === 'inventory' && (
|
|
||||||
<Space size={6}>
|
|
||||||
<Switch size="small" checked={editMode} onChange={setEditMode} />
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
|
||||||
Режим редактирования
|
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue