utp_service/frontend/src/pages/Admin.tsx
2026-04-09 14:33:34 +03:00

581 lines
19 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,
GlobalOutlined,
KeyOutlined,
PlusOutlined,
UserOutlined,
ApiOutlined,
} from '@ant-design/icons'
import {
Badge,
Breadcrumb,
Button,
Col,
Divider,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Switch,
Table,
Tag,
Tabs,
Typography,
message,
} from 'antd'
import { useState } from 'react'
import {
useAdminUsers,
useCreateAdminUser,
useDeleteAdminUser,
useGlobalSSHCredentials,
useCreateGlobalSSHCredential,
useDeleteGlobalSSHCredential,
usePlugins,
useUpdateAdminUser,
useUpdatePlugin,
type GlobalSSHCredentialItem,
type UserItem,
} from '../api/admin'
import { useSSHKeys, useCreateSSHKey, useDeleteSSHKey, type SSHKeyItem } from '../api/credentials'
import { stripEmpty } from '../utils/form'
// ── Plugins Tab ───────────────────────────────────────────────────────────────
function PluginsTab() {
const { data: plugins, isLoading } = usePlugins()
const updatePlugin = useUpdatePlugin()
const toggle = async (name: string, enabled: boolean) => {
try {
await updatePlugin.mutateAsync({ name, enabled })
message.success(enabled ? `Плагин «${name}» включён` : `Плагин «${name}» отключён`)
} catch {
message.error('Ошибка при обновлении плагина')
}
}
const columns = [
{
title: 'Название',
key: 'display_name',
render: (_: unknown, row: (typeof plugins)[0]) => (
<Space direction="vertical" size={0}>
<Typography.Text strong>{row.display_name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.name}
</Typography.Text>
</Space>
),
},
{
title: 'Описание',
dataIndex: 'description',
key: 'description',
render: (v: string | null) => v ?? '—',
},
{
title: 'Категории',
dataIndex: 'compatible_categories',
key: 'categories',
render: (cats: string[]) =>
cats.length === 0 ? (
<Tag color="default">Все</Tag>
) : (
cats.map((c) => <Tag key={c}>{c}</Tag>)
),
},
{
title: 'Статус',
key: 'enabled',
width: 100,
render: (_: unknown, row: (typeof plugins)[0]) => (
<Switch
checked={row.enabled}
onChange={(val) => toggle(row.name, val)}
loading={updatePlugin.isPending}
checkedChildren="Вкл"
unCheckedChildren="Откл"
/>
),
},
]
return (
<Table
dataSource={plugins ?? []}
columns={columns}
rowKey="name"
loading={isLoading}
size="middle"
pagination={false}
/>
)
}
// ── Users Tab ─────────────────────────────────────────────────────────────────
function UsersTab() {
const { data: users, isLoading } = useAdminUsers()
const createUser = useCreateAdminUser()
const updateUser = useUpdateAdminUser()
const deleteUser = useDeleteAdminUser()
const [createModal, setCreateModal] = useState(false)
const [editTarget, setEditTarget] = useState<UserItem | null>(null)
const [form] = Form.useForm()
const openCreate = () => {
form.resetFields()
setEditTarget(null)
setCreateModal(true)
}
const openEdit = (user: UserItem) => {
form.resetFields()
form.setFieldsValue({
email: user.email ?? '',
full_name: user.full_name ?? '',
role: user.role,
is_active: user.is_active,
})
setEditTarget(user)
setCreateModal(true)
}
const handleSave = async () => {
const values = await form.validateFields()
const payload = stripEmpty(values)
try {
if (editTarget) {
await updateUser.mutateAsync({ id: editTarget.id, body: payload as any })
message.success('Пользователь обновлён')
} else {
await createUser.mutateAsync(payload as any)
message.success('Пользователь создан')
}
setCreateModal(false)
setEditTarget(null)
} catch (err: any) {
const detail = err?.response?.data?.detail
message.error(detail ?? 'Ошибка при сохранении')
}
}
const handleDelete = async (id: number) => {
try {
await deleteUser.mutateAsync(id)
message.success('Пользователь удалён')
} catch (err: any) {
message.error(err?.response?.data?.detail ?? 'Ошибка при удалении')
}
}
const columns = [
{
title: 'Логин',
dataIndex: 'username',
key: 'username',
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
{ title: 'Email', dataIndex: 'email', key: 'email', render: (v: string | null) => v ?? '—' },
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
render: (v: string) => (
<Tag color={v === 'admin' ? 'red' : 'blue'}>{v}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'is_active',
key: 'is_active',
render: (v: boolean) =>
v ? <Badge status="success" text="Активен" /> : <Badge status="default" text="Неактивен" />,
},
{
title: '',
key: 'actions',
width: 100,
render: (_: unknown, row: UserItem) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(row)} />
<Popconfirm
title="Удалить пользователя?"
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => handleDelete(row.id)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
return (
<>
<div style={{ marginBottom: 16, textAlign: 'right' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
Создать пользователя
</Button>
</div>
<Table
dataSource={users ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="middle"
pagination={false}
/>
<Modal
title={editTarget ? `Редактировать: ${editTarget.username}` : 'Создать пользователя'}
open={createModal}
onCancel={() => { setCreateModal(false); setEditTarget(null) }}
onOk={handleSave}
confirmLoading={createUser.isPending || updateUser.isPending}
okText={editTarget ? 'Сохранить' : 'Создать'}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
{!editTarget && (
<>
<Form.Item name="username" label="Логин" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
</>
)}
{editTarget && (
<Form.Item name="password" label="Новый пароль (оставьте пустым для сохранения)">
<Input.Password />
</Form.Item>
)}
<Row gutter={12}>
<Col span={12}>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="full_name" label="Полное имя">
<Input />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="role" label="Роль" initialValue="operator">
<Select options={[
{ value: 'admin', label: 'Администратор' },
{ value: 'operator', label: 'Оператор' },
]} />
</Form.Item>
</Col>
{editTarget && (
<Col span={12}>
<Form.Item name="is_active" label="Статус" valuePropName="checked">
<Switch checkedChildren="Активен" unCheckedChildren="Неактивен" />
</Form.Item>
</Col>
)}
</Row>
</Form>
</Modal>
</>
)
}
// ── SSH Keys Tab ──────────────────────────────────────────────────────────────
function SSHKeysTab() {
const { data: keys, isLoading } = useSSHKeys()
const createKey = useCreateSSHKey()
const deleteKey = useDeleteSSHKey()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const handleCreate = async () => {
const values = await form.validateFields()
try {
await createKey.mutateAsync(values)
message.success('Ключ зарегистрирован')
setModalOpen(false)
form.resetFields()
} catch (err: any) {
message.error(err?.response?.data?.detail ?? 'Ошибка при создании')
}
}
const columns = [
{ title: 'Название', dataIndex: 'name', key: 'name' },
{ title: 'Путь на сервере', dataIndex: 'path', key: 'path', render: (v: string) => <Typography.Text code>{v}</Typography.Text> },
{ title: 'Описание', dataIndex: 'description', key: 'description' },
{
title: '',
key: 'actions',
width: 60,
render: (_: unknown, row: SSHKeyItem) => (
<Popconfirm
title="Удалить ключ?"
description="Устройства, использующие этот ключ, потеряют его привязку."
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => deleteKey.mutateAsync(row.id).catch(() => message.error('Ошибка при удалении'))}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
]
return (
<>
<div style={{ marginBottom: 16, textAlign: 'right' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
Зарегистрировать ключ
</Button>
</div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Файлы SSH-ключей должны находиться на сервере. Укажите путь к существующему файлу.
</Typography.Text>
<Table
dataSource={keys ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="middle"
pagination={false}
/>
<Modal
title="Зарегистрировать SSH-ключ"
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleCreate}
confirmLoading={createKey.isPending}
okText="Зарегистрировать"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="Название" rules={[{ required: true }]}>
<Input placeholder="main-key" />
</Form.Item>
<Form.Item name="path" label="Путь на сервере" rules={[{ required: true }]}>
<Input placeholder="/app/ssh_keys/id_rsa" />
</Form.Item>
<Form.Item name="description" label="Описание">
<Input placeholder="Основной ключ доступа к объектам" />
</Form.Item>
</Form>
</Modal>
</>
)
}
// ── Global SSH Credentials Tab ────────────────────────────────────────────────
function GlobalCredsTab() {
const { data: creds, isLoading } = useGlobalSSHCredentials()
const { data: sshKeys } = useSSHKeys()
const createCred = useCreateGlobalSSHCredential()
const deleteCred = useDeleteGlobalSSHCredential()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const handleCreate = async () => {
const values = await form.validateFields()
const payload = stripEmpty(values)
try {
await createCred.mutateAsync(payload as any)
message.success('Учётные данные добавлены')
setModalOpen(false)
form.resetFields()
} catch (err: any) {
message.error(err?.response?.data?.detail ?? 'Ошибка при создании')
}
}
const columns = [
{
title: 'Тип',
dataIndex: 'type',
key: 'type',
width: 90,
render: (v: string) => <Tag color={v === 'key' ? 'green' : 'blue'}>{v === 'key' ? 'SSH-ключ' : 'Пароль'}</Tag>,
},
{
title: 'Логин',
dataIndex: 'username',
key: 'username',
render: (v: string) => <Typography.Text code>{v}</Typography.Text>,
},
{
title: 'SSH-ключ',
dataIndex: 'ssh_key_id',
key: 'ssh_key_id',
render: (v: number | null) => {
if (!v) return '—'
const key = (sshKeys ?? []).find((k) => k.id === v)
return key ? <Tag color="green">{key.name}</Tag> : <Tag>key_id={v}</Tag>
},
},
{
title: 'Приоритет',
dataIndex: 'priority',
key: 'priority',
width: 100,
},
{
title: 'Описание',
dataIndex: 'description',
key: 'description',
render: (v: string | null) => v ?? '—',
},
{
title: '',
key: 'actions',
width: 60,
render: (_: unknown, row: GlobalSSHCredentialItem) => (
<Popconfirm
title="Удалить глобальные учётные данные?"
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => deleteCred.mutateAsync(row.id).catch(() => message.error('Ошибка при удалении'))}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
]
return (
<>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Глобальные SSH-кредентиалы используются как последний резерв при подключении к устройствам,
если ни объектные, ни device-level настройки не подошли.
</Typography.Text>
<div style={{ marginBottom: 16, textAlign: 'right' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
Добавить
</Button>
</div>
<Table
dataSource={creds ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="middle"
pagination={false}
/>
<Modal
title="Добавить глобальные SSH-кредентиалы"
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleCreate}
confirmLoading={createCred.isPending}
okText="Добавить"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="type" label="Тип" rules={[{ required: true }]} initialValue="password">
<Select options={[
{ value: 'password', label: 'Пароль' },
{ value: 'key', label: 'SSH-ключ' },
]} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="priority" label="Приоритет" initialValue={0}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="username" label="Логин" rules={[{ required: true }]}>
<Input placeholder="root" />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.type !== cur.type}>
{({ getFieldValue }) =>
getFieldValue('type') === 'key' ? (
<Form.Item name="ssh_key_id" label="SSH-ключ" rules={[{ required: true }]}>
<Select
placeholder="Выбрать ключ"
options={(sshKeys ?? []).map((k) => ({ value: k.id, label: `${k.name} (${k.path})` }))}
/>
</Form.Item>
) : (
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
)
}
</Form.Item>
<Form.Item name="description" label="Описание">
<Input placeholder="Дефолтный пароль устройств" />
</Form.Item>
</Form>
</Modal>
</>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export function AdminPage() {
return (
<div>
<Breadcrumb style={{ marginBottom: 16 }} items={[{ title: 'Администрирование' }]} />
<Typography.Title level={4} style={{ marginBottom: 16 }}>
Администрирование
</Typography.Title>
<Tabs
defaultActiveKey="plugins"
items={[
{
key: 'plugins',
label: (
<span>
<ApiOutlined /> Плагины
</span>
),
children: <PluginsTab />,
},
{
key: 'users',
label: (
<span>
<UserOutlined /> Пользователи
</span>
),
children: <UsersTab />,
},
{
key: 'ssh-keys',
label: (
<span>
<KeyOutlined /> SSH-ключи
</span>
),
children: <SSHKeysTab />,
},
{
key: 'global-creds',
label: (
<span>
<GlobalOutlined /> Глобальные SSH-кредентиалы
</span>
),
children: <GlobalCredsTab />,
},
]}
/>
</div>
)
}