вайбикс 1

This commit is contained in:
dv 2026-04-08 15:41:06 +03:00
parent 55c80af9c5
commit 56b0eaa495
5 changed files with 221 additions and 60 deletions

View file

@ -6,7 +6,8 @@
"Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv''\")", "Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv''\")",
"Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' -Raw | Write-Output\")", "Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' -Raw | Write-Output\")",
"Bash(powershell -Command \"[System.IO.File]::ReadAllText\\(''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv''\\)\")", "Bash(powershell -Command \"[System.IO.File]::ReadAllText\\(''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv''\\)\")",
"Bash(powershell -Command \"Get-Item ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' | Select-Object Length, LastWriteTime\")" "Bash(powershell -Command \"Get-Item ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' | Select-Object Length, LastWriteTime\")",
"Bash(npx tsc:*)"
] ]
} }
} }

View file

@ -244,6 +244,18 @@ async def update_device(
return device return device
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
async def delete_all_devices(
obj_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
await _get_object(db, obj_id)
result = await db.execute(select(Device).where(Device.object_id == obj_id))
for device in result.scalars().all():
await db.delete(device)
@router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_device( async def delete_device(
obj_id: int, obj_id: int,

View file

@ -132,20 +132,26 @@ async def update_object(
@router.delete("/{obj_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{obj_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_object( async def delete_object(
obj_id: int, obj_id: int,
force: bool = False,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user), _: User = Depends(get_current_user),
): ):
obj = await _load_object(db, obj_id) obj = await _load_object(db, obj_id)
# Check for devices (RESTRICT constraint would catch this, but give a clear message) if force:
count_result = await db.execute( devices_result = await db.execute(select(Device).where(Device.object_id == obj_id))
select(Device.id).where(Device.object_id == obj_id).limit(1) for device in devices_result.scalars().all():
) await db.delete(device)
if count_result.scalar_one_or_none() is not None: await db.flush()
raise HTTPException( else:
status_code=status.HTTP_409_CONFLICT, count_result = await db.execute(
detail="Cannot delete object with existing devices. Remove all devices first.", select(Device.id).where(Device.object_id == obj_id).limit(1)
) )
if count_result.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot delete object with existing devices. Remove all devices first.",
)
await db.delete(obj) await db.delete(obj)

View file

@ -130,6 +130,22 @@ export const useDeleteObject = () => {
}) })
} }
export const useForceDeleteObject = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/objects/${id}`, { params: { force: true } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['objects'] }),
})
}
export const useDeleteAllDevices = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.delete(`/api/v1/objects/${objId}/devices`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
})
}
export interface DeviceCreatePayload { export interface DeviceCreatePayload {
ip: string ip: string
hostname?: string hostname?: string

View file

@ -39,6 +39,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import { import {
useCreateDevice, useCreateDevice,
useDeleteDevice, useDeleteDevice,
useDeleteAllDevices,
useForceDeleteObject,
useDiscoverObject, useDiscoverObject,
useUpdateDevice, useUpdateDevice,
useDevices, useDevices,
@ -56,6 +58,13 @@ import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import { stripEmpty } from '../utils/form' import { stripEmpty } from '../utils/form'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const SERVER_CATEGORIES = new Set(['main_server', 'vm', 'router'])
const DEVICE_COL_SPAN = 8
type GroupRow = { _type: 'group'; _key: string; label: string; count: number; onlineCount: number }
type DeviceRow = DeviceItem & { _type: 'device' }
type TableRow = GroupRow | DeviceRow
const DEVICE_CATEGORIES = [ const DEVICE_CATEGORIES = [
{ value: 'main_server', label: 'Главный сервер' }, { value: 'main_server', label: 'Главный сервер' },
{ value: 'vm', label: 'Виртуалка / сервис' }, { value: 'vm', label: 'Виртуалка / сервис' },
@ -101,6 +110,8 @@ export function InventoryObjectDetailPage() {
const deleteRack = useDeleteRack(objId) const deleteRack = useDeleteRack(objId)
const createZone = useCreateZone(objId) const createZone = useCreateZone(objId)
const deleteZone = useDeleteZone(objId) const deleteZone = useDeleteZone(objId)
const deleteAllDevices = useDeleteAllDevices(objId)
const forceDeleteObject = useForceDeleteObject()
const [deviceModal, setDeviceModal] = useState(false) const [deviceModal, setDeviceModal] = useState(false)
const [editDevice, setEditDevice] = useState<DeviceItem | null>(null) const [editDevice, setEditDevice] = useState<DeviceItem | null>(null)
@ -217,6 +228,34 @@ export function InventoryObjectDetailPage() {
} }
} }
const handleDeleteAllDevices = async () => {
try {
await deleteAllDevices.mutateAsync()
message.success('Все устройства удалены')
} catch {
message.error('Ошибка при удалении устройств')
}
}
const handleDeleteObject = () => {
Modal.confirm({
title: `Удалить объект «${obj?.name}»?`,
content: `Будут удалены все устройства (${devices?.length ?? 0} шт.) и сам объект. Это действие необратимо.`,
okText: 'Удалить',
cancelText: 'Отмена',
okButtonProps: { danger: true },
onOk: async () => {
try {
await forceDeleteObject.mutateAsync(objId)
message.success('Объект удалён')
navigate('/inventory/objects')
} catch {
message.error('Ошибка при удалении объекта')
}
},
})
}
const handleCsvPreview = async () => { const handleCsvPreview = async () => {
if (!csvFile) return if (!csvFile) return
try { try {
@ -276,6 +315,43 @@ export function InventoryObjectDetailPage() {
) )
}, [devices, searchText]) }, [devices, searchText])
const groupedTableData = useMemo((): TableRow[] => {
const servers = filteredDevices.filter((d) => SERVER_CATEGORIES.has(d.category))
const rest = filteredDevices.filter((d) => !SERVER_CATEGORIES.has(d.category))
const rackMap = new Map<string, DeviceItem[]>()
const noRack: DeviceItem[] = []
for (const d of rest) {
if (d.rack_name) {
if (!rackMap.has(d.rack_name)) rackMap.set(d.rack_name, [])
rackMap.get(d.rack_name)!.push(d)
} else {
noRack.push(d)
}
}
const rows: TableRow[] = []
const addGroup = (label: string, key: string, items: DeviceItem[]) => {
rows.push({
_type: 'group',
_key: key,
label,
count: items.length,
onlineCount: items.filter((d) => d.status === 'online').length,
})
items.forEach((d) => rows.push({ ...d, _type: 'device' }))
}
if (servers.length > 0) addGroup('Серверная инфраструктура', '__servers__', servers)
const sortedRacks = [...rackMap.entries()].sort(([a], [b]) => a.localeCompare(b))
for (const [rackName, items] of sortedRacks) addGroup(rackName, `rack:${rackName}`, items)
if (noRack.length > 0) addGroup('Без привязки', '__norack__', noRack)
return rows
}, [filteredDevices])
if (objLoading) return <Spin /> if (objLoading) return <Spin />
const deviceColumns = [ const deviceColumns = [
@ -283,88 +359,111 @@ export function InventoryObjectDetailPage() {
title: 'IP', title: 'IP',
dataIndex: 'ip', dataIndex: 'ip',
key: 'ip', key: 'ip',
render: (ip: string) => <code>{ip}</code>, onCell: (row: TableRow) =>
row._type === 'group'
? { colSpan: DEVICE_COL_SPAN, style: { background: '#f5f5f5', padding: '6px 16px', borderBottom: '1px solid #e8e8e8' } }
: {},
render: (_: unknown, row: TableRow) => {
if (row._type === 'group') {
return (
<Space size={8}>
<Typography.Text strong style={{ fontSize: 13 }}>{row.label}</Typography.Text>
<Tag style={{ marginLeft: 2 }}>{row.count} устройств</Tag>
{row.onlineCount > 0 && <Tag color="green">Online: {row.onlineCount}</Tag>}
</Space>
)
}
return <code>{(row as DeviceRow).ip}</code>
},
}, },
{ {
title: 'Hostname', title: 'Hostname',
dataIndex: 'hostname', dataIndex: 'hostname',
key: 'hostname', key: 'hostname',
render: (h: string | null) => h ?? '—', onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
render: (_: unknown, row: TableRow) =>
row._type === 'group' ? null : ((row as DeviceRow).hostname ?? '—'),
}, },
{ {
title: 'Стойка / локация', title: 'Стойка / локация',
key: 'location', key: 'location',
render: (_: unknown, row: DeviceItem) => { onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
if (row.rack_name) return <Tag color="blue">{row.rack_name}</Tag> render: (_: unknown, row: TableRow) => {
const loc = (row as any).location if (row._type === 'group') return null
const d = row as DeviceRow
if (d.rack_name) return <Tag color="blue">{d.rack_name}</Tag>
const loc = (d as any).location
if (loc === 'server_room') return <Tag color="purple">Серверная</Tag> if (loc === 'server_room') return <Tag color="purple">Серверная</Tag>
if (loc === 'street') return <Tag color="orange">Улица</Tag> if (loc === 'street') return <Tag color="orange">Улица</Tag>
return <Typography.Text type="secondary"></Typography.Text> return <Typography.Text type="secondary"></Typography.Text>
}, },
filters: [
{ text: 'Без привязки', value: '__none__' },
{ text: 'Серверная', value: 'server_room' },
{ text: 'Улица', value: 'street' },
...(racks ?? []).map((r) => ({ text: r.name, value: `rack:${r.name}` })),
],
onFilter: (value: unknown, record: DeviceItem) => {
if (value === '__none__') return !record.rack_name && !(record as any).location
if (value === 'server_room') return (record as any).location === 'server_room'
if (value === 'street') return (record as any).location === 'street'
if (typeof value === 'string' && value.startsWith('rack:'))
return record.rack_name === value.slice(5)
return false
},
}, },
{ {
title: 'Категория', title: 'Категория',
dataIndex: 'category', dataIndex: 'category',
key: 'category', key: 'category',
render: (c: string) => <CategoryTag category={c} />, onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
filters: DEVICE_CATEGORIES.map((c) => ({ text: c.label, value: c.value })), render: (_: unknown, row: TableRow) =>
onFilter: (value: unknown, record: DeviceItem) => record.category === value, row._type === 'group' ? null : <CategoryTag category={(row as DeviceRow).category} />,
}, },
{ {
title: 'Статус', title: 'Статус',
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
render: (s: string) => <DeviceStatusBadge status={s} />, onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
render: (_: unknown, row: TableRow) =>
row._type === 'group' ? null : <DeviceStatusBadge status={(row as DeviceRow).status} />,
}, },
{ {
title: 'Последний ping', title: 'Последний ping',
dataIndex: 'last_seen', dataIndex: 'last_seen',
key: 'last_seen', key: 'last_seen',
render: (ts: string | null, row: DeviceItem) => onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
ts ? ( render: (_: unknown, row: TableRow) => {
<Tooltip title={dayjs(ts).format('DD.MM.YYYY HH:mm:ss')}> if (row._type === 'group') return null
<span>{row.last_ping_ms}ms</span> const d = row as DeviceRow
return d.last_seen ? (
<Tooltip title={dayjs(d.last_seen).format('DD.MM.YYYY HH:mm:ss')}>
<span>{d.last_ping_ms}ms</span>
</Tooltip> </Tooltip>
) : '—', ) : '—'
},
}, },
{ {
title: 'Источник', title: 'Источник',
dataIndex: 'source', dataIndex: 'source',
key: 'source', key: 'source',
render: (s: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{s}</Typography.Text>, onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
render: (_: unknown, row: TableRow) =>
row._type === 'group' ? null : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{(row as DeviceRow).source}
</Typography.Text>
),
}, },
{ {
title: '', title: '',
key: 'actions', key: 'actions',
width: 100, width: 100,
render: (_: unknown, row: DeviceItem) => ( onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
<Space> render: (_: unknown, row: TableRow) => {
<Button size="small" icon={<EditOutlined />} onClick={() => openEditDevice(row)} /> if (row._type === 'group') return null
<Popconfirm const d = row as DeviceRow
title="Удалить устройство?" return (
okText="Удалить" <Space>
cancelText="Отмена" <Button size="small" icon={<EditOutlined />} onClick={() => openEditDevice(d)} />
okButtonProps={{ danger: true }} <Popconfirm
onConfirm={() => handleDeleteDevice(row)} title="Удалить устройство?"
> okText="Удалить"
<Button size="small" danger icon={<DeleteOutlined />} loading={deleteDevice.isPending} /> cancelText="Отмена"
</Popconfirm> okButtonProps={{ danger: true }}
</Space> onConfirm={() => handleDeleteDevice(d)}
), >
<Button size="small" danger icon={<DeleteOutlined />} loading={deleteDevice.isPending} />
</Popconfirm>
</Space>
)
},
}, },
] ]
@ -473,21 +572,48 @@ export function InventoryObjectDetailPage() {
<Button icon={<ThunderboltOutlined />} onClick={() => setRackModal(true)}> <Button icon={<ThunderboltOutlined />} onClick={() => setRackModal(true)}>
Стойки и зоны Стойки и зоны
</Button> </Button>
<Button danger icon={<DeleteOutlined />} onClick={handleDeleteObject} loading={forceDeleteObject.isPending}>
Удалить объект
</Button>
</Space> </Space>
</Col> </Col>
</Row> </Row>
<Input.Search <Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
placeholder="Поиск по IP или hostname..." <Col>
allowClear <Input.Search
style={{ marginBottom: 12, maxWidth: 360 }} placeholder="Поиск по IP или hostname..."
onChange={(e) => setSearchText(e.target.value)} allowClear
/> style={{ width: 360 }}
onChange={(e) => setSearchText(e.target.value)}
/>
</Col>
<Col>
<Popconfirm
title="Удалить все устройства?"
description={`Будет удалено ${devices?.length ?? 0} устройств. Отменить нельзя.`}
okText="Удалить все"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={handleDeleteAllDevices}
disabled={!devices?.length}
>
<Button
danger
icon={<DeleteOutlined />}
loading={deleteAllDevices.isPending}
disabled={!devices?.length}
>
Удалить все устройства
</Button>
</Popconfirm>
</Col>
</Row>
<Table <Table
dataSource={filteredDevices} dataSource={groupedTableData}
columns={deviceColumns} columns={deviceColumns}
rowKey="id" rowKey={(row) => row._type === 'group' ? row._key : String((row as DeviceRow).id)}
loading={devLoading} loading={devLoading}
size="middle" size="middle"
pagination={{ pageSize: 50 }} pagination={{ pageSize: 50 }}