этап 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
|
||||
|
||||
|
||||
@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()],
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<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) =>
|
||||
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<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 {
|
||||
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<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) => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
|
|
|||
|
|
@ -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<Task[]>('/api/v1/tasks').then((r) => r.data),
|
||||
queryKey: ['tasks', page, pageSize],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get<Task[]>('/api/v1/tasks', { params: { skip: (page - 1) * pageSize, limit: pageSize } })
|
||||
.then((r) => r.data),
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<File | null>(null)
|
||||
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const [form] = Form.useForm()
|
||||
const [deviceForm] = Form.useForm()
|
||||
const [selectedAction, setSelectedAction] = useState<string>('')
|
||||
|
|
@ -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 <Spin />
|
||||
|
||||
const columns = [
|
||||
|
|
@ -118,7 +186,10 @@ export function ObjectDetailPage() {
|
|||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
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,
|
||||
},
|
||||
{
|
||||
|
|
@ -149,18 +220,50 @@ export function ObjectDetailPage() {
|
|||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (_: unknown, row: DeviceItem) => (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => openTaskModal({ type: 'device', id: row.id })}
|
||||
>
|
||||
Запустить
|
||||
</Button>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => openTaskModal({ type: 'device', id: row.id })}
|
||||
>
|
||||
Запустить
|
||||
</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 (
|
||||
<div>
|
||||
<Breadcrumb
|
||||
|
|
@ -181,17 +284,13 @@ export function ObjectDetailPage() {
|
|||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={syncMutation.isPending}
|
||||
onClick={handleSync}
|
||||
>
|
||||
<Button icon={<SyncOutlined />} loading={syncMutation.isPending} onClick={handleSync}>
|
||||
Синхронизировать из БД
|
||||
</Button>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setDeviceModal(true)}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} onClick={() => setCsvModal(true)}>
|
||||
Импорт CSV
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={() => setDeviceModal(true)}>
|
||||
Добавить устройство
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -205,8 +304,15 @@ export function ObjectDetailPage() {
|
|||
</Col>
|
||||
</Row>
|
||||
|
||||
<Input.Search
|
||||
placeholder="Поиск по IP или hostname..."
|
||||
allowClear
|
||||
style={{ marginBottom: 12, maxWidth: 360 }}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
|
||||
<Table
|
||||
dataSource={devices ?? []}
|
||||
dataSource={filteredDevices}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={devLoading}
|
||||
|
|
@ -214,6 +320,95 @@ export function ObjectDetailPage() {
|
|||
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
|
||||
title="Добавить устройство"
|
||||
open={deviceModal}
|
||||
|
|
@ -226,7 +421,11 @@ export function ObjectDetailPage() {
|
|||
<Form form={deviceForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<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" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
|
@ -254,7 +453,11 @@ export function ObjectDetailPage() {
|
|||
</Form.Item>
|
||||
</Col>
|
||||
<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" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
|
@ -273,7 +476,9 @@ export function ObjectDetailPage() {
|
|||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider orientation="left" plain>SSH (переопределить настройки объекта)</Divider>
|
||||
<Divider orientation="left" plain>
|
||||
SSH (переопределить настройки объекта)
|
||||
</Divider>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
|
|
@ -295,6 +500,7 @@ export function ObjectDetailPage() {
|
|||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* ── Task Modal ── */}
|
||||
<Modal
|
||||
title="Создать задачу"
|
||||
open={!!taskModal}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
|
|
@ -8,38 +8,96 @@ import {
|
|||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
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() {
|
||||
const navigate = useNavigate()
|
||||
const { data: objects, isLoading } = useObjects()
|
||||
const createObject = useCreateObject()
|
||||
const updateObject = useUpdateObject()
|
||||
const deleteObject = useDeleteObject()
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [modalMode, setModalMode] = useState<ModalMode>('create')
|
||||
const [editTarget, setEditTarget] = useState<ObjectItem | null>(null)
|
||||
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()
|
||||
// strip empty strings to undefined
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(values).filter(([, v]) => v !== '' && v !== null && v !== undefined)
|
||||
)
|
||||
try {
|
||||
await createObject.mutateAsync(payload as any)
|
||||
message.success('Объект создан')
|
||||
if (modalMode === 'create') {
|
||||
await createObject.mutateAsync(payload as any)
|
||||
message.success('Объект создан')
|
||||
} else {
|
||||
await updateObject.mutateAsync({ id: editTarget!.id, body: payload as any })
|
||||
message.success('Объект обновлён')
|
||||
}
|
||||
setModalOpen(false)
|
||||
form.resetFields()
|
||||
} 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: '',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (_: unknown, row: ObjectItem) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => navigate(`/objects/${row.id}`)}>
|
||||
Устройства
|
||||
</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>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const isSubmitting = createObject.isPending || updateObject.isPending
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
|
|
@ -112,7 +194,7 @@ export function ObjectsPage() {
|
|||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
Объекты
|
||||
</Typography.Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -127,16 +209,15 @@ export function ObjectsPage() {
|
|||
/>
|
||||
|
||||
<Modal
|
||||
title="Новый объект"
|
||||
title={modalMode === 'create' ? 'Новый объект' : `Редактировать: ${editTarget?.name}`}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setModalOpen(false); form.resetFields() }}
|
||||
onOk={handleCreate}
|
||||
confirmLoading={createObject.isPending}
|
||||
okText="Создать"
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={isSubmitting}
|
||||
okText={modalMode === 'create' ? 'Создать' : 'Сохранить'}
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
{/* ── Основное ── */}
|
||||
<Row gutter={16}>
|
||||
<Col span={14}>
|
||||
<Form.Item name="name" label="Название" rules={[{ required: true, message: 'Обязательное поле' }]}>
|
||||
|
|
@ -163,8 +244,8 @@ export function ObjectsPage() {
|
|||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Form.Item name="ssh_password" label="Пароль">
|
||||
<Input.Password placeholder="••••••••" />
|
||||
<Form.Item name="ssh_password" label={modalMode === 'edit' ? 'Новый пароль' : 'Пароль'}>
|
||||
<Input.Password placeholder={modalMode === 'edit' ? 'Оставьте пустым чтобы не менять' : '••••••••'} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
|
|
@ -206,8 +287,8 @@ export function ObjectsPage() {
|
|||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="db_password" label="Пароль">
|
||||
<Input.Password placeholder="••••••••" />
|
||||
<Form.Item name="db_password" label={modalMode === 'edit' ? 'Новый пароль' : 'Пароль'}>
|
||||
<Input.Password placeholder={modalMode === 'edit' ? 'Оставьте пустым чтобы не менять' : '••••••••'} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -215,7 +296,7 @@ export function ObjectsPage() {
|
|||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="db_table" label="Таблица">
|
||||
<Input placeholder="devices" />
|
||||
<Input placeholder="racks" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { Table, Typography } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTasks, type Task } from '../api/tasks'
|
||||
import { TaskStatusBadge } from '../components/StatusBadge'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function TasksPage() {
|
||||
const navigate = useNavigate()
|
||||
const { data: tasks, isLoading } = useTasks()
|
||||
const [page, setPage] = useState(1)
|
||||
const { data: tasks, isLoading } = useTasks(page, PAGE_SIZE)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
|
|
@ -38,8 +42,7 @@ export function TasksPage() {
|
|||
title: 'Итог',
|
||||
dataIndex: 'result',
|
||||
key: 'result',
|
||||
render: (r: Task['result']) =>
|
||||
r ? `${r.ok}/${r.total}` : '—',
|
||||
render: (r: Task['result']) => (r ? `${r.ok}/${r.total}` : '—'),
|
||||
},
|
||||
{
|
||||
title: 'Создана',
|
||||
|
|
@ -60,7 +63,14 @@ export function TasksPage() {
|
|||
rowKey="id"
|
||||
loading={isLoading}
|
||||
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}`) })}
|
||||
rowClassName={() => 'cursor-pointer'}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue