Compare commits

..

No commits in common. "a322b612f8436e479e5f57e9ec5cf87c09fe9445" and "13ef5fed738ae3a59b1084aaee72defb5e54d517" have entirely different histories.

3 changed files with 94 additions and 99 deletions

View file

@ -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 ("Filesystem ...", "target ...", "Mounted on ...") # Skip header lines
if line.startswith(("Filesystem", "target", "Mounted")): if line.startswith("Filesystem") or line.startswith("target"):
continue continue
parts = line.split() parts = line.split()
if len(parts) >= 5: if len(parts) >= 5:

View file

@ -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, type ObjectItem } 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'
@ -101,29 +101,13 @@ function getSnapshotConfig(device: DeviceItem) {
// ─── SSH / Credentials block ────────────────────────────────────────────────── // ─── SSH / Credentials block ──────────────────────────────────────────────────
function SshBlock({ device, objId, obj }: { device: DeviceItem; objId: number; obj: ObjectItem | undefined }) { function SshBlock({ device, objId }: { device: DeviceItem; objId: number }) {
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
@ -133,80 +117,63 @@ function SshBlock({ device, objId, obj }: { device: DeviceItem; objId: number; o
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}> {device.ssh_user_override
{effectiveUser ? <><Typography.Text code>{device.ssh_user_override}</Typography.Text> <Tag color="blue" style={{ fontSize: 10 }}>override</Tag></>
? <Typography.Text code>{effectiveUser}</Typography.Text> : <Typography.Text type="secondary">С объекта</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>
{/* Effective auth method + password */} <Descriptions.Item label="Пароль">
<Descriptions.Item label="Авторизация"> {device.ssh_key_id_override != null ? (
{usesKey ? ( <Typography.Text type="secondary"> (используется ключ)</Typography.Text>
<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}>
<Tag color="orange">Пароль</Tag> {showPassword ? (
{credSource === 'object' ? ( credsFetching
<Typography.Text type="secondary" style={{ fontSize: 12 }}> ? <Spin size="small" />
с объекта{lastAuth?.cred_id != null ? ` (cred_id=${String(lastAuth.cred_id)})` : ''} : creds?.ssh_password
</Typography.Text> ? <Typography.Text code>{creds.ssh_password}</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="Порт">
<Space size={6}> {device.ssh_port_override
<Typography.Text code>{effectivePort}</Typography.Text> ? <><Typography.Text code>{device.ssh_port_override}</Typography.Text> <Tag color="blue" style={{ fontSize: 10 }}>override</Tag></>
{device.ssh_port_override && <Tag color="blue" style={{ fontSize: 10 }}>device</Tag>} : <Typography.Text type="secondary">22 (по умолчанию)</Typography.Text>}
</Space>
</Descriptions.Item> </Descriptions.Item>
{/* Last successful connection */} {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 && ( {lastAuth && (
<Descriptions.Item label="Последнее подключение"> <Descriptions.Item label="Последний успешный SSH">
<Space size={4} wrap> <Space size={4} wrap>
<Tag color="green">успешно</Tag> <Tag color="green">{String(lastAuth.type ?? '—')}</Tag>
{authType && <Tag>{authType === 'key' ? 'ключ' : 'пароль'}</Tag>} {lastAuth.username && <Typography.Text code>{String(lastAuth.username)}</Typography.Text>}
{lastAuth.username && ( {lastAuth.key_id != null && <Typography.Text type="secondary">key_id={String(lastAuth.key_id)}</Typography.Text>}
<Typography.Text code style={{ fontSize: 12 }}>{String(lastAuth.username)}</Typography.Text> {lastAuth.cred_id != null && <Typography.Text type="secondary">cred_id={String(lastAuth.cred_id)}</Typography.Text>}
)}
</Space> </Space>
</Descriptions.Item> </Descriptions.Item>
)} )}
@ -405,7 +372,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.filter((d) => !isNaN(parseInt(d.pct))).map((d) => { {disks.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 }}>
@ -1054,7 +1021,7 @@ export function DeviceDetailPage() {
</Descriptions> </Descriptions>
{/* SSH block — always shown */} {/* SSH block — always shown */}
<SshBlock device={device} objId={objId} obj={obj} /> <SshBlock device={device} objId={objId} />
{/* System info (Linux-like devices) */} {/* System info (Linux-like devices) */}
{isLinuxLike && <SystemInfoBlock device={device} tasks={tasks} />} {isLinuxLike && <SystemInfoBlock device={device} tasks={tasks} />}

View file

@ -25,6 +25,7 @@ import {
Select, Select,
Space, Space,
Spin, Spin,
Switch,
Table, Table,
Tag, Tag,
Tooltip, Tooltip,
@ -71,9 +72,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, Category, Ping+Status, Source, Actions = 6 // Inventory cols: IP, Hostname, Rack, Category, Ping+Status, Source, Actions = 7
// 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: 6, work: 5 } const COL_SPAN: Record<Mode, number> = { inventory: 7, work: 5 }
function PingButton({ function PingButton({
scope, scope,
@ -126,6 +127,7 @@ 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'))
@ -395,6 +397,19 @@ 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',
@ -494,6 +509,7 @@ 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) }} />
@ -606,14 +622,16 @@ 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>
<Segmented <div onClick={toggleMode} style={{ cursor: 'pointer' }}>
value={mode} <Segmented
options={[ value={mode}
{ label: 'Инвентарь', value: 'inventory', icon: <DatabaseOutlined /> }, options={[
{ label: 'Работа', value: 'work', icon: <ControlOutlined /> }, { label: 'Инвентарь', value: 'inventory', icon: <DatabaseOutlined /> },
]} { label: 'Работа', value: 'work', icon: <ControlOutlined /> },
onChange={(v) => setMode(v as Mode)} ]}
/> onChange={() => {}}
/>
</div>
</Col> </Col>
</Row> </Row>
@ -685,15 +703,25 @@ export function ObjectDetailPage({ defaultMode = 'inventory' }: { defaultMode?:
</Col> </Col>
</Row> </Row>
{/* Search */} {/* Search + edit toggle + delete all */}
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}> <Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col> <Col>
<Input.Search <Space>
placeholder="Поиск по IP или hostname..." <Input.Search
allowClear placeholder="Поиск по IP или hostname..."
style={{ width: 360 }} allowClear
onChange={(e) => setSearchText(e.target.value)} style={{ width: 360 }}
/> 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>