вайбикс 1
This commit is contained in:
parent
55c80af9c5
commit
56b0eaa495
5 changed files with 221 additions and 60 deletions
|
|
@ -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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -132,12 +132,18 @@ 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:
|
||||||
|
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(
|
count_result = await db.execute(
|
||||||
select(Device.id).where(Device.object_id == obj_id).limit(1)
|
select(Device.id).where(Device.object_id == obj_id).limit(1)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 } : {},
|
||||||
|
render: (_: unknown, row: TableRow) => {
|
||||||
|
if (row._type === 'group') return null
|
||||||
|
const d = row as DeviceRow
|
||||||
|
return (
|
||||||
<Space>
|
<Space>
|
||||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEditDevice(row)} />
|
<Button size="small" icon={<EditOutlined />} onClick={() => openEditDevice(d)} />
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="Удалить устройство?"
|
title="Удалить устройство?"
|
||||||
okText="Удалить"
|
okText="Удалить"
|
||||||
cancelText="Отмена"
|
cancelText="Отмена"
|
||||||
okButtonProps={{ danger: true }}
|
okButtonProps={{ danger: true }}
|
||||||
onConfirm={() => handleDeleteDevice(row)}
|
onConfirm={() => handleDeleteDevice(d)}
|
||||||
>
|
>
|
||||||
<Button size="small" danger icon={<DeleteOutlined />} loading={deleteDevice.isPending} />
|
<Button size="small" danger icon={<DeleteOutlined />} loading={deleteDevice.isPending} />
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</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>
|
||||||
|
|
||||||
|
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||||||
|
<Col>
|
||||||
<Input.Search
|
<Input.Search
|
||||||
placeholder="Поиск по IP или hostname..."
|
placeholder="Поиск по IP или hostname..."
|
||||||
allowClear
|
allowClear
|
||||||
style={{ marginBottom: 12, maxWidth: 360 }}
|
style={{ width: 360 }}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
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 }}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue