321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
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>
|
||
)
|
||
}
|