From 56b0eaa495f8af379741f48b225f09a1e3dec9e6 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 15:41:06 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D0=B9=D0=B1=D0=B8=D0=BA=D1=81=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- backend/app/routers/devices.py | 12 + backend/app/routers/objects.py | 22 +- frontend/src/api/objects.ts | 16 ++ frontend/src/pages/InventoryObjectDetail.tsx | 228 ++++++++++++++----- 5 files changed, 221 insertions(+), 60 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6259af3..cec2c47 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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'' -Raw | Write-Output\")", "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:*)" ] } } diff --git a/backend/app/routers/devices.py b/backend/app/routers/devices.py index 867e100..8cc7916 100644 --- a/backend/app/routers/devices.py +++ b/backend/app/routers/devices.py @@ -244,6 +244,18 @@ async def update_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) async def delete_device( obj_id: int, diff --git a/backend/app/routers/objects.py b/backend/app/routers/objects.py index 019a06d..2c85cc6 100644 --- a/backend/app/routers/objects.py +++ b/backend/app/routers/objects.py @@ -132,20 +132,26 @@ async def update_object( @router.delete("/{obj_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_object( obj_id: int, + force: bool = False, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): obj = await _load_object(db, obj_id) - # Check for devices (RESTRICT constraint would catch this, but give a clear message) - count_result = await db.execute( - 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.", + if force: + devices_result = await db.execute(select(Device).where(Device.object_id == obj_id)) + for device in devices_result.scalars().all(): + await db.delete(device) + await db.flush() + else: + count_result = await db.execute( + 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) diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index dfd2666..0f8dcd6 100644 --- a/frontend/src/api/objects.ts +++ b/frontend/src/api/objects.ts @@ -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 { ip: string hostname?: string diff --git a/frontend/src/pages/InventoryObjectDetail.tsx b/frontend/src/pages/InventoryObjectDetail.tsx index 2691f35..b8c674d 100644 --- a/frontend/src/pages/InventoryObjectDetail.tsx +++ b/frontend/src/pages/InventoryObjectDetail.tsx @@ -39,6 +39,8 @@ import { useNavigate, useParams } from 'react-router-dom' import { useCreateDevice, useDeleteDevice, + useDeleteAllDevices, + useForceDeleteObject, useDiscoverObject, useUpdateDevice, useDevices, @@ -56,6 +58,13 @@ import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge' import { stripEmpty } from '../utils/form' 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 = [ { value: 'main_server', label: 'Главный сервер' }, { value: 'vm', label: 'Виртуалка / сервис' }, @@ -101,6 +110,8 @@ export function InventoryObjectDetailPage() { const deleteRack = useDeleteRack(objId) const createZone = useCreateZone(objId) const deleteZone = useDeleteZone(objId) + const deleteAllDevices = useDeleteAllDevices(objId) + const forceDeleteObject = useForceDeleteObject() const [deviceModal, setDeviceModal] = useState(false) const [editDevice, setEditDevice] = useState(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 () => { if (!csvFile) return try { @@ -276,6 +315,43 @@ export function InventoryObjectDetailPage() { ) }, [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() + 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 const deviceColumns = [ @@ -283,88 +359,111 @@ export function InventoryObjectDetailPage() { title: 'IP', dataIndex: 'ip', key: 'ip', - render: (ip: string) => {ip}, + 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 ( + + {row.label} + {row.count} устройств + {row.onlineCount > 0 && Online: {row.onlineCount}} + + ) + } + return {(row as DeviceRow).ip} + }, }, { title: 'Hostname', dataIndex: '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: 'Стойка / локация', key: 'location', - render: (_: unknown, row: DeviceItem) => { - if (row.rack_name) return {row.rack_name} - const loc = (row as any).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 {d.rack_name} + const loc = (d as any).location if (loc === 'server_room') return Серверная if (loc === 'street') return Улица return }, - 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: 'Категория', dataIndex: 'category', key: 'category', - render: (c: string) => , - filters: DEVICE_CATEGORIES.map((c) => ({ text: c.label, value: c.value })), - onFilter: (value: unknown, record: DeviceItem) => record.category === value, + onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, + render: (_: unknown, row: TableRow) => + row._type === 'group' ? null : , }, { title: 'Статус', dataIndex: 'status', key: 'status', - render: (s: string) => , + onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, + render: (_: unknown, row: TableRow) => + row._type === 'group' ? null : , }, { title: 'Последний ping', dataIndex: 'last_seen', key: 'last_seen', - render: (ts: string | null, row: DeviceItem) => - ts ? ( - - {row.last_ping_ms}ms + onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, + render: (_: unknown, row: TableRow) => { + if (row._type === 'group') return null + const d = row as DeviceRow + return d.last_seen ? ( + + {d.last_ping_ms}ms - ) : '—', + ) : '—' + }, }, { title: 'Источник', dataIndex: 'source', key: 'source', - render: (s: string) => {s}, + onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, + render: (_: unknown, row: TableRow) => + row._type === 'group' ? null : ( + + {(row as DeviceRow).source} + + ), }, { title: '', key: 'actions', width: 100, - render: (_: unknown, row: DeviceItem) => ( - - + - setSearchText(e.target.value)} - /> + + + setSearchText(e.target.value)} + /> + + + + + + + row._type === 'group' ? row._key : String((row as DeviceRow).id)} loading={devLoading} size="middle" pagination={{ pageSize: 50 }}