diff --git a/backend/app/routers/devices.py b/backend/app/routers/devices.py index d663ad0..218bae7 100644 --- a/backend/app/routers/devices.py +++ b/backend/app/routers/devices.py @@ -87,7 +87,7 @@ async def create_device( return device -@router.get("/import/preview", response_model=DeviceImportPreview) +@router.post("/import/preview", response_model=DeviceImportPreview) async def preview_csv_import( obj_id: int, file: Annotated[UploadFile, File()], diff --git a/backend/app/services/object_db.py b/backend/app/services/object_db.py index 4485b0c..14d7205 100644 --- a/backend/app/services/object_db.py +++ b/backend/app/services/object_db.py @@ -7,12 +7,11 @@ Expected table schema (racks): name TEXT — short label, e.g. "01" type TEXT — "rack.lane" | "rack.payment" | ... data JSONB — contains "host" (IP address) and other device-specific fields - -SSH tunnel support is deferred to Этап 2. """ from dataclasses import dataclass, field +import asyncssh import psycopg from sqlalchemy import select 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") +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: 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") return result - if obj.db_via_tunnel: - result.errors.append("SSH tunnel sync is not yet supported (coming in Этап 2)") - return result - try: - password = decrypt(obj.db_pass_enc) + db_password = decrypt(obj.db_pass_enc) except Exception: result.errors.append("Failed to decrypt object DB password") return result - dsn = ( - f"host={obj.db_host} port={obj.db_port} dbname={obj.db_name} " - f"user={obj.db_user} password={password} connect_timeout=10" - ) + if obj.db_via_tunnel: + # Connect via SSH tunnel through obj.server_ip + 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: - async with await psycopg.AsyncConnection.connect(dsn) as conn: - # Pull id, name, type and host from the JSONB data column - rows = await conn.execute( - f'SELECT id, name, type, data->>\'host\' AS host FROM "{obj.db_table}"' - ) - remote_rows = await rows.fetchall() - except Exception as exc: - result.errors.append(f"Cannot connect to object DB: {exc}") - return result + try: + async with asyncssh.connect( + obj.server_ip, + port=obj.ssh_port or 22, + username=obj.ssh_user, + password=ssh_password, + known_hosts=None, + ) as tunnel: + listener = await tunnel.forward_local_port( + "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: external_id, name, rack_type, host = row[0], row[1], row[2], row[3] diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index 9a21889..3e2d36c 100644 --- a/frontend/src/api/objects.ts +++ b/frontend/src/api/objects.ts @@ -38,6 +38,18 @@ export interface SyncResult { 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 }[] + errors: { row: number; error: string }[] +} + +export interface DeviceImportResult { + created: number + updated: number + errors: { row: number; error: string }[] +} + export const useObjects = (isActive?: boolean) => useQuery({ 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 }) => + api.patch(`/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 { ip: 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(`/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(`/api/v1/objects/${objId}/devices/import`, form) + .then((r) => r.data) + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }), + }) +} + export const useSyncObject = (objId: number) => { const qc = useQueryClient() return useMutation({ diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts index 6e8b04b..eb65bd1 100644 --- a/frontend/src/api/tasks.ts +++ b/frontend/src/api/tasks.ts @@ -45,10 +45,13 @@ export interface ActionInfo { params_schema: { name: string; type: string; label: string; required?: boolean }[] } -export const useTasks = () => +export const useTasks = (page = 1, pageSize = 50) => useQuery({ - queryKey: ['tasks'], - queryFn: () => api.get('/api/v1/tasks').then((r) => r.data), + queryKey: ['tasks', page, pageSize], + queryFn: () => + api + .get('/api/v1/tasks', { params: { skip: (page - 1) * pageSize, limit: pageSize } }) + .then((r) => r.data), refetchInterval: 5000, }) diff --git a/frontend/src/pages/ObjectDetail.tsx b/frontend/src/pages/ObjectDetail.tsx index efe553d..d6c37d6 100644 --- a/frontend/src/pages/ObjectDetail.tsx +++ b/frontend/src/pages/ObjectDetail.tsx @@ -1,10 +1,13 @@ import { + DeleteOutlined, PlayCircleOutlined, PlusOutlined, SyncOutlined, ThunderboltOutlined, + UploadOutlined, } from '@ant-design/icons' import { + Alert, Breadcrumb, Button, Col, @@ -14,6 +17,7 @@ import { Input, InputNumber, Modal, + Popconfirm, Row, Select, Space, @@ -21,11 +25,24 @@ import { Table, Tag, Tooltip, + Typography, + Upload, message, } from 'antd' +import type { UploadFile } from 'antd' import { useState } from 'react' 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 { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge' import dayjs from 'dayjs' @@ -40,11 +57,18 @@ export function ObjectDetailPage() { const syncMutation = useSyncObject(objId) const { data: actions } = useActions() const createTask = useCreateTask() - 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 [deviceModal, setDeviceModal] = useState(false) + const [csvModal, setCsvModal] = useState(false) + const [csvFile, setCsvFile] = useState(null) + const [csvPreview, setCsvPreview] = useState(null) + const [searchText, setSearchText] = useState('') + const [form] = Form.useForm() const [deviceForm] = Form.useForm() const [selectedAction, setSelectedAction] = useState('') @@ -66,7 +90,7 @@ export function ObjectDetailPage() { target_scope: taskModal!.scope, params, }) - message.success(`Задача создана`) + message.success('Задача создана') setTaskModal(null) navigate(`/tasks/${task.id}`) } 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 const columns = [ @@ -118,7 +186,10 @@ export function ObjectDetailPage() { dataIndex: 'category', key: 'category', render: (c: string) => , - 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, }, { @@ -149,18 +220,50 @@ export function ObjectDetailPage() { { title: '', key: 'actions', + width: 120, render: (_: unknown, row: DeviceItem) => ( - + + + handleDeleteDevice(row)} + > + - +