этап 2 ок?
This commit is contained in:
parent
68b9fa7417
commit
86c02eb2e9
7 changed files with 468 additions and 73 deletions
|
|
@ -87,7 +87,7 @@ async def create_device(
|
||||||
return device
|
return device
|
||||||
|
|
||||||
|
|
||||||
@router.get("/import/preview", response_model=DeviceImportPreview)
|
@router.post("/import/preview", response_model=DeviceImportPreview)
|
||||||
async def preview_csv_import(
|
async def preview_csv_import(
|
||||||
obj_id: int,
|
obj_id: int,
|
||||||
file: Annotated[UploadFile, File()],
|
file: Annotated[UploadFile, File()],
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,11 @@ Expected table schema (racks):
|
||||||
name TEXT — short label, e.g. "01"
|
name TEXT — short label, e.g. "01"
|
||||||
type TEXT — "rack.lane" | "rack.payment" | ...
|
type TEXT — "rack.lane" | "rack.payment" | ...
|
||||||
data JSONB — contains "host" (IP address) and other device-specific fields
|
data JSONB — contains "host" (IP address) and other device-specific fields
|
||||||
|
|
||||||
SSH tunnel support is deferred to Этап 2.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import asyncssh
|
||||||
import psycopg
|
import psycopg
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -51,6 +50,15 @@ def _map_category(raw: str | None) -> str:
|
||||||
return _CATEGORY_MAP.get(raw.strip().lower(), "other")
|
return _CATEGORY_MAP.get(raw.strip().lower(), "other")
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_rows(dsn: str, table: str) -> list[tuple]:
|
||||||
|
"""Open a short-lived psycopg connection and fetch device rows."""
|
||||||
|
async with await psycopg.AsyncConnection.connect(dsn) as conn:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
f"SELECT id, name, type, data->>'host' AS host FROM \"{table}\""
|
||||||
|
)
|
||||||
|
return await cursor.fetchall()
|
||||||
|
|
||||||
|
|
||||||
async def sync_from_object_db(db: AsyncSession, obj: Object) -> SyncResult:
|
async def sync_from_object_db(db: AsyncSession, obj: Object) -> SyncResult:
|
||||||
result = SyncResult()
|
result = SyncResult()
|
||||||
|
|
||||||
|
|
@ -58,31 +66,56 @@ async def sync_from_object_db(db: AsyncSession, obj: Object) -> SyncResult:
|
||||||
result.errors.append("Object DB connection is not fully configured")
|
result.errors.append("Object DB connection is not fully configured")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if obj.db_via_tunnel:
|
|
||||||
result.errors.append("SSH tunnel sync is not yet supported (coming in Этап 2)")
|
|
||||||
return result
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
password = decrypt(obj.db_pass_enc)
|
db_password = decrypt(obj.db_pass_enc)
|
||||||
except Exception:
|
except Exception:
|
||||||
result.errors.append("Failed to decrypt object DB password")
|
result.errors.append("Failed to decrypt object DB password")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
dsn = (
|
if obj.db_via_tunnel:
|
||||||
f"host={obj.db_host} port={obj.db_port} dbname={obj.db_name} "
|
# Connect via SSH tunnel through obj.server_ip
|
||||||
f"user={obj.db_user} password={password} connect_timeout=10"
|
if not obj.server_ip or not obj.ssh_user or not obj.ssh_pass_enc:
|
||||||
)
|
result.errors.append("SSH credentials not configured for tunnel (need server_ip, ssh_user, ssh_password)")
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
ssh_password = decrypt(obj.ssh_pass_enc)
|
||||||
|
except Exception:
|
||||||
|
result.errors.append("Failed to decrypt SSH password for tunnel")
|
||||||
|
return result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with await psycopg.AsyncConnection.connect(dsn) as conn:
|
async with asyncssh.connect(
|
||||||
# Pull id, name, type and host from the JSONB data column
|
obj.server_ip,
|
||||||
rows = await conn.execute(
|
port=obj.ssh_port or 22,
|
||||||
f'SELECT id, name, type, data->>\'host\' AS host FROM "{obj.db_table}"'
|
username=obj.ssh_user,
|
||||||
)
|
password=ssh_password,
|
||||||
remote_rows = await rows.fetchall()
|
known_hosts=None,
|
||||||
except Exception as exc:
|
) as tunnel:
|
||||||
result.errors.append(f"Cannot connect to object DB: {exc}")
|
listener = await tunnel.forward_local_port(
|
||||||
return result
|
"127.0.0.1", 0, obj.db_host, obj.db_port or 5432
|
||||||
|
)
|
||||||
|
local_port = listener.get_port()
|
||||||
|
dsn = (
|
||||||
|
f"host=127.0.0.1 port={local_port} dbname={obj.db_name} "
|
||||||
|
f"user={obj.db_user} password={db_password} connect_timeout=10"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
remote_rows = await _fetch_rows(dsn, obj.db_table)
|
||||||
|
finally:
|
||||||
|
listener.close()
|
||||||
|
except Exception as exc:
|
||||||
|
result.errors.append(f"SSH tunnel failed: {exc}")
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
dsn = (
|
||||||
|
f"host={obj.db_host} port={obj.db_port or 5432} dbname={obj.db_name} "
|
||||||
|
f"user={obj.db_user} password={db_password} connect_timeout=10"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
remote_rows = await _fetch_rows(dsn, obj.db_table)
|
||||||
|
except Exception as exc:
|
||||||
|
result.errors.append(f"Cannot connect to object DB: {exc}")
|
||||||
|
return result
|
||||||
|
|
||||||
for row in remote_rows:
|
for row in remote_rows:
|
||||||
external_id, name, rack_type, host = row[0], row[1], row[2], row[3]
|
external_id, name, rack_type, host = row[0], row[1], row[2], row[3]
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,18 @@ export interface SyncResult {
|
||||||
errors: string[]
|
errors: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceImportPreview {
|
||||||
|
would_create: { ip: string; hostname?: string; category: string; role: string; model?: string }[]
|
||||||
|
would_update: { ip: string; id: number; changes: Record<string, { from: unknown; to: unknown }> }[]
|
||||||
|
errors: { row: number; error: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceImportResult {
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
errors: { row: number; error: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
export const useObjects = (isActive?: boolean) =>
|
export const useObjects = (isActive?: boolean) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['objects', isActive],
|
queryKey: ['objects', isActive],
|
||||||
|
|
@ -88,6 +100,23 @@ export const useCreateObject = () => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useUpdateObject = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: number; body: Partial<ObjectCreatePayload> }) =>
|
||||||
|
api.patch<ObjectItem>(`/api/v1/objects/${id}`, body).then((r) => r.data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['objects'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeleteObject = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => api.delete(`/api/v1/objects/${id}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['objects'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeviceCreatePayload {
|
export interface DeviceCreatePayload {
|
||||||
ip: string
|
ip: string
|
||||||
hostname?: string
|
hostname?: string
|
||||||
|
|
@ -110,6 +139,39 @@ export const useCreateDevice = (objId: number) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useDeleteDevice = (objId: number) => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (devId: number) => api.delete(`/api/v1/objects/${objId}/devices/${devId}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePreviewCSV = (objId: number) =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: (file: File) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return api
|
||||||
|
.post<DeviceImportPreview>(`/api/v1/objects/${objId}/devices/import/preview`, form)
|
||||||
|
.then((r) => r.data)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useImportCSV = (objId: number) => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (file: File) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return api
|
||||||
|
.post<DeviceImportResult>(`/api/v1/objects/${objId}/devices/import`, form)
|
||||||
|
.then((r) => r.data)
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const useSyncObject = (objId: number) => {
|
export const useSyncObject = (objId: number) => {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,13 @@ export interface ActionInfo {
|
||||||
params_schema: { name: string; type: string; label: string; required?: boolean }[]
|
params_schema: { name: string; type: string; label: string; required?: boolean }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTasks = () =>
|
export const useTasks = (page = 1, pageSize = 50) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['tasks'],
|
queryKey: ['tasks', page, pageSize],
|
||||||
queryFn: () => api.get<Task[]>('/api/v1/tasks').then((r) => r.data),
|
queryFn: () =>
|
||||||
|
api
|
||||||
|
.get<Task[]>('/api/v1/tasks', { params: { skip: (page - 1) * pageSize, limit: pageSize } })
|
||||||
|
.then((r) => r.data),
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import {
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
|
UploadOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
Button,
|
Button,
|
||||||
Col,
|
Col,
|
||||||
|
|
@ -14,6 +17,7 @@ import {
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
Row,
|
Row,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
|
|
@ -21,11 +25,24 @@ import {
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
Upload,
|
||||||
message,
|
message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
|
import type { UploadFile } from 'antd'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useCreateDevice, useDevices, useObject, useSyncObject, type DeviceItem } from '../api/objects'
|
import {
|
||||||
|
useCreateDevice,
|
||||||
|
useDeleteDevice,
|
||||||
|
useDevices,
|
||||||
|
useImportCSV,
|
||||||
|
useObject,
|
||||||
|
usePreviewCSV,
|
||||||
|
useSyncObject,
|
||||||
|
type DeviceImportPreview,
|
||||||
|
type DeviceItem,
|
||||||
|
} from '../api/objects'
|
||||||
import { useActions, useCreateTask } from '../api/tasks'
|
import { useActions, useCreateTask } from '../api/tasks'
|
||||||
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
|
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
@ -40,11 +57,18 @@ export function ObjectDetailPage() {
|
||||||
const syncMutation = useSyncObject(objId)
|
const syncMutation = useSyncObject(objId)
|
||||||
const { data: actions } = useActions()
|
const { data: actions } = useActions()
|
||||||
const createTask = useCreateTask()
|
const createTask = useCreateTask()
|
||||||
|
|
||||||
const createDevice = useCreateDevice(objId)
|
const createDevice = useCreateDevice(objId)
|
||||||
|
const deleteDevice = useDeleteDevice(objId)
|
||||||
|
const previewCSV = usePreviewCSV(objId)
|
||||||
|
const importCSV = useImportCSV(objId)
|
||||||
|
|
||||||
const [taskModal, setTaskModal] = useState<{ scope: object } | null>(null)
|
const [taskModal, setTaskModal] = useState<{ scope: object } | null>(null)
|
||||||
const [deviceModal, setDeviceModal] = useState(false)
|
const [deviceModal, setDeviceModal] = useState(false)
|
||||||
|
const [csvModal, setCsvModal] = useState(false)
|
||||||
|
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||||
|
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null)
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const [deviceForm] = Form.useForm()
|
const [deviceForm] = Form.useForm()
|
||||||
const [selectedAction, setSelectedAction] = useState<string>('')
|
const [selectedAction, setSelectedAction] = useState<string>('')
|
||||||
|
|
@ -66,7 +90,7 @@ export function ObjectDetailPage() {
|
||||||
target_scope: taskModal!.scope,
|
target_scope: taskModal!.scope,
|
||||||
params,
|
params,
|
||||||
})
|
})
|
||||||
message.success(`Задача создана`)
|
message.success('Задача создана')
|
||||||
setTaskModal(null)
|
setTaskModal(null)
|
||||||
navigate(`/tasks/${task.id}`)
|
navigate(`/tasks/${task.id}`)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -98,6 +122,50 @@ export function ObjectDetailPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeleteDevice = async (row: DeviceItem) => {
|
||||||
|
try {
|
||||||
|
await deleteDevice.mutateAsync(row.id)
|
||||||
|
message.success(`Устройство ${row.ip} удалено`)
|
||||||
|
} catch {
|
||||||
|
message.error('Ошибка при удалении устройства')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCsvPreview = async () => {
|
||||||
|
if (!csvFile) return
|
||||||
|
try {
|
||||||
|
const preview = await previewCSV.mutateAsync(csvFile)
|
||||||
|
setCsvPreview(preview)
|
||||||
|
} catch {
|
||||||
|
message.error('Не удалось разобрать CSV')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCsvImport = async () => {
|
||||||
|
if (!csvFile) return
|
||||||
|
try {
|
||||||
|
const result = await importCSV.mutateAsync(csvFile)
|
||||||
|
message.success(`Импортировано: +${result.created} создано, ${result.updated} обновлено`)
|
||||||
|
setCsvModal(false)
|
||||||
|
setCsvFile(null)
|
||||||
|
setCsvPreview(null)
|
||||||
|
} catch {
|
||||||
|
message.error('Ошибка при импорте CSV')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeCsvModal = () => {
|
||||||
|
setCsvModal(false)
|
||||||
|
setCsvFile(null)
|
||||||
|
setCsvPreview(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredDevices = (devices ?? []).filter((d) => {
|
||||||
|
if (!searchText) return true
|
||||||
|
const q = searchText.toLowerCase()
|
||||||
|
return d.ip.toLowerCase().includes(q) || (d.hostname ?? '').toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
|
||||||
if (objLoading) return <Spin />
|
if (objLoading) return <Spin />
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
|
|
@ -118,7 +186,10 @@ export function ObjectDetailPage() {
|
||||||
dataIndex: 'category',
|
dataIndex: 'category',
|
||||||
key: 'category',
|
key: 'category',
|
||||||
render: (c: string) => <CategoryTag category={c} />,
|
render: (c: string) => <CategoryTag category={c} />,
|
||||||
filters: ['raspberry', 'mikrotik', 'camera', 'embedded', 'server', 'other'].map((v) => ({ text: v, value: v })),
|
filters: ['raspberry', 'mikrotik', 'camera', 'embedded', 'server', 'other'].map((v) => ({
|
||||||
|
text: v,
|
||||||
|
value: v,
|
||||||
|
})),
|
||||||
onFilter: (value: unknown, record: DeviceItem) => record.category === value,
|
onFilter: (value: unknown, record: DeviceItem) => record.category === value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -149,18 +220,50 @@ export function ObjectDetailPage() {
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
|
width: 120,
|
||||||
render: (_: unknown, row: DeviceItem) => (
|
render: (_: unknown, row: DeviceItem) => (
|
||||||
<Button
|
<Space>
|
||||||
size="small"
|
<Button
|
||||||
icon={<PlayCircleOutlined />}
|
size="small"
|
||||||
onClick={() => openTaskModal({ type: 'device', id: row.id })}
|
icon={<PlayCircleOutlined />}
|
||||||
>
|
onClick={() => openTaskModal({ type: 'device', id: row.id })}
|
||||||
Запустить
|
>
|
||||||
</Button>
|
Запустить
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="Удалить устройство?"
|
||||||
|
okText="Удалить"
|
||||||
|
cancelText="Отмена"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => handleDeleteDevice(row)}
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} loading={deleteDevice.isPending} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// CSV preview columns
|
||||||
|
const previewCreateCols = [
|
||||||
|
{ title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> },
|
||||||
|
{ title: 'Hostname', dataIndex: 'hostname', key: 'hostname', render: (v: string) => v ?? '—' },
|
||||||
|
{ title: 'Категория', dataIndex: 'category', key: 'category' },
|
||||||
|
{ title: 'Роль', dataIndex: 'role', key: 'role' },
|
||||||
|
]
|
||||||
|
const previewUpdateCols = [
|
||||||
|
{ title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> },
|
||||||
|
{
|
||||||
|
title: 'Изменения',
|
||||||
|
dataIndex: 'changes',
|
||||||
|
key: 'changes',
|
||||||
|
render: (changes: Record<string, { from: unknown; to: unknown }>) =>
|
||||||
|
Object.entries(changes)
|
||||||
|
.map(([k, v]) => `${k}: ${v.from} → ${v.to}`)
|
||||||
|
.join(', ') || '—',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
|
|
@ -181,17 +284,13 @@ export function ObjectDetailPage() {
|
||||||
</Col>
|
</Col>
|
||||||
<Col>
|
<Col>
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button icon={<SyncOutlined />} loading={syncMutation.isPending} onClick={handleSync}>
|
||||||
icon={<SyncOutlined />}
|
|
||||||
loading={syncMutation.isPending}
|
|
||||||
onClick={handleSync}
|
|
||||||
>
|
|
||||||
Синхронизировать из БД
|
Синхронизировать из БД
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button icon={<UploadOutlined />} onClick={() => setCsvModal(true)}>
|
||||||
icon={<PlusOutlined />}
|
Импорт CSV
|
||||||
onClick={() => setDeviceModal(true)}
|
</Button>
|
||||||
>
|
<Button icon={<PlusOutlined />} onClick={() => setDeviceModal(true)}>
|
||||||
Добавить устройство
|
Добавить устройство
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -205,8 +304,15 @@ export function ObjectDetailPage() {
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Input.Search
|
||||||
|
placeholder="Поиск по IP или hostname..."
|
||||||
|
allowClear
|
||||||
|
style={{ marginBottom: 12, maxWidth: 360 }}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
dataSource={devices ?? []}
|
dataSource={filteredDevices}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={devLoading}
|
loading={devLoading}
|
||||||
|
|
@ -214,6 +320,95 @@ export function ObjectDetailPage() {
|
||||||
pagination={{ pageSize: 50 }}
|
pagination={{ pageSize: 50 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── CSV Import Modal ── */}
|
||||||
|
<Modal
|
||||||
|
title="Импорт устройств из CSV"
|
||||||
|
open={csvModal}
|
||||||
|
onCancel={closeCsvModal}
|
||||||
|
width={700}
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={closeCsvModal}>Отмена</Button>
|
||||||
|
{!csvPreview && (
|
||||||
|
<Button
|
||||||
|
onClick={handleCsvPreview}
|
||||||
|
loading={previewCSV.isPending}
|
||||||
|
disabled={!csvFile}
|
||||||
|
>
|
||||||
|
Предпросмотр
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{csvPreview && (
|
||||||
|
<Button type="primary" onClick={handleCsvImport} loading={importCSV.isPending}>
|
||||||
|
Импортировать
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!csvPreview ? (
|
||||||
|
<>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
Формат CSV: <code>ip, hostname, category, role, model, mac, notes</code>
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Upload.Dragger
|
||||||
|
accept=".csv"
|
||||||
|
maxCount={1}
|
||||||
|
beforeUpload={(file) => { setCsvFile(file); return false }}
|
||||||
|
onRemove={() => setCsvFile(null)}
|
||||||
|
fileList={csvFile ? [{ uid: '1', name: csvFile.name, status: 'done' } as UploadFile] : []}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<UploadOutlined style={{ fontSize: 32, color: '#1677ff' }} />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text">Перетащите CSV-файл или нажмите для выбора</p>
|
||||||
|
</Upload.Dragger>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{csvPreview.errors.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
message={`${csvPreview.errors.length} строк с ошибками`}
|
||||||
|
description={csvPreview.errors.map((e) => `Строка ${e.row}: ${e.error}`).join('\n')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{csvPreview.would_create.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography.Text strong>
|
||||||
|
Будет создано: {csvPreview.would_create.length}
|
||||||
|
</Typography.Text>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
dataSource={csvPreview.would_create}
|
||||||
|
columns={previewCreateCols}
|
||||||
|
rowKey="ip"
|
||||||
|
pagination={false}
|
||||||
|
style={{ marginTop: 8, marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{csvPreview.would_update.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography.Text strong>
|
||||||
|
Будет обновлено: {csvPreview.would_update.length}
|
||||||
|
</Typography.Text>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
dataSource={csvPreview.would_update}
|
||||||
|
columns={previewUpdateCols}
|
||||||
|
rowKey="ip"
|
||||||
|
pagination={false}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Add Device Modal ── */}
|
||||||
<Modal
|
<Modal
|
||||||
title="Добавить устройство"
|
title="Добавить устройство"
|
||||||
open={deviceModal}
|
open={deviceModal}
|
||||||
|
|
@ -226,7 +421,11 @@ export function ObjectDetailPage() {
|
||||||
<Form form={deviceForm} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={deviceForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="ip" label="IP-адрес" rules={[{ required: true, message: 'Обязательное поле' }]}>
|
<Form.Item
|
||||||
|
name="ip"
|
||||||
|
label="IP-адрес"
|
||||||
|
rules={[{ required: true, message: 'Обязательное поле' }]}
|
||||||
|
>
|
||||||
<Input placeholder="192.168.1.100" />
|
<Input placeholder="192.168.1.100" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -254,7 +453,11 @@ export function ObjectDetailPage() {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="role" label="Роль" rules={[{ required: true, message: 'Обязательное поле' }]}>
|
<Form.Item
|
||||||
|
name="role"
|
||||||
|
label="Роль"
|
||||||
|
rules={[{ required: true, message: 'Обязательное поле' }]}
|
||||||
|
>
|
||||||
<Input placeholder="entry / parking / barrier" />
|
<Input placeholder="entry / parking / barrier" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -273,7 +476,9 @@ export function ObjectDetailPage() {
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Divider orientation="left" plain>SSH (переопределить настройки объекта)</Divider>
|
<Divider orientation="left" plain>
|
||||||
|
SSH (переопределить настройки объекта)
|
||||||
|
</Divider>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
|
|
@ -295,6 +500,7 @@ export function ObjectDetailPage() {
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Task Modal ── */}
|
||||||
<Modal
|
<Modal
|
||||||
title="Создать задачу"
|
title="Создать задачу"
|
||||||
open={!!taskModal}
|
open={!!taskModal}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { PlusOutlined } from '@ant-design/icons'
|
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
|
@ -8,38 +8,96 @@ import {
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
Row,
|
Row,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useCreateObject, useObjects, type ObjectItem } from '../api/objects'
|
import {
|
||||||
|
useCreateObject,
|
||||||
|
useDeleteObject,
|
||||||
|
useUpdateObject,
|
||||||
|
useObjects,
|
||||||
|
type ObjectItem,
|
||||||
|
} from '../api/objects'
|
||||||
|
|
||||||
|
type ModalMode = 'create' | 'edit'
|
||||||
|
|
||||||
export function ObjectsPage() {
|
export function ObjectsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: objects, isLoading } = useObjects()
|
const { data: objects, isLoading } = useObjects()
|
||||||
const createObject = useCreateObject()
|
const createObject = useCreateObject()
|
||||||
|
const updateObject = useUpdateObject()
|
||||||
|
const deleteObject = useDeleteObject()
|
||||||
|
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [modalMode, setModalMode] = useState<ModalMode>('create')
|
||||||
|
const [editTarget, setEditTarget] = useState<ObjectItem | null>(null)
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
const handleCreate = async () => {
|
useEffect(() => {
|
||||||
|
if (modalMode === 'edit' && editTarget) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: editTarget.name,
|
||||||
|
description: editTarget.description ?? '',
|
||||||
|
server_ip: editTarget.server_ip ?? '',
|
||||||
|
ssh_user: editTarget.ssh_user ?? '',
|
||||||
|
db_host: editTarget.db_host ?? '',
|
||||||
|
// passwords intentionally left blank — only sent if filled
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [editTarget, modalMode, form])
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setModalMode('create')
|
||||||
|
setEditTarget(null)
|
||||||
|
form.resetFields()
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (row: ObjectItem) => {
|
||||||
|
setModalMode('edit')
|
||||||
|
setEditTarget(row)
|
||||||
|
form.resetFields()
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
// strip empty strings to undefined
|
|
||||||
const payload = Object.fromEntries(
|
const payload = Object.fromEntries(
|
||||||
Object.entries(values).filter(([, v]) => v !== '' && v !== null && v !== undefined)
|
Object.entries(values).filter(([, v]) => v !== '' && v !== null && v !== undefined)
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
await createObject.mutateAsync(payload as any)
|
if (modalMode === 'create') {
|
||||||
message.success('Объект создан')
|
await createObject.mutateAsync(payload as any)
|
||||||
|
message.success('Объект создан')
|
||||||
|
} else {
|
||||||
|
await updateObject.mutateAsync({ id: editTarget!.id, body: payload as any })
|
||||||
|
message.success('Объект обновлён')
|
||||||
|
}
|
||||||
setModalOpen(false)
|
setModalOpen(false)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Ошибка при создании объекта')
|
message.error(modalMode === 'create' ? 'Ошибка при создании объекта' : 'Ошибка при обновлении объекта')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: ObjectItem) => {
|
||||||
|
try {
|
||||||
|
await deleteObject.mutateAsync(row.id)
|
||||||
|
message.success(`Объект "${row.name}" удалён`)
|
||||||
|
} catch (err: any) {
|
||||||
|
const detail = err?.response?.data?.detail
|
||||||
|
if (err?.response?.status === 409) {
|
||||||
|
message.error('Нельзя удалить объект с устройствами — сначала удалите все устройства')
|
||||||
|
} else {
|
||||||
|
message.error(detail ?? 'Ошибка при удалении объекта')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,16 +147,40 @@ export function ObjectsPage() {
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
|
width: 160,
|
||||||
render: (_: unknown, row: ObjectItem) => (
|
render: (_: unknown, row: ObjectItem) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button size="small" onClick={() => navigate(`/objects/${row.id}`)}>
|
<Button size="small" onClick={() => navigate(`/objects/${row.id}`)}>
|
||||||
Устройства
|
Устройства
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={(e) => { e.stopPropagation(); openEdit(row) }}
|
||||||
|
/>
|
||||||
|
<Popconfirm
|
||||||
|
title="Удалить объект?"
|
||||||
|
description="Объект можно удалить только если у него нет устройств."
|
||||||
|
okText="Удалить"
|
||||||
|
cancelText="Отмена"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => handleDelete(row)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
loading={deleteObject.isPending}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const isSubmitting = createObject.isPending || updateObject.isPending
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -112,7 +194,7 @@ export function ObjectsPage() {
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
Объекты
|
Объекты
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||||
Добавить
|
Добавить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,16 +209,15 @@ export function ObjectsPage() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="Новый объект"
|
title={modalMode === 'create' ? 'Новый объект' : `Редактировать: ${editTarget?.name}`}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onCancel={() => { setModalOpen(false); form.resetFields() }}
|
onCancel={() => { setModalOpen(false); form.resetFields() }}
|
||||||
onOk={handleCreate}
|
onOk={handleSubmit}
|
||||||
confirmLoading={createObject.isPending}
|
confirmLoading={isSubmitting}
|
||||||
okText="Создать"
|
okText={modalMode === 'create' ? 'Создать' : 'Сохранить'}
|
||||||
width={640}
|
width={640}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
{/* ── Основное ── */}
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={14}>
|
<Col span={14}>
|
||||||
<Form.Item name="name" label="Название" rules={[{ required: true, message: 'Обязательное поле' }]}>
|
<Form.Item name="name" label="Название" rules={[{ required: true, message: 'Обязательное поле' }]}>
|
||||||
|
|
@ -163,8 +244,8 @@ export function ObjectsPage() {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={10}>
|
<Col span={10}>
|
||||||
<Form.Item name="ssh_password" label="Пароль">
|
<Form.Item name="ssh_password" label={modalMode === 'edit' ? 'Новый пароль' : 'Пароль'}>
|
||||||
<Input.Password placeholder="••••••••" />
|
<Input.Password placeholder={modalMode === 'edit' ? 'Оставьте пустым чтобы не менять' : '••••••••'} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
|
|
@ -206,8 +287,8 @@ export function ObjectsPage() {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Form.Item name="db_password" label="Пароль">
|
<Form.Item name="db_password" label={modalMode === 'edit' ? 'Новый пароль' : 'Пароль'}>
|
||||||
<Input.Password placeholder="••••••••" />
|
<Input.Password placeholder={modalMode === 'edit' ? 'Оставьте пустым чтобы не менять' : '••••••••'} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
@ -215,7 +296,7 @@ export function ObjectsPage() {
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Form.Item name="db_table" label="Таблица">
|
<Form.Item name="db_table" label="Таблица">
|
||||||
<Input placeholder="devices" />
|
<Input placeholder="racks" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { Table, Typography } from 'antd'
|
import { Table, Typography } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTasks, type Task } from '../api/tasks'
|
import { useTasks, type Task } from '../api/tasks'
|
||||||
import { TaskStatusBadge } from '../components/StatusBadge'
|
import { TaskStatusBadge } from '../components/StatusBadge'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
export function TasksPage() {
|
export function TasksPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: tasks, isLoading } = useTasks()
|
const [page, setPage] = useState(1)
|
||||||
|
const { data: tasks, isLoading } = useTasks(page, PAGE_SIZE)
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
|
|
@ -38,8 +42,7 @@ export function TasksPage() {
|
||||||
title: 'Итог',
|
title: 'Итог',
|
||||||
dataIndex: 'result',
|
dataIndex: 'result',
|
||||||
key: 'result',
|
key: 'result',
|
||||||
render: (r: Task['result']) =>
|
render: (r: Task['result']) => (r ? `${r.ok}/${r.total}` : '—'),
|
||||||
r ? `${r.ok}/${r.total}` : '—',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Создана',
|
title: 'Создана',
|
||||||
|
|
@ -60,7 +63,14 @@ export function TasksPage() {
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
size="middle"
|
size="middle"
|
||||||
pagination={{ pageSize: 30 }}
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
onChange: setPage,
|
||||||
|
// Backend doesn't return total count — hide size changer, show simple pagination
|
||||||
|
showSizeChanger: false,
|
||||||
|
simple: true,
|
||||||
|
}}
|
||||||
onRow={(row) => ({ onClick: () => navigate(`/tasks/${row.id}`) })}
|
onRow={(row) => ({ onClick: () => navigate(`/tasks/${row.id}`) })}
|
||||||
rowClassName={() => 'cursor-pointer'}
|
rowClassName={() => 'cursor-pointer'}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue