этап 2 ок?

This commit is contained in:
dv 2026-04-07 01:05:59 +03:00
parent 68b9fa7417
commit 86c02eb2e9
7 changed files with 468 additions and 73 deletions

View file

@ -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()],

View 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,28 +66,53 @@ 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,
known_hosts=None,
) as tunnel:
listener = await tunnel.forward_local_port(
"127.0.0.1", 0, obj.db_host, obj.db_port or 5432
) )
remote_rows = await rows.fetchall() 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: except Exception as exc:
result.errors.append(f"Cannot connect to object DB: {exc}") result.errors.append(f"Cannot connect to object DB: {exc}")
return result return result

View file

@ -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({

View file

@ -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,
}) })

View file

@ -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,7 +220,9 @@ export function ObjectDetailPage() {
{ {
title: '', title: '',
key: 'actions', key: 'actions',
width: 120,
render: (_: unknown, row: DeviceItem) => ( render: (_: unknown, row: DeviceItem) => (
<Space>
<Button <Button
size="small" size="small"
icon={<PlayCircleOutlined />} icon={<PlayCircleOutlined />}
@ -157,10 +230,40 @@ export function ObjectDetailPage() {
> >
Запустить Запустить
</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}

View file

@ -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 {
if (modalMode === 'create') {
await createObject.mutateAsync(payload as any) await createObject.mutateAsync(payload as any)
message.success('Объект создан') 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}>

View file

@ -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'}
/> />