utp_service/frontend/src/pages/Objects.tsx
2026-04-07 01:05:59 +03:00

321 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import {
Button,
Checkbox,
Col,
Divider,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Space,
Table,
Tag,
Typography,
message,
} from 'antd'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
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()
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 payload = Object.fromEntries(
Object.entries(values).filter(([, v]) => v !== '' && v !== null && v !== undefined)
)
try {
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(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 ?? 'Ошибка при удалении объекта')
}
}
}
const columns = [
{
title: 'Название',
dataIndex: 'name',
key: 'name',
render: (name: string, row: ObjectItem) => (
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/objects/${row.id}`)}>
{name}
</Button>
),
},
{
title: 'Сервер',
dataIndex: 'server_ip',
key: 'server_ip',
render: (ip: string | null) =>
ip ?? <Typography.Text type="secondary"></Typography.Text>,
},
{
title: 'SSH',
dataIndex: 'ssh_user',
key: 'ssh_user',
render: (u: string | null) =>
u ? <Tag>{u}</Tag> : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: 'Теги',
dataIndex: 'tags',
key: 'tags',
render: (tags: ObjectItem['tags']) =>
tags.map((t) => (
<Tag key={t.id} color={t.color}>
{t.name}
</Tag>
)),
},
{
title: 'Статус',
dataIndex: 'is_active',
key: 'is_active',
render: (active: boolean) =>
active ? <Tag color="green">Активен</Tag> : <Tag color="default">Неактивен</Tag>,
},
{
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
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
Объекты
</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
Добавить
</Button>
</div>
<Table
dataSource={objects ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="middle"
pagination={{ pageSize: 20 }}
/>
<Modal
title={modalMode === 'create' ? 'Новый объект' : `Редактировать: ${editTarget?.name}`}
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields() }}
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: 'Обязательное поле' }]}>
<Input placeholder="Анапа, Парковка ТЦ Мира" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="server_ip" label="IP сервера объекта">
<Input placeholder="10.23.0.201" />
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="Описание">
<Input.TextArea rows={2} />
</Form.Item>
<Divider orientation="left" plain>SSH (по умолчанию для устройств)</Divider>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="ssh_user" label="Логин">
<Input placeholder="caps" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="ssh_password" label={modalMode === 'edit' ? 'Новый пароль' : 'Пароль'}>
<Input.Password placeholder={modalMode === 'edit' ? 'Оставьте пустым чтобы не менять' : '••••••••'} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="ssh_port" label="Порт" initialValue={22}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</Col>
</Row>
<Divider orientation="left" plain>
Подключение к БД объекта{' '}
<Typography.Text type="secondary" style={{ fontWeight: 400 }}>
(для синхронизации устройств)
</Typography.Text>
</Divider>
<Row gutter={16}>
<Col span={14}>
<Form.Item name="db_host" label="Хост БД">
<Input placeholder="10.23.0.202" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="db_port" label="Порт" initialValue={5432}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="db_name" label="База данных">
<Input placeholder="caps" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="db_user" label="Пользователь">
<Input placeholder="caps" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="db_password" label={modalMode === 'edit' ? 'Новый пароль' : 'Пароль'}>
<Input.Password placeholder={modalMode === 'edit' ? 'Оставьте пустым чтобы не менять' : '••••••••'} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="db_table" label="Таблица">
<Input placeholder="racks" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="db_col_ip" label="Колонка IP">
<Input placeholder="ip_address" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="db_col_type" label="Колонка типа">
<Input placeholder="device_type" />
</Form.Item>
</Col>
</Row>
<Form.Item name="db_via_tunnel" valuePropName="checked">
<Checkbox>Подключаться через SSH-туннель</Checkbox>
</Form.Item>
</Form>
</Modal>
</div>
)
}