Add frontend UI for global SSH keys management

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-18 16:45:08 +00:00
parent 43aaac4bcc
commit 715045cc3a
3 changed files with 403 additions and 0 deletions

View File

@ -42,6 +42,12 @@ export default {
icon: <IconFont type="ql-icon-env" />,
component: '@/pages/env/index',
},
{
path: '/sshKey',
name: 'SSH密钥',
icon: <IconFont type="ql-icon-key" />,
component: '@/pages/sshKey/index',
},
{
path: '/config',
name: intl.get('配置文件'),

300
src/pages/sshKey/index.tsx Normal file
View File

@ -0,0 +1,300 @@
import useTableScrollHeight from '@/hooks/useTableScrollHeight';
import { SharedContext } from '@/layouts';
import config from '@/utils/config';
import { request } from '@/utils/http';
import {
CheckCircleOutlined,
DeleteOutlined,
EditOutlined,
StopOutlined,
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import { useOutletContext } from '@umijs/max';
import {
Button,
Input,
Modal,
Space,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import intl from 'react-intl-universal';
import { useVT } from 'virtualizedtableforantd4';
import Copy from '../../components/copy';
import SshKeyModal from './modal';
const { Paragraph, Text } = Typography;
const { Search } = Input;
enum Status {
'已启用',
'已禁用',
}
enum StatusColor {
'success',
'error',
}
enum OperationName {
'启用',
'禁用',
}
enum OperationPath {
'enable',
'disable',
}
const SshKey = () => {
const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>();
const columns: any = [
{
title: '序号',
width: 80,
render: (text: string, record: any, index: number) => {
return <span style={{ cursor: 'text' }}>{index + 1} </span>;
},
},
{
title: '别名',
dataIndex: 'alias',
key: 'alias',
sorter: (a: any, b: any) => a.alias.localeCompare(b.alias),
render: (text: string, record: any) => {
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
<Tooltip title={text} placement="topLeft">
<div className="text-ellipsis">{text}</div>
</Tooltip>
<Copy text={text} />
</div>
);
},
},
{
title: '备注',
dataIndex: 'remarks',
key: 'remarks',
width: '35%',
sorter: (a: any, b: any) => (a.remarks || '').localeCompare(b.remarks || ''),
render: (text: string, record: any) => {
return (
<Tooltip title={text} placement="topLeft">
<div className="text-ellipsis">{text}</div>
</Tooltip>
);
},
},
{
title: '状态',
key: 'status',
dataIndex: 'status',
width: 90,
filters: [
{
text: '已启用',
value: 0,
},
{
text: '已禁用',
value: 1,
},
],
onFilter: (value: number, record: any) => record.status === value,
render: (value: number, record: any) => {
return <Tag color={StatusColor[value]}>{Status[value]}</Tag>;
},
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 185,
sorter: (a: any, b: any) => {
return (
dayjs(a.createdAt || 0).valueOf() - dayjs(b.createdAt || 0).valueOf()
);
},
render: (text: string, record: any) => {
const d = dayjs(text);
return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : '-';
},
},
{
title: '操作',
key: 'action',
width: 120,
render: (text: string, record: any, index: number) => {
return (
<Space size="middle">
<Tooltip title={OperationName[record.status]}>
<a
onClick={() => {
operateSSHKey(
[record.id],
record.status === 0 ? OperationPath[1] : OperationPath[0],
);
}}
>
{OperationName[record.status]}
</a>
</Tooltip>
<Tooltip title="编辑">
<a
onClick={() => {
editSSHKey(record);
}}
>
<EditOutlined />
</a>
</Tooltip>
<Tooltip title="删除">
<a
onClick={() => {
deleteSSHKey(record);
}}
>
<DeleteOutlined />
</a>
</Tooltip>
</Space>
);
},
},
];
const [value, setValue] = useState<any>();
const [loading, setLoading] = useState(true);
const [isModalVisible, setIsModalVisible] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const tableRef = useRef<HTMLDivElement>(null);
const [data, setData] = useState<any[]>([]);
const scrollHeight = useTableScrollHeight(tableRef, data, [
headerStyle.marginTop,
]);
const [vt] = useVT(() => ({ scroll: { y: scrollHeight } }), [scrollHeight]);
const getSSHKeys = (needLoading = true) => {
setLoading(needLoading);
request
.get(`${config.apiPrefix}sshKeys?searchValue=${searchValue}`)
.then(({ code, data }) => {
if (code === 200) {
setData(data);
}
})
.finally(() => setLoading(false));
};
const editSSHKey = (record: any) => {
setValue(record);
setIsModalVisible(true);
};
const deleteSSHKey = (record: any) => {
Modal.confirm({
title: '确认删除',
content: (
<>
SSH密钥
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.alias}
</Text>
</>
),
onOk() {
request
.delete(`${config.apiPrefix}sshKeys`, [record.id])
.then(({ code, data }) => {
if (code === 200) {
message.success('删除成功');
getSSHKeys();
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
const operateSSHKey = (ids: any[], operationPath: string) => {
request
.put(`${config.apiPrefix}sshKeys/${operationPath}`, ids)
.then(({ code, data }) => {
if (code === 200) {
message.success(`批量${OperationName[OperationPath[operationPath]]}成功`);
getSSHKeys(false);
}
});
};
const onSearch = (value: string) => {
setSearchValue(value);
};
const handleCancel = (keys?: any[]) => {
setIsModalVisible(false);
if (keys) {
getSSHKeys();
}
};
const addSSHKey = () => {
setValue(null);
setIsModalVisible(true);
};
useEffect(() => {
getSSHKeys();
}, [searchValue]);
const rowSelection = {
selectedRowKeys,
onChange: (selectedRowKeys: React.Key[], selectedRows: any[]) => {
setSelectedRowKeys(selectedRowKeys);
},
};
return (
<PageContainer
className="ql-container-wrapper"
title="SSH密钥"
extra={[
<Button key="1" type="primary" onClick={addSSHKey}>
</Button>,
]}
header={{
style: headerStyle,
}}
>
<div ref={tableRef}>
<Table
columns={columns}
rowSelection={rowSelection}
dataSource={data}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 768 }}
sticky
components={vt}
size="middle"
/>
</div>
{isModalVisible && (
<SshKeyModal sshKey={value} handleCancel={handleCancel} />
)}
</PageContainer>
);
};
export default SshKey;

View File

@ -0,0 +1,97 @@
import intl from 'react-intl-universal';
import React, { useEffect, useState } from 'react';
import { Modal, message, Input, Form } from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
const SshKeyModal = ({
sshKey,
handleCancel,
}: {
sshKey?: any;
handleCancel: (keys?: any[]) => void;
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const handleOk = async (values: any) => {
setLoading(true);
const method = sshKey ? 'put' : 'post';
const payload = sshKey ? { ...values, id: sshKey.id } : [values];
try {
const { code, data } = await request[method](
`${config.apiPrefix}sshKeys`,
payload,
);
if (code === 200) {
message.success(
sshKey ? '更新SSH密钥成功' : '创建SSH密钥成功',
);
handleCancel(data);
}
setLoading(false);
} catch (error: any) {
setLoading(false);
}
};
return (
<Modal
title={sshKey ? '编辑SSH密钥' : '创建SSH密钥'}
open={true}
forceRender
centered
maskClosable={false}
onOk={() => {
form
.validateFields()
.then((values) => {
handleOk(values);
})
.catch((info) => {
console.log('Validate Failed:', info);
});
}}
onCancel={() => handleCancel()}
confirmLoading={loading}
>
<Form form={form} layout="vertical" name="ssh_key_modal" initialValues={sshKey}>
<Form.Item
name="alias"
label="别名"
rules={[
{
required: true,
message: '请输入SSH密钥别名',
whitespace: true,
},
]}
>
<Input placeholder="请输入SSH密钥别名" disabled={!!sshKey} />
</Form.Item>
<Form.Item
name="private_key"
label="私钥"
rules={[
{
required: true,
message: '请输入SSH私钥',
whitespace: true,
},
]}
>
<Input.TextArea
autoSize={{ minRows: 4, maxRows: 12 }}
placeholder="请输入SSH私钥内容以 -----BEGIN 开头)"
/>
</Form.Item>
<Form.Item name="remarks" label="备注">
<Input placeholder="请输入备注" />
</Form.Item>
</Form>
</Modal>
);
};
export default SshKeyModal;