feat: 添加数据库资产、命令拦截器、授权资产等功能,修复GitHub Actions工作流

This commit is contained in:
2026-04-18 07:44:18 +08:00
parent 6e2e2f9387
commit 3c217ab039
64 changed files with 3308 additions and 760 deletions
@@ -1,4 +1,4 @@
import React, {useEffect, useMemo, useState} from 'react';
import React, {useEffect, useMemo, useRef, useState} from 'react';
import {Button, Card, Input, Modal, Result, Space, Spin} from "antd";
import accountApi, {AuthType} from "@/api/account-api";
import {startAuthentication} from "@simplewebauthn/browser";
@@ -26,7 +26,8 @@ const MultiFactorAuthentication = ({open, handleOk, handleCancel, forceReauth =
const [loading, setLoading] = useState(false);
const [otpValue, setOtpValue] = useState('');
const [showSelector, setShowSelector] = useState(false);
const [otpKey, setOtpKey] = useState(0); // 用于强制重新挂载 InputOTP
const [otpKey, setOtpKey] = useState(0);
const handledRef = useRef(false);
// 查询支持的认证类型
const {data: supportedAuthTypes = []} = useQuery({
@@ -58,6 +59,7 @@ const MultiFactorAuthentication = ({open, handleOk, handleCancel, forceReauth =
setOtpValue('');
setShowSelector(false);
setAuthType('');
handledRef.current = false;
};
// 清除输入
@@ -81,12 +83,10 @@ const MultiFactorAuthentication = ({open, handleOk, handleCancel, forceReauth =
const validateSecurityToken = () => {
const securityToken = sessionStorage.getItem('securityToken');
if (securityToken) {
// 检查 Token 是否有效,有效则直接使用
accountApi.validateSecurityToken(securityToken).then((ok) => {
if (ok) {
storeSecurityToken(securityToken);
} else {
// 无效则清除
sessionStorage.removeItem('securityToken');
}
});
@@ -121,7 +121,7 @@ const MultiFactorAuthentication = ({open, handleOk, handleCancel, forceReauth =
onSuccess: (token) => token && storeSecurityToken(token),
onError: (e: any) => {
setOtpValue('');
setOtpKey(prev => prev + 1); // 强制重新挂载以重新获得焦点
setOtpKey(prev => prev + 1);
const code = typeof e?.code === 'number' ? e.code : 1;
setError({code, message: e?.message || String(e)});
}
@@ -141,8 +141,13 @@ const MultiFactorAuthentication = ({open, handleOk, handleCancel, forceReauth =
return;
}
if (handledRef.current) {
return;
}
// 没有可用的认证方式,直接通过
if (supportedAuthTypes.length === 0) {
handledRef.current = true;
handleOk('');
return;
}
@@ -153,7 +158,6 @@ const MultiFactorAuthentication = ({open, handleOk, handleCancel, forceReauth =
setAuthType(supportedAuthTypes[0]);
setShowSelector(false);
} else {
// 有可用的认证方式
if (!authType || authType === 'none') {
setAuthType('');
setShowSelector(true);
@@ -170,8 +174,7 @@ const MultiFactorAuthentication = ({open, handleOk, handleCancel, forceReauth =
}, [authType, showSelector, open]);
useEffect(() => {
if (open && !forceReauth) {
// 检查是否有存储的 securityToken
if (open && !forceReauth && !handledRef.current) {
validateSecurityToken();
}
}, [open, forceReauth]);
@@ -93,24 +93,14 @@ const AuthorisedAssetPost = () => {
}}
request={async () => {
let items = await assetApi.getGroups();
// let selected = await authorisedAssetApi.selected('assetId', userId, userGroupId, '');
// 递归把 key 字段设置为 value,并且非叶子节点全部 disabled
function setKeyAndDisabled(item: any) {
item.value = item.key;
if (!item.isLeaf) {
// 递归处理子节点
if (item.children) {
item.children.forEach(setKeyAndDisabled);
}
function processNode(item: any) {
item.value = item.id;
if (item.children) {
item.children.forEach(processNode);
}
// if (selected.includes(item.key)) {
// item.disabled = true;
// }
}
// 对获取到的所有节点进行处理
items.forEach((item: any) => {
setKeyAndDisabled(item);
processNode(item);
});
return items;
}}
@@ -127,28 +117,19 @@ const AuthorisedAssetPost = () => {
}}
request={async () => {
let items = await assetApi.tree();
// let selected = await authorisedAssetApi.selected('assetId', userId, userGroupId, '');
// 递归把 key 字段设置为 value,并且非叶子节点全部 disabled
function setKeyAndDisabled(item: any) {
item.value = item.key;
function processNode(item: any) {
item.value = item.id;
if (!item.isLeaf) {
item.disabled = true;
// 递归处理子节点
if (item.children) {
item.children.forEach(setKeyAndDisabled);
}
} else {
item.title = item.title + ' (' + item.extra?.network + ')';
}
// if (selected.includes(item.key)) {
// item.disabled = true;
// }
if (item.children) {
item.children.forEach(processNode);
}
}
// 对获取到的所有节点进行处理
items.forEach((item: any) => {
setKeyAndDisabled(item);
processNode(item);
});
return items;
}}
+18 -3
View File
@@ -29,7 +29,18 @@ const GatewayGroupDrawer: React.FC<Props> = ({open, group, onClose}) => {
useEffect(() => {
if (open && group) {
form.setFieldsValue(group);
let members = group.members;
if (typeof members === 'string') {
try {
members = JSON.parse(members);
} catch {
members = [];
}
}
form.setFieldsValue({
...group,
members,
});
} else if (open) {
form.resetFields();
form.setFieldsValue({
@@ -41,10 +52,14 @@ const GatewayGroupDrawer: React.FC<Props> = ({open, group, onClose}) => {
const handleSubmit = async (values: any) => {
try {
const submitData = {
...values,
members: JSON.stringify(values.members || []),
};
if (group?.id) {
await gatewayGroupApi.updateById(group.id, values);
await gatewayGroupApi.updateById(group.id, submitData);
} else {
await gatewayGroupApi.create(values);
await gatewayGroupApi.create(submitData);
}
message.success(t('general.success'));
onClose(true);
+13 -2
View File
@@ -43,6 +43,16 @@ const GatewayGroupPage: React.FC = () => {
}
};
const parseMembers = (members: string | undefined): any[] => {
if (!members) return [];
if (Array.isArray(members)) return members;
try {
return JSON.parse(members);
} catch {
return [];
}
};
const columns: ProColumns<GatewayGroup>[] = [
{
title: t('gateway_group.name'),
@@ -69,8 +79,9 @@ const GatewayGroupPage: React.FC = () => {
dataIndex: 'members',
width: 100,
render: (_, record) => {
const enabledCount = record.members?.filter(m => m.enabled).length || 0;
const totalCount = record.members?.length || 0;
const members = parseMembers(record.members as unknown as string);
const enabledCount = members.filter(m => m.enabled).length;
const totalCount = members.length;
return `${enabledCount}/${totalCount}`;
},
},
+20 -1
View File
@@ -194,7 +194,25 @@ const ScheduledTaskLogPage = ({open, jobId, handleCancel}: Props) => {
title: t('sysops.logs.result'),
dataIndex: 'result',
key: 'result',
valueType: 'code',
width: 200,
render: (text: string) => {
if (!text) return <Text type="secondary">-</Text>
return (
<div style={{
maxHeight: 60,
overflow: 'auto',
backgroundColor: '#f5f5f5',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}>
{text}
</div>
)
}
},
]
case 'renew-certificate':
@@ -244,6 +262,7 @@ const ScheduledTaskLogPage = ({open, jobId, handleCancel}: Props) => {
options={false}
dataSource={record.results}
pagination={false}
rowKey={(r: any, index: number) => r.name || r.id || index}
/>
}
+34 -33
View File
@@ -13,6 +13,7 @@ import {Col, Modal, Popover, Row} from "antd";
import scheduledTaskApi, {ScheduledTask} from "@/api/scheduled-task-api";
import assetApi from "@/api/asset-api";
import ScheduledTaskRuntime from "@/pages/sysops/ScheduledTaskRuntime";
import {useQuery} from "@tanstack/react-query";
const ScheduledTaskModal = ({
open,
@@ -23,13 +24,33 @@ const ScheduledTaskModal = ({
}: Props) => {
let {t} = useTranslation();
const formRef = useRef<ProFormInstance>(null);
let [spec, setSpec] = useState('');
let [runtimeOpen, setRuntimeOpen] = useState(false);
let [specForRuntime, setSpecForRuntime] = useState('');
const {data: assetTreeData} = useQuery({
queryKey: ['asset-tree', 'ssh'],
queryFn: async () => {
let items = await assetApi.tree('ssh');
function processItem(item: any) {
item.value = item.value || item.key || item.id;
item.title = item.title || item.name || '';
if (item.isLeaf && item.extra?.network) {
item.title = item.title + ' (' + item.extra.network + ')';
}
if (item.children && item.children.length > 0) {
item.children.forEach(processItem);
}
}
if (items && items.length > 0) {
items.forEach(processItem);
}
return items || [];
},
});
const get = async () => {
if (id) {
let data = await scheduledTaskApi.getById(id);
setSpec(data.spec);
return data
}
return {
@@ -38,6 +59,14 @@ const ScheduledTaskModal = ({
} as ScheduledTask;
}
const handleRuntimeOpenChange = (open: boolean) => {
if (open) {
const currentSpec = formRef.current?.getFieldValue('spec') || '';
setSpecForRuntime(currentSpec);
}
setRuntimeOpen(open);
}
return (
<Modal
@@ -82,20 +111,14 @@ const ScheduledTaskModal = ({
rules={[{required: true}]}
tooltip={t('sysops.spec_tooltip')}
fieldProps={{
value: spec,
onChange: (e) => {
setSpec(e.target.value);
},
addonAfter: <div className={'cursor-pointer'}>
<Popover
content={<ScheduledTaskRuntime open={runtimeOpen} spec={spec}/>}
content={runtimeOpen ? <ScheduledTaskRuntime open={runtimeOpen} spec={specForRuntime}/> : null}
title={t('sysops.spec_run_time')}
trigger="click"
placement="rightTop"
open={runtimeOpen}
onOpenChange={(open) => {
setRuntimeOpen(open);
}}
onOpenChange={handleRuntimeOpenChange}
>
{t('sysops.spec_run')}
</Popover>
@@ -135,34 +158,12 @@ const ScheduledTaskModal = ({
name='assetIdList'
rules={[{required: true}]}
fieldProps={{
treeData: assetTreeData || [],
multiple: true,
showSearch: true,
treeDefaultExpandAll: true,
treeNodeFilterProp: "title",
}}
request={async () => {
let items = await assetApi.tree('ssh');
// 递归把 key 字段设置为 value,并且非叶子节点全部 disabled
function setKeyAndDisabled(item: any) {
item.value = item.key;
if (!item.isLeaf) {
// item.disabled = true;
// 递归处理子节点
if (item.children) {
item.children.forEach(setKeyAndDisabled);
}
} else {
item.title = item.title + ' (' + item.extra?.network + ')';
}
}
// 对获取到的所有节点进行处理
items.forEach((item: any) => {
setKeyAndDisabled(item);
});
return items;
}}
/>
}
return <></>
+5 -11
View File
@@ -1,4 +1,4 @@
import React, {useEffect} from 'react';
import React from 'react';
import {useQuery} from "@tanstack/react-query";
import scheduledTaskApi from '@/api/scheduled-task-api';
@@ -14,26 +14,20 @@ const ScheduledTaskRuntime = ({open, spec}: Props) => {
queryFn: () => {
return scheduledTaskApi.getNextTenRuns(spec);
},
enabled: open,
enabled: open && !!spec,
retry: false,
});
useEffect(() => {
if (open) {
query.refetch();
}
}, [open]);
return (
<div className={''}>
{query.isError && <div className={'text-red-500'}>Error: {query.error?.message}</div>}
<div className={'space-y-1'}>
{Array.isArray(query.data) ? query.data.map((item: any) => {
return <div key={item.id}>{item}</div>
{Array.isArray(query.data) ? query.data.map((item: any, index: number) => {
return <div key={index}>{item}</div>
}) : []}
</div>
</div>
);
};
export default ScheduledTaskRuntime;
export default ScheduledTaskRuntime;
+8 -3
View File
@@ -23,6 +23,11 @@ const ToolsPing = () => {
setLogs((prevLogs) => [...prevLogs, event.data]);
};
eventSource.addEventListener('done', () => {
eventSource?.close();
setRunning(false);
});
eventSource.onerror = () => {
eventSource?.close();
setRunning(false);
@@ -67,11 +72,11 @@ const ToolsPing = () => {
</Form.Item>
</Form>
<div className='border rounded-lg p-4 flex-grow'>
<pre>{logs.join("\n")}</pre>
<div className='border rounded-lg p-4 flex-grow overflow-auto'>
<pre className='whitespace-pre-wrap break-all'>{logs.join("\n")}</pre>
</div>
</div>
);
};
export default ToolsPing;
export default ToolsPing;
+10 -5
View File
@@ -1,4 +1,4 @@
import {Button, Form, Input, InputNumber, Space} from 'antd';
import {Button, Form, Input, InputNumber} from 'antd';
import React, {useState} from 'react';
import {baseUrl, getToken} from "@/api/core/requests";
import {useTranslation} from "react-i18next";
@@ -15,7 +15,7 @@ const ToolsTcping = () => {
let eventSource: EventSource | null = null;
const onSearch = (host: string, port: number) => {
if (running) return; // 防止重复启动
if (running) return;
setRunning(true);
setLogs([]);
@@ -25,6 +25,11 @@ const ToolsTcping = () => {
setLogs((prevLogs) => [...prevLogs, event.data]);
};
eventSource.addEventListener('done', () => {
eventSource?.close();
setRunning(false);
});
eventSource.onerror = () => {
eventSource?.close();
setRunning(false);
@@ -79,11 +84,11 @@ const ToolsTcping = () => {
</Form.Item>
</Form>
<div className='border rounded-lg p-4 flex-grow'>
<pre>{logs.join("\n")}</pre>
<div className='border rounded-lg p-4 flex-grow overflow-auto'>
<pre className='whitespace-pre-wrap break-all'>{logs.join("\n")}</pre>
</div>
</div>
);
};
export default ToolsTcping;
export default ToolsTcping;
+98 -1
View File
@@ -50,7 +50,8 @@
"upload": "上传",
"offline": "离线",
"online": "在线",
"search_placeholder": "关键词搜索"
"search_placeholder": "关键词搜索",
"max_length": "最大长度为 {{max}} 个字符"
},
"actions": {
"new": "新建",
@@ -1834,5 +1835,101 @@
"403": "没有权限执行此操作",
"404": "请求的资源不存在",
"500": "服务器内部错误"
},
"permissions": {
"dashboard": "数据概览",
"asset-access": "访问资产",
"asset-add": "新增资产",
"asset-edit": "编辑资产",
"asset-del": "删除资产",
"asset-copy": "复制资产",
"asset-conn-test": "连接测试",
"asset-import": "导入资产",
"asset-change-owner": "修改所有者",
"asset-authorised-user-add": "授权用户",
"asset-authorised-user-del": "取消授权用户",
"asset-authorised-user-group-add": "授权用户组",
"asset-authorised-user-group-del": "取消授权用户组",
"credential-add": "新增凭证",
"credential-del": "删除凭证",
"credential-edit": "编辑凭证",
"command-add": "新增命令",
"command-edit": "编辑命令",
"command-del": "删除命令",
"command-exec": "执行命令",
"command-change-owner": "修改所有者",
"access-gateway-add": "新增网关",
"access-gateway-del": "删除网关",
"access-gateway-edit": "编辑网关",
"online-session-disconnect": "断开会话",
"online-session-monitor": "监控会话",
"offline-session-playback": "回放会话",
"offline-session-del": "删除会话",
"offline-session-clear": "清空会话",
"offline-session-command": "命令记录",
"offline-session-reviewed": "标记已阅",
"offline-session-unreviewed": "标记未读",
"offline-session-reviewed-all": "全部标记已阅",
"login-log-del": "删除登录日志",
"login-log-clear": "清空登录日志",
"storage-log-del": "删除文件日志",
"storage-log-clear": "清空文件日志",
"session-command": "命令日志",
"job-add": "新增任务",
"job-del": "删除任务",
"job-edit": "编辑任务",
"job-run": "执行任务",
"job-change-status": "修改状态",
"job-log": "任务日志",
"job-log-clear": "清空日志",
"storage-add": "新增存储",
"storage-del": "删除存储",
"storage-edit": "编辑存储",
"storage-browse-download": "下载文件",
"storage-browse-upload": "上传文件",
"storage-browse-mkdir": "创建目录",
"storage-browse-rm": "删除文件",
"storage-browse-rename": "重命名",
"storage-browse-edit": "编辑文件",
"monitoring": "系统监控",
"access-security-add": "新增规则",
"access-security-del": "删除规则",
"access-security-edit": "编辑规则",
"login-policy-add": "新增策略",
"login-policy-del": "删除策略",
"login-policy-edit": "编辑策略",
"login-policy-bind-user": "绑定用户",
"user-add": "新增用户",
"user-del": "删除用户",
"user-edit": "编辑用户",
"user-change-password": "修改密码",
"user-enable-disable": "启用/禁用",
"user-reset-totp": "重置OTP",
"user-bind-asset": "绑定资产",
"user-unbind-asset": "解绑资产",
"login-policy-unbind-user": "解绑用户",
"user-unbind-login-policy": "解绑策略",
"role-add": "新增角色",
"role-del": "删除角色",
"role-edit": "编辑角色",
"role-detail": "角色详情",
"user-group-add": "新增用户组",
"user-group-del": "删除用户组",
"user-group-edit": "编辑用户组",
"user-group-detail": "用户组详情",
"user-group-bind-asset": "绑定资产",
"user-group-unbind-asset": "解绑资产",
"command-filter-add": "新增拦截器",
"command-filter-del": "删除拦截器",
"command-filter-edit": "编辑拦截器",
"command-filter-rule-add": "新增规则",
"command-filter-rule-put": "修改规则",
"command-filter-rule-del": "删除规则",
"strategy-add": "新增策略",
"strategy-edit": "编辑策略",
"strategy-del": "删除策略",
"strategy-detail": "策略详情",
"setting": "系统设置",
"info": "系统信息"
}
}