581 lines
19 KiB
TypeScript
581 lines
19 KiB
TypeScript
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>
|
||
)
|
||
}
|