mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
fix(notification): bound audit logs without breaking delivery
This commit is contained in:
@@ -66,9 +66,7 @@ const EditableTagGroup = ({
|
||||
}, [inputVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setTags(value);
|
||||
}
|
||||
setTags(value || []);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -29,10 +29,10 @@ const Terminal = ({
|
||||
const lastLineRef = useRef<null | HTMLElement>(null);
|
||||
|
||||
// An effect that handles scrolling into view the last line of terminal input or output
|
||||
const performScrolldown = useRef(false);
|
||||
const performScrollDown = useRef(false);
|
||||
useEffect(() => {
|
||||
if (performScrolldown.current) {
|
||||
// skip scrolldown when the component first loads
|
||||
if (performScrollDown.current) {
|
||||
// skip scrollDown when the component first loads
|
||||
setTimeout(
|
||||
() =>
|
||||
lastLineRef?.current?.scrollIntoView({
|
||||
@@ -42,7 +42,7 @@ const Terminal = ({
|
||||
500,
|
||||
);
|
||||
}
|
||||
performScrolldown.current = true;
|
||||
performScrollDown.current = true;
|
||||
}, [lineData.length]);
|
||||
|
||||
const renderedLineData = lineData.map((ld, i) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import intl from 'react-intl-universal';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { SettingOutlined, DashboardOutlined } from '@ant-design/icons';
|
||||
import IconFont from '@/components/iconfont';
|
||||
import { BasicLayoutProps } from '@ant-design/pro-layout';
|
||||
|
||||
@@ -24,6 +24,12 @@ export default {
|
||||
hideInMenu: true,
|
||||
component: '@/pages/error/index',
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: intl.get('仪表盘'),
|
||||
icon: <DashboardOutlined />,
|
||||
component: '@/pages/dashboard/index',
|
||||
},
|
||||
{
|
||||
path: '/crontab',
|
||||
name: intl.get('定时任务'),
|
||||
|
||||
@@ -212,6 +212,7 @@ body {
|
||||
&.crontab-wrapper,
|
||||
&.log-wrapper,
|
||||
&.env-wrapper,
|
||||
&.dashboard-wrapper,
|
||||
&.config-wrapper {
|
||||
.CodeMirror {
|
||||
width: calc(100vw - 24px);
|
||||
|
||||
+54
-10
@@ -22,9 +22,11 @@ import WebSocketManager from '../utils/websocket';
|
||||
export interface SharedContext {
|
||||
headerStyle: React.CSSProperties;
|
||||
isPhone: boolean;
|
||||
siteTitle: string;
|
||||
theme: 'vs' | 'vs-dark';
|
||||
user: any;
|
||||
reloadUser: (needLoading?: boolean) => void;
|
||||
reloadSystemConfig: () => void;
|
||||
reloadTheme: () => void;
|
||||
systemInfo: TSystemInfo;
|
||||
}
|
||||
@@ -45,6 +47,9 @@ export default function () {
|
||||
const [user, setUser] = useState<any>({});
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [systemInfo, setSystemInfo] = useState<TSystemInfo>();
|
||||
const [siteTitle, setSiteTitle] = useState(
|
||||
() => localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [initLoading, setInitLoading] = useState<boolean>(true);
|
||||
const {
|
||||
@@ -89,7 +94,7 @@ export default function () {
|
||||
if (code === 200 && data.username) {
|
||||
setUser(data);
|
||||
if (location.pathname === '/') {
|
||||
history.push('/crontab');
|
||||
history.push('/dashboard');
|
||||
}
|
||||
}
|
||||
needLoading && setLoading(false);
|
||||
@@ -109,13 +114,9 @@ export default function () {
|
||||
history.push('/error');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const responseStatus = error.response.status;
|
||||
if (responseStatus !== 401) {
|
||||
history.push('/error');
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
.catch(() => {
|
||||
// Health is anonymous; a failure must not trigger a page reload loop.
|
||||
history.push('/error');
|
||||
})
|
||||
.finally(() => setInitLoading(false));
|
||||
};
|
||||
@@ -124,6 +125,31 @@ export default function () {
|
||||
getUser(needLoading);
|
||||
};
|
||||
|
||||
const reloadSystemConfig = () => {
|
||||
request
|
||||
.get(`${config.apiPrefix}system/config`)
|
||||
.then(({ data }: any) => {
|
||||
const panelTitle = data?.info?.panelTitle?.trim();
|
||||
if (panelTitle) {
|
||||
setSiteTitle(panelTitle);
|
||||
localStorage.setItem('qinglong_panel_title', panelTitle);
|
||||
} else {
|
||||
setSiteTitle(intl.get('青龙'));
|
||||
localStorage.removeItem('qinglong_panel_title');
|
||||
}
|
||||
if (!data?.info?.lang) {
|
||||
const browserLang =
|
||||
localStorage.getItem('lang') ||
|
||||
navigator.language?.slice(0, 2) ||
|
||||
'zh';
|
||||
request
|
||||
.put(`${config.apiPrefix}system/config/lang`, { lang: browserLang })
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (systemInfo && systemInfo.isInitialized && !user) {
|
||||
getUser();
|
||||
@@ -134,6 +160,19 @@ export default function () {
|
||||
getHealthStatus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reloadSystemConfig();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!['/login', '/initialization', '/error'].includes(location.pathname)) {
|
||||
return;
|
||||
}
|
||||
const title =
|
||||
(config.documentTitleMap as any)[location.pathname] || intl.get('未找到');
|
||||
document.title = `${title} - ${siteTitle}`;
|
||||
}, [location.pathname, siteTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'vs-dark') {
|
||||
document.body.setAttribute('data-dark', 'true');
|
||||
@@ -201,7 +240,7 @@ export default function () {
|
||||
|
||||
if (['/login', '/initialization', '/error'].includes(location.pathname)) {
|
||||
if (systemInfo?.isInitialized && location.pathname === '/initialization') {
|
||||
history.push('/crontab');
|
||||
history.push('/dashboard');
|
||||
}
|
||||
|
||||
if (systemInfo || location.pathname === '/error') {
|
||||
@@ -212,7 +251,10 @@ export default function () {
|
||||
theme,
|
||||
user,
|
||||
reloadUser,
|
||||
reloadSystemConfig,
|
||||
reloadTheme,
|
||||
siteTitle,
|
||||
systemInfo,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -293,7 +335,7 @@ export default function () {
|
||||
const title =
|
||||
(config.documentTitleMap as any)[location.pathname] ||
|
||||
intl.get('未找到');
|
||||
return `${title} - ${intl.get('青龙')}`;
|
||||
return `${title} - ${siteTitle}`;
|
||||
}}
|
||||
onCollapse={setCollapsed}
|
||||
collapsed={collapsed}
|
||||
@@ -355,7 +397,9 @@ export default function () {
|
||||
theme,
|
||||
user,
|
||||
reloadUser,
|
||||
reloadSystemConfig,
|
||||
reloadTheme,
|
||||
siteTitle,
|
||||
systemInfo,
|
||||
}}
|
||||
/>
|
||||
|
||||
+674
-525
File diff suppressed because it is too large
Load Diff
+672
-523
File diff suppressed because it is too large
Load Diff
+199
-95
@@ -3,8 +3,6 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
message,
|
||||
Input,
|
||||
Form,
|
||||
Button,
|
||||
Card,
|
||||
Tag,
|
||||
@@ -22,6 +20,7 @@ import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
FullscreenOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { CrontabStatus } from './type';
|
||||
import { diffTime } from '@/utils/date';
|
||||
@@ -39,8 +38,8 @@ const { Text } = Typography;
|
||||
|
||||
const tabList = [
|
||||
{
|
||||
key: 'log',
|
||||
tab: intl.get('日志'),
|
||||
key: 'runningHistory',
|
||||
tab: intl.get('运行历史'),
|
||||
},
|
||||
{
|
||||
key: 'script',
|
||||
@@ -48,11 +47,6 @@ const tabList = [
|
||||
},
|
||||
];
|
||||
|
||||
interface LogItem {
|
||||
directory: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
const CronDetailModal = ({
|
||||
cron = {},
|
||||
handleCancel,
|
||||
@@ -64,40 +58,49 @@ const CronDetailModal = ({
|
||||
theme: string;
|
||||
isPhone: boolean;
|
||||
}) => {
|
||||
const [activeTabKey, setActiveTabKey] = useState('log');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [logs, setLogs] = useState<LogItem[]>([]);
|
||||
const [log, setLog] = useState('');
|
||||
const [activeTabKey, setActiveTabKey] = useState('runningHistory');
|
||||
const [value, setValue] = useState('');
|
||||
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
|
||||
const editorRef = useRef<any>(null);
|
||||
const [scriptInfo, setScriptInfo] = useState<any>({});
|
||||
const [logUrl, setLogUrl] = useState('');
|
||||
const [validTabs, setValidTabs] = useState(tabList);
|
||||
const [currentCron, setCurrentCron] = useState<any>({});
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const tableScrollHeight = useScrollHeight(listRef);
|
||||
const [runningInstances, setRunningInstances] = useState<any[]>([]);
|
||||
const needRefreshRef = useRef(false);
|
||||
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
|
||||
const [logData, setLogData] = useState('');
|
||||
const [logUrl, setLogUrl] = useState('');
|
||||
|
||||
const fetchRunningInstances = async () => {
|
||||
if (!cron.id) return Promise.resolve();
|
||||
return request
|
||||
.get(`${config.apiPrefix}crons/${cron.id}/instances`)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200 && data) {
|
||||
setRunningInstances(data);
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRunningInstances();
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
await fetchRunningInstances();
|
||||
if (cancelled) return;
|
||||
timer = setTimeout(poll, 10000);
|
||||
};
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [cron.id]);
|
||||
|
||||
const contentList: any = {
|
||||
log: (
|
||||
<div ref={listRef}>
|
||||
<List>
|
||||
<VirtualList
|
||||
data={logs}
|
||||
height={tableScrollHeight}
|
||||
itemHeight={47}
|
||||
itemKey="filename"
|
||||
>
|
||||
{(item) => (
|
||||
<List.Item className="log-item" onClick={() => onClickItem(item)}>
|
||||
<FileOutlined style={{ marginRight: 10 }} />
|
||||
{item.directory}/{item.filename}
|
||||
</List.Item>
|
||||
)}
|
||||
</VirtualList>
|
||||
</List>
|
||||
</div>
|
||||
),
|
||||
script: scriptInfo.filename && (
|
||||
<Editor
|
||||
language={getEditorMode(scriptInfo.filename)}
|
||||
@@ -115,17 +118,137 @@ const CronDetailModal = ({
|
||||
}}
|
||||
/>
|
||||
),
|
||||
runningHistory: (
|
||||
<div ref={listRef}>
|
||||
<List>
|
||||
<VirtualList
|
||||
data={runningInstances}
|
||||
height={tableScrollHeight}
|
||||
itemHeight={47}
|
||||
itemKey="id"
|
||||
>
|
||||
{(item) => (
|
||||
<List.Item
|
||||
className="log-item"
|
||||
onClick={() => item.log_path && viewInstanceLog(item)}
|
||||
style={{ cursor: item.log_path ? 'pointer' : 'default' }}
|
||||
actions={[
|
||||
item.log_path && (
|
||||
<Tooltip title={intl.get('查看日志')} key="log">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<FileOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
viewInstanceLog(item);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
item.status === 0 && (
|
||||
<Tooltip title={intl.get('停止')} key="stop">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<StopOutlined />}
|
||||
danger
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
stopRunningInstance(item);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
].filter(Boolean)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<span>
|
||||
{item.log_path && (
|
||||
<span style={{ marginRight: 8 }}>
|
||||
{item.log_path.split('/').pop()}
|
||||
</span>
|
||||
)}
|
||||
{item.status === 0 && (
|
||||
<Tag icon={<Loading3QuartersOutlined spin />} color="processing">
|
||||
{intl.get('运行中')}
|
||||
</Tag>
|
||||
)}
|
||||
{item.status === 1 && (
|
||||
<Tag color="success">{intl.get('已完成')}</Tag>
|
||||
)}
|
||||
{item.status === 2 && (
|
||||
<Tag color="default">{intl.get('已停止')}</Tag>
|
||||
)}
|
||||
{item.status === 3 && (
|
||||
<Tag color="error">{intl.get('错误')}</Tag>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span style={{ color: '#999' }}>
|
||||
{item.status === 0 && item.pid && (
|
||||
<span>
|
||||
PID: {item.pid}{' | '}
|
||||
</span>
|
||||
)}
|
||||
{intl.get('启动')}: {dayjs.unix(item.started_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
{item.finished_at && (
|
||||
<span>
|
||||
{' | '}{intl.get('结束')}: {dayjs.unix(item.finished_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</span>
|
||||
)}
|
||||
{item.exit_code !== undefined && item.exit_code !== null && (
|
||||
<span>
|
||||
{' | '}{intl.get('退出码')}: {item.exit_code}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
</VirtualList>
|
||||
</List>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const onClickItem = (item: LogItem) => {
|
||||
const url = `${config.apiPrefix}logs/detail?file=${item.filename}&path=${
|
||||
item.directory || ''
|
||||
}`;
|
||||
const stopRunningInstance = (instance: any) => {
|
||||
Modal.confirm({
|
||||
title: intl.get('确认停止实例'),
|
||||
content: (
|
||||
<>
|
||||
{intl.get('确认停止运行实例')} PID: {instance.pid}{' '}
|
||||
{intl.get('吗')}
|
||||
</>
|
||||
),
|
||||
onOk() {
|
||||
request
|
||||
.post(`${config.apiPrefix}crons/${cron.id}/instances/${instance.id}/stop`)
|
||||
.then(({ code }) => {
|
||||
if (code === 200) {
|
||||
message.success(intl.get('实例已停止'));
|
||||
needRefreshRef.current = true;
|
||||
fetchRunningInstances();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const viewInstanceLog = (instance: any) => {
|
||||
if (!instance.log_path) return;
|
||||
const parts = instance.log_path.split('/');
|
||||
const filename = parts.pop() || '';
|
||||
const directory = parts.join('/');
|
||||
const url = `${config.apiPrefix}logs/detail?file=${filename}&path=${directory}`;
|
||||
localStorage.setItem('logCron', url);
|
||||
setLogUrl(url);
|
||||
request.get(url).then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
setLog(data);
|
||||
setLogData(data);
|
||||
setIsLogModalVisible(true);
|
||||
}
|
||||
});
|
||||
@@ -135,22 +258,9 @@ const CronDetailModal = ({
|
||||
setActiveTabKey(key);
|
||||
};
|
||||
|
||||
const getLogs = () => {
|
||||
setLoading(true);
|
||||
request
|
||||
.get(`${config.apiPrefix}crons/${cron.id}/logs`)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
setLogs(data);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const getScript = () => {
|
||||
const result = getCommandScript(cron.command);
|
||||
if (Array.isArray(result)) {
|
||||
setValidTabs(validTabs);
|
||||
const [s, p] = result;
|
||||
setScriptInfo({ parent: p, filename: s });
|
||||
request
|
||||
@@ -161,14 +271,14 @@ const CronDetailModal = ({
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setValidTabs([validTabs[0]]);
|
||||
setActiveTabKey('log');
|
||||
setValidTabs([{ key: 'runningHistory', tab: intl.get('运行历史') }]);
|
||||
setActiveTabKey('runningHistory');
|
||||
}
|
||||
};
|
||||
|
||||
const saveFile = () => {
|
||||
Modal.confirm({
|
||||
title: `确认保存`,
|
||||
title: intl.get('确认保存'),
|
||||
content: (
|
||||
<>
|
||||
{intl.get('确认保存文件')}
|
||||
@@ -193,7 +303,7 @@ const CronDetailModal = ({
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
setValue(content);
|
||||
message.success(`保存成功`);
|
||||
message.success(intl.get('保存成功'));
|
||||
}
|
||||
resolve(null);
|
||||
})
|
||||
@@ -221,9 +331,7 @@ const CronDetailModal = ({
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
setCurrentCron({ ...currentCron, status: CrontabStatus.running });
|
||||
setTimeout(() => {
|
||||
getLogs();
|
||||
}, 1000);
|
||||
fetchRunningInstances();
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -256,9 +364,8 @@ const CronDetailModal = ({
|
||||
|
||||
const enabledOrDisabledCron = () => {
|
||||
Modal.confirm({
|
||||
title: `确认${
|
||||
currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
|
||||
}`,
|
||||
title: `确认${currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
|
||||
}`,
|
||||
content: (
|
||||
<>
|
||||
{intl.get('确认')}
|
||||
@@ -273,8 +380,7 @@ const CronDetailModal = ({
|
||||
onOk() {
|
||||
request
|
||||
.put(
|
||||
`${config.apiPrefix}crons/${
|
||||
currentCron.isDisabled === 1 ? 'enable' : 'disable'
|
||||
`${config.apiPrefix}crons/${currentCron.isDisabled === 1 ? 'enable' : 'disable'
|
||||
}`,
|
||||
[currentCron.id],
|
||||
)
|
||||
@@ -292,9 +398,8 @@ const CronDetailModal = ({
|
||||
|
||||
const pinOrUnPinCron = () => {
|
||||
Modal.confirm({
|
||||
title: `确认${
|
||||
currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
|
||||
}`,
|
||||
title: `确认${currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
|
||||
}`,
|
||||
content: (
|
||||
<>
|
||||
{intl.get('确认')}
|
||||
@@ -309,8 +414,7 @@ const CronDetailModal = ({
|
||||
onOk() {
|
||||
request
|
||||
.put(
|
||||
`${config.apiPrefix}crons/${
|
||||
currentCron.isPinned === 1 ? 'unpin' : 'pin'
|
||||
`${config.apiPrefix}crons/${currentCron.isPinned === 1 ? 'unpin' : 'pin'
|
||||
}`,
|
||||
[currentCron.id],
|
||||
)
|
||||
@@ -334,7 +438,6 @@ const CronDetailModal = ({
|
||||
useEffect(() => {
|
||||
if (cron && cron.id) {
|
||||
setCurrentCron(cron);
|
||||
getLogs();
|
||||
getScript();
|
||||
}
|
||||
}, [cron]);
|
||||
@@ -441,7 +544,7 @@ const CronDetailModal = ({
|
||||
open={true}
|
||||
forceRender
|
||||
footer={false}
|
||||
onCancel={() => handleCancel()}
|
||||
onCancel={() => handleCancel(needRefreshRef.current)}
|
||||
wrapClassName="crontab-detail"
|
||||
width={!isPhone ? '80vw' : ''}
|
||||
>
|
||||
@@ -458,27 +561,27 @@ const CronDetailModal = ({
|
||||
<div className="cron-detail-info-value">
|
||||
{(!currentCron.isDisabled ||
|
||||
currentCron.status !== CrontabStatus.idle) && (
|
||||
<>
|
||||
{currentCron.status === CrontabStatus.idle && (
|
||||
<Tag icon={<ClockCircleOutlined />} color="default">
|
||||
{intl.get('空闲中')}
|
||||
</Tag>
|
||||
)}
|
||||
{currentCron.status === CrontabStatus.running && (
|
||||
<Tag
|
||||
icon={<Loading3QuartersOutlined spin />}
|
||||
color="processing"
|
||||
>
|
||||
{intl.get('运行中')}
|
||||
</Tag>
|
||||
)}
|
||||
{currentCron.status === CrontabStatus.queued && (
|
||||
<Tag icon={<FieldTimeOutlined />} color="default">
|
||||
{intl.get('队列中')}
|
||||
</Tag>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
{currentCron.status === CrontabStatus.idle && (
|
||||
<Tag icon={<ClockCircleOutlined />} color="default">
|
||||
{intl.get('空闲中')}
|
||||
</Tag>
|
||||
)}
|
||||
{currentCron.status === CrontabStatus.running && (
|
||||
<Tag
|
||||
icon={<Loading3QuartersOutlined spin />}
|
||||
color="processing"
|
||||
>
|
||||
{intl.get('运行中')}
|
||||
</Tag>
|
||||
)}
|
||||
{currentCron.status === CrontabStatus.queued && (
|
||||
<Tag icon={<FieldTimeOutlined />} color="default">
|
||||
{intl.get('队列中')}
|
||||
</Tag>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentCron.isDisabled === 1 &&
|
||||
currentCron.status === CrontabStatus.idle && (
|
||||
<Tag icon={<CloseCircleOutlined />} color="error">
|
||||
@@ -503,8 +606,8 @@ const CronDetailModal = ({
|
||||
<div className="cron-detail-info-value">
|
||||
{currentCron.last_execution_time
|
||||
? dayjs(currentCron.last_execution_time * 1000).format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -561,9 +664,10 @@ const CronDetailModal = ({
|
||||
<CronLogModal
|
||||
handleCancel={() => {
|
||||
setIsLogModalVisible(false);
|
||||
fetchRunningInstances();
|
||||
}}
|
||||
cron={cron}
|
||||
data={log}
|
||||
data={logData}
|
||||
logUrl={logUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -365,9 +365,9 @@ const Crontab = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getCrons = () => {
|
||||
setLoading(true);
|
||||
const { page, size, sorter, filters } = pageConf;
|
||||
const getCrons = async (silent?: boolean) => {
|
||||
if (!silent) setLoading(true);
|
||||
const { page = 1, size = 20, sorter, filters = {} } = pageConf;
|
||||
let url = `${config.apiPrefix
|
||||
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify(
|
||||
filters,
|
||||
@@ -385,7 +385,7 @@ const Crontab = () => {
|
||||
filterRelation: viewConf.filterRelation || 'and',
|
||||
})}`;
|
||||
}
|
||||
request
|
||||
return request
|
||||
.get(url)
|
||||
.then(async ({ code, data: _data }) => {
|
||||
if (code === 200) {
|
||||
@@ -419,9 +419,24 @@ const Crontab = () => {
|
||||
setTotal(total);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
.finally(() => !silent && setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
await getCrons(true);
|
||||
if (cancelled) return;
|
||||
timer = setTimeout(poll, 10000);
|
||||
};
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [pageConf, viewConf, searchText]);
|
||||
|
||||
const addCron = () => {
|
||||
setEditedCron(null as any);
|
||||
setIsModalVisible(true);
|
||||
@@ -809,7 +824,7 @@ const Crontab = () => {
|
||||
setAllSubscriptions(data || []);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1077,8 +1092,11 @@ const Crontab = () => {
|
||||
)}
|
||||
{isDetailModalVisible && (
|
||||
<CronDetailModal
|
||||
handleCancel={() => {
|
||||
handleCancel={(needUpdate?: boolean) => {
|
||||
setIsDetailModalVisible(false);
|
||||
if (needUpdate) {
|
||||
getCrons();
|
||||
}
|
||||
}}
|
||||
cron={detailCron}
|
||||
theme={theme}
|
||||
|
||||
@@ -16,11 +16,12 @@ import {
|
||||
CheckCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { PageLoading } from "@ant-design/pro-layout";
|
||||
import { logEnded } from "@/utils";
|
||||
import { CrontabStatus } from "./type";
|
||||
import Ansi from "ansi-to-react";
|
||||
|
||||
const { Countdown } = Statistic;
|
||||
const LOG_CHUNK_BYTES = 256 * 1024;
|
||||
const MAX_LOG_VIEW_CHARS = 1024 * 1024;
|
||||
|
||||
const CronLogModal = ({
|
||||
cron,
|
||||
@@ -38,37 +39,60 @@ const CronLogModal = ({
|
||||
const [executing, setExecuting] = useState<any>(true);
|
||||
const [isPhone, setIsPhone] = useState(false);
|
||||
const scrollInfoRef = useRef({ value: 0, down: true });
|
||||
const logOffsetRef = useRef<number>();
|
||||
const valueRef = useRef(value);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const uniqPath = logUrl ? logUrl : String(cron?.id);
|
||||
|
||||
const getCronLog = (isFirst?: boolean) => {
|
||||
if (isFirst) {
|
||||
setLoading(true);
|
||||
}
|
||||
const baseUrl = logUrl ? logUrl : `${config.apiPrefix}crons/${cron.id}/log`;
|
||||
const separator = baseUrl.includes("?") ? "&" : "?";
|
||||
const offset = isFirst ? undefined : logOffsetRef.current;
|
||||
const pagination = `${separator}limit=${LOG_CHUNK_BYTES}${
|
||||
isFirst ? "&tail=true" : offset !== undefined ? `&offset=${offset}` : ""
|
||||
}`;
|
||||
request
|
||||
.get(logUrl ? logUrl : `${config.apiPrefix}crons/${cron.id}/log`)
|
||||
.then(({ code, data }) => {
|
||||
if (
|
||||
code === 200 &&
|
||||
localStorage.getItem("logCron") === uniqPath &&
|
||||
data !== value
|
||||
) {
|
||||
const log = data as string;
|
||||
setValue(log || intl.get("暂无日志"));
|
||||
const hasNext = Boolean(
|
||||
log && !logEnded(log) && !log.includes("日志不存在") && !log.includes("日志设置为忽略"),
|
||||
.get(`${baseUrl}${pagination}`)
|
||||
.then(({ code, data, logStatus, nextOffset, total }) => {
|
||||
if (code !== 200 || localStorage.getItem("logCron") !== uniqPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isRunning = logStatus === "running";
|
||||
// A completed process can still have multiple unread chunks. Drain
|
||||
// them before stopping, without presenting it as a running task.
|
||||
const hasUnread =
|
||||
typeof nextOffset === "number" &&
|
||||
typeof total === "number" &&
|
||||
nextOffset > (offset ?? 0) &&
|
||||
nextOffset < total;
|
||||
const hasNext = isRunning || hasUnread;
|
||||
const chunk = (data as string) || "";
|
||||
let log = isFirst ? chunk : `${valueRef.current}${chunk}`;
|
||||
if (!log && !hasNext) {
|
||||
log = intl.get("暂无日志");
|
||||
}
|
||||
if (log.length > MAX_LOG_VIEW_CHARS) {
|
||||
log = log.slice(-MAX_LOG_VIEW_CHARS);
|
||||
}
|
||||
valueRef.current = log;
|
||||
setValue(log);
|
||||
if (typeof nextOffset === "number") {
|
||||
logOffsetRef.current = nextOffset;
|
||||
}
|
||||
setExecuting(isRunning);
|
||||
|
||||
if (chunk || !hasNext) {
|
||||
autoScroll();
|
||||
}
|
||||
if (hasNext) {
|
||||
pollTimerRef.current = setTimeout(
|
||||
() => getCronLog(),
|
||||
hasUnread ? 0 : 2000,
|
||||
);
|
||||
if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) {
|
||||
setTimeout(() => {
|
||||
autoScroll();
|
||||
});
|
||||
}
|
||||
setExecuting(hasNext);
|
||||
if (hasNext) {
|
||||
setTimeout(() => {
|
||||
autoScroll();
|
||||
getCronLog();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -91,6 +115,9 @@ const CronLogModal = ({
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
}
|
||||
localStorage.removeItem("logCron");
|
||||
handleCancel();
|
||||
};
|
||||
@@ -119,12 +146,19 @@ const CronLogModal = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (cron && cron.id) {
|
||||
logOffsetRef.current = undefined;
|
||||
getCronLog(true);
|
||||
}
|
||||
return () => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [cron]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
valueRef.current = data;
|
||||
setValue(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
@@ -28,6 +28,7 @@ const CronModal = ({
|
||||
const method = cron?.id ? 'put' : 'post';
|
||||
const payload = {
|
||||
...values,
|
||||
labels: values.labels || [],
|
||||
schedule:
|
||||
scheduleType !== ScheduleType.Normal
|
||||
? scheduleTypeMap[scheduleType]
|
||||
@@ -211,12 +212,14 @@ const CronModal = ({
|
||||
return Promise.reject(intl.get('日志名称不能超过100个字符'));
|
||||
}
|
||||
if (
|
||||
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
|
||||
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w\p{Script=Han}.-]+\/)*[\w\p{Script=Han}.-]+\/?$/u.test(
|
||||
value,
|
||||
)
|
||||
) {
|
||||
return Promise.reject(
|
||||
intl.get('日志名称只能包含字母、数字、下划线和连字符'),
|
||||
intl.get(
|
||||
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符',
|
||||
),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
@@ -229,6 +232,15 @@ const CronModal = ({
|
||||
maxLength={200}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="work_dir"
|
||||
label={intl.get('工作目录')}
|
||||
tooltip={intl.get(
|
||||
'脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径',
|
||||
)}
|
||||
>
|
||||
<Input placeholder={intl.get('留空自动检测,或输入相对/绝对路径')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="task_before"
|
||||
label={intl.get('执行前')}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export enum CrontabStatus {
|
||||
'running' = 0,
|
||||
'queued' = 0.5,
|
||||
'idle' = 1,
|
||||
'disabled',
|
||||
'disabled' = 2,
|
||||
'queued' = 3,
|
||||
}
|
||||
|
||||
export enum OperationName {
|
||||
|
||||
@@ -76,6 +76,7 @@ const ViewCreateModal = ({
|
||||
{ name: intl.get('运行中'), value: CrontabStatus.running },
|
||||
{ name: intl.get('空闲中'), value: CrontabStatus.idle },
|
||||
{ name: intl.get('已禁用'), value: CrontabStatus.disabled },
|
||||
{ name: intl.get('排队中'), value: CrontabStatus.queued },
|
||||
],
|
||||
sub_id: data?.data.map((x) => ({ name: x.name, value: x.id })),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, Col, Row, Statistic, Table, Tag, Spin, Empty } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ThunderboltOutlined,
|
||||
StopOutlined,
|
||||
BarChartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { PageContainer } from '@ant-design/pro-layout';
|
||||
import { useOutletContext } from '@umijs/max';
|
||||
import { Area, Gauge } from '@ant-design/plots';
|
||||
import intl from 'react-intl-universal';
|
||||
import { SharedContext } from '@/layouts';
|
||||
import { request } from '@/utils/http';
|
||||
import config from '@/utils/config';
|
||||
import CronLogModal from '../crontab/logModal';
|
||||
import TaskResultModal from './taskResultModal';
|
||||
|
||||
interface Overview {
|
||||
total: number;
|
||||
enabled: number;
|
||||
disabled: number;
|
||||
todayRuns: number;
|
||||
todaySuccess: number;
|
||||
todayFail: number;
|
||||
successRate: string;
|
||||
avgTime: number;
|
||||
}
|
||||
|
||||
interface TrendItem {
|
||||
date: string;
|
||||
total: number;
|
||||
success: number;
|
||||
fail: number;
|
||||
}
|
||||
|
||||
interface TopItem {
|
||||
rank: number;
|
||||
name: string;
|
||||
avgTime?: number;
|
||||
maxTime?: number;
|
||||
runCount?: number;
|
||||
successRate?: string;
|
||||
}
|
||||
|
||||
interface Runtime {
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
running: Array<{ instanceId: number; id: number; name: string; pid: number; elapsed: number; logPath: string }>;
|
||||
idleTasks: Array<{ id: number; name: string; lastRun: string }>;
|
||||
}
|
||||
|
||||
interface SystemInfo {
|
||||
platform: string;
|
||||
uptime: number;
|
||||
memTotal: number;
|
||||
memFree: number;
|
||||
memUsagePercent: string;
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
loadAvg: number[];
|
||||
cpus: number;
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatSeconds = (s: number) => {
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
const REFRESH_INTERVAL = 30000;
|
||||
|
||||
const Dashboard = () => {
|
||||
const { headerStyle, theme } = useOutletContext<SharedContext>();
|
||||
const isDark = theme === 'vs-dark';
|
||||
const [overview, setOverview] = useState<Overview | null>(null);
|
||||
const [trend, setTrend] = useState<TrendItem[]>([]);
|
||||
const [topTime, setTopTime] = useState<TopItem[]>([]);
|
||||
const [topCount, setTopCount] = useState<TopItem[]>([]);
|
||||
const [runtime, setRuntime] = useState<Runtime | null>(null);
|
||||
const [system, setSystem] = useState<SystemInfo | null>(null);
|
||||
const [labels, setLabels] = useState<any[]>([]);
|
||||
const [logCron, setLogCron] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [result, setResult] = useState<'success' | 'failure' | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [overviewRes, trendRes, topTimeRes, topCountRes, runtimeRes, systemRes, labelsRes] =
|
||||
await Promise.allSettled([
|
||||
request.get(`${config.apiPrefix}dashboard/overview`),
|
||||
request.get(`${config.apiPrefix}dashboard/trend`),
|
||||
request.get(`${config.apiPrefix}dashboard/top-time`),
|
||||
request.get(`${config.apiPrefix}dashboard/top-count`),
|
||||
request.get(`${config.apiPrefix}dashboard/runtime`),
|
||||
request.get(`${config.apiPrefix}dashboard/system`),
|
||||
request.get(`${config.apiPrefix}dashboard/labels`),
|
||||
]);
|
||||
|
||||
if (overviewRes.status === 'fulfilled' && overviewRes.value.code === 200)
|
||||
setOverview(overviewRes.value.data);
|
||||
if (trendRes.status === 'fulfilled' && trendRes.value.code === 200)
|
||||
setTrend(trendRes.value.data);
|
||||
if (topTimeRes.status === 'fulfilled' && topTimeRes.value.code === 200)
|
||||
setTopTime(topTimeRes.value.data);
|
||||
if (topCountRes.status === 'fulfilled' && topCountRes.value.code === 200)
|
||||
setTopCount(topCountRes.value.data);
|
||||
if (runtimeRes.status === 'fulfilled' && runtimeRes.value.code === 200)
|
||||
setRuntime(runtimeRes.value.data);
|
||||
if (systemRes.status === 'fulfilled' && systemRes.value.code === 200)
|
||||
setSystem(systemRes.value.data);
|
||||
if (labelsRes.status === 'fulfilled' && labelsRes.value.code === 200)
|
||||
setLabels(labelsRes.value.data);
|
||||
} catch (e) {
|
||||
console.error('[dashboard] fetch error', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const poll = async () => {
|
||||
await fetchData();
|
||||
timer = setTimeout(poll, REFRESH_INTERVAL);
|
||||
};
|
||||
fetchData();
|
||||
timer = setTimeout(poll, REFRESH_INTERVAL);
|
||||
return () => clearTimeout(timer);
|
||||
}, [fetchData]);
|
||||
|
||||
const trendConfig = {
|
||||
data: trend.flatMap((d) => [
|
||||
{ date: d.date, value: d.total, type: intl.get('总执行') },
|
||||
{ date: d.date, value: d.success, type: intl.get('成功') },
|
||||
{ date: d.date, value: d.fail, type: intl.get('失败') },
|
||||
]),
|
||||
xField: 'date',
|
||||
yField: 'value',
|
||||
seriesField: 'type',
|
||||
smooth: true,
|
||||
height: 260,
|
||||
color: ['#1677ff', '#52c41a', '#ff4d4f'],
|
||||
legend: { position: 'top' as const },
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
xAxis: {
|
||||
label: { style: { fill: isDark ? '#aaa' : '#333' } },
|
||||
},
|
||||
yAxis: {
|
||||
label: { style: { fill: isDark ? '#aaa' : '#333' } },
|
||||
grid: { line: { style: { stroke: isDark ? '#333' : '#eee' } } },
|
||||
},
|
||||
};
|
||||
|
||||
const runtimePagination = {
|
||||
pageSize: 5,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total: number) => `共 ${total} 个`,
|
||||
};
|
||||
|
||||
if (loading) return <Spin size="large" style={{ display: 'block', marginTop: 100 }} />;
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={intl.get('仪表盘')}
|
||||
header={{ style: headerStyle }}
|
||||
className={'ql-container-wrapper dashboard-wrapper'}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={8} md={6} lg={3}>
|
||||
<Card size="small"><Statistic title={intl.get('总任务')} value={overview?.total || 0} prefix={<BarChartOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={3}>
|
||||
<Card size="small"><Statistic title={intl.get('已启用')} value={overview?.enabled || 0} valueStyle={{ color: '#1677ff' }} prefix={<CheckCircleOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={3}>
|
||||
<Card size="small"><Statistic title={intl.get('今日执行')} value={overview?.todayRuns || 0} valueStyle={{ color: '#1677ff' }} prefix={<ThunderboltOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={3}>
|
||||
<Card size="small"><Statistic title={intl.get('成功率')} value={`${overview?.successRate || '0'}%`} valueStyle={{ color: '#52c41a' }} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={3}>
|
||||
<Card size="small" hoverable role="button" tabIndex={0}
|
||||
aria-label={intl.get('今日成功')}
|
||||
onClick={() => setResult('success')}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
setResult('success');
|
||||
}
|
||||
}}
|
||||
><Statistic title={intl.get('今日成功')} value={overview?.todaySuccess || 0} valueStyle={{ color: '#52c41a' }} prefix={<CheckCircleOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={3}>
|
||||
<Card size="small" hoverable role="button" tabIndex={0}
|
||||
aria-label={intl.get('今日失败')}
|
||||
onClick={() => setResult('failure')}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
setResult('failure');
|
||||
}
|
||||
}}
|
||||
><Statistic title={intl.get('今日失败')} value={overview?.todayFail || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<CloseCircleOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={3}>
|
||||
<Card size="small"><Statistic title={intl.get('平均耗时')} value={overview?.avgTime ? `${(overview.avgTime / 1000).toFixed(1)}s` : '-'} prefix={<ClockCircleOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={3}>
|
||||
<Card size="small"><Statistic title={intl.get('已禁用')} value={overview?.disabled || 0} prefix={<StopOutlined />} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={24}>
|
||||
<Card title={intl.get('近 7 日趋势')} size="small">
|
||||
{trend.length > 0 ? <Area {...trendConfig} /> : <Empty description={intl.get('暂无数据')} />}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title={intl.get('今日耗时 Top 5')} size="small">
|
||||
<Table
|
||||
dataSource={topTime}
|
||||
rowKey="rank"
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: '#', dataIndex: 'rank', width: 40 },
|
||||
{ title: intl.get('定时任务'), dataIndex: 'name', ellipsis: true },
|
||||
{ title: intl.get('平均耗时'), dataIndex: 'avgTime', width: 100, render: (v: number) => v ? `${(v / 1000).toFixed(1)}s` : '-' },
|
||||
{ title: intl.get('最长单次'), dataIndex: 'maxTime', width: 100, render: (v: number) => v ? `${(v / 1000).toFixed(1)}s` : '-' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title={intl.get('今日执行次数 Top 5')} size="small">
|
||||
<Table
|
||||
dataSource={topCount}
|
||||
rowKey="rank"
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: '#', dataIndex: 'rank', width: 40 },
|
||||
{ title: intl.get('定时任务'), dataIndex: 'name', ellipsis: true },
|
||||
{ title: intl.get('次数'), dataIndex: 'runCount', width: 60 },
|
||||
{ title: intl.get('平均耗时'), dataIndex: 'avgTime', width: 100, render: (v: number) => v ? `${(v / 1000).toFixed(1)}s` : '-' },
|
||||
{ title: intl.get('成功率'), dataIndex: 'successRate', width: 80, render: (v: string) => `${v}%` },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{labels.length > 0 && (
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={24}>
|
||||
<Card title={intl.get('标签统计')} size="small">
|
||||
<Table
|
||||
dataSource={labels}
|
||||
rowKey="label"
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: intl.get('标签'), dataIndex: 'label', width: 150, render: (v: string) => <Tag>{v}</Tag> },
|
||||
{ title: intl.get('任务数'), dataIndex: 'count', width: 80 },
|
||||
{ title: intl.get('今日执行'), dataIndex: 'todayRuns', width: 100 },
|
||||
{ title: intl.get('成功率'), dataIndex: 'successRate', width: 100, render: (v: string) => `${v}%` },
|
||||
{ title: intl.get('平均耗时'), dataIndex: 'avgTime', width: 120, render: (v: number) => v ? `${(v / 1000).toFixed(1)}s` : '-' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={intl.get('实时运行态')}
|
||||
size="small"
|
||||
extra={
|
||||
<>
|
||||
<Tag color="processing">{intl.get('运行中')} {runtime?.runningCount || 0}</Tag>
|
||||
<Tag color="warning">{intl.get('排队中')} {runtime?.queuedCount || 0}</Tag>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
dataSource={runtime?.running || []}
|
||||
rowKey="instanceId"
|
||||
pagination={runtime && runtime.running.length > 5 ? runtimePagination : false}
|
||||
size="small"
|
||||
locale={{ emptyText: <Empty description={intl.get('暂无运行中任务')} /> }}
|
||||
columns={[
|
||||
{ title: intl.get('定时任务'), dataIndex: 'name', ellipsis: true, render: (name: string, record) => {
|
||||
const sameTaskCount = (runtime?.running || []).filter(r => r.id === record.id).length;
|
||||
return sameTaskCount > 1 ? <span>{name} <Tag color="processing" style={{ fontSize: 10, lineHeight: '16px' }}>×{sameTaskCount}</Tag></span> : name;
|
||||
} },
|
||||
{ title: 'PID', dataIndex: 'pid', width: 80 },
|
||||
{ title: intl.get('已运行'), dataIndex: 'elapsed', width: 100, render: (v: number) => v ? formatSeconds(v) : '-' },
|
||||
{ title: intl.get('日志'), dataIndex: 'id', width: 60, render: (id, record) => <a onClick={() => { localStorage.setItem('logCron', String(id)); setLogCron({ id, name: record.name }); }}>{intl.get('查看')}</a> },
|
||||
]}
|
||||
/>
|
||||
{runtime?.idleTasks && runtime.idleTasks.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginTop: 12, fontSize: 13, color: '#ff7a00' }}>
|
||||
{intl.get('24小时未运行')} ({runtime.idleTasks.length})
|
||||
</div>
|
||||
<Table
|
||||
dataSource={runtime.idleTasks}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
showHeader={false}
|
||||
columns={[
|
||||
{ dataIndex: 'name', ellipsis: true },
|
||||
{ dataIndex: 'lastRun', width: 110, render: (v: string) => <span style={{ color: '#999' }}>{v}</span> },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title={intl.get('系统资源')} size="small">
|
||||
{system && (
|
||||
<Row gutter={[16, 16]} align="middle">
|
||||
<Col span={12}>
|
||||
<Gauge
|
||||
height={300}
|
||||
data={{
|
||||
target: Number(system.memUsagePercent),
|
||||
total: 100,
|
||||
name: '内存使用率',
|
||||
}}
|
||||
style={{
|
||||
textY: '70%',
|
||||
textContent: (target: number, total: number) => {
|
||||
return `${intl.get('内存')}:${target}%`;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic title={intl.get('系统运行')} value={formatSeconds(system.uptime)} />
|
||||
<Statistic title={intl.get('堆内存')} value={`${system.heapUsed} MB`} valueStyle={{ fontSize: 16 }} />
|
||||
<div style={{ marginTop: 8, color: '#888', fontSize: 12 }}>
|
||||
{intl.get('负载 1m')}: {system.loadAvg?.[0] || '-'} | CPU: {system.cpus} {intl.get('核心')} | {system.platform}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{result && <TaskResultModal key={result} result={result} onCancel={() => setResult(null)} />}
|
||||
{logCron && (
|
||||
<CronLogModal
|
||||
cron={logCron}
|
||||
handleCancel={() => setLogCron(null)}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Modal, Table, Tag } from 'antd';
|
||||
import intl from 'react-intl-universal';
|
||||
import { request } from '@/utils/http';
|
||||
import config from '@/utils/config';
|
||||
import CronLogModal from '../crontab/logModal';
|
||||
|
||||
interface TaskResult {
|
||||
id: number;
|
||||
name: string;
|
||||
command: string;
|
||||
failCount?: number;
|
||||
successCount?: number;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
export default function TaskResultModal({
|
||||
result,
|
||||
onCancel,
|
||||
}: {
|
||||
result: 'success' | 'failure';
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const isSuccess = result === 'success';
|
||||
const [tasks, setTasks] = useState<TaskResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [reload, setReload] = useState(0);
|
||||
const [logCron, setLogCron] = useState<TaskResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
request
|
||||
.get(
|
||||
`${config.apiPrefix}dashboard/${isSuccess ? 'successes' : 'failures'}`,
|
||||
)
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
if (response.code !== 200)
|
||||
throw new Error('Failed to load dashboard task results');
|
||||
setTasks(response.data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [reload, isSuccess]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={intl.get(isSuccess ? '今日成功' : '今日失败')}
|
||||
open
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={900}
|
||||
>
|
||||
{failed ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={intl.get('加载失败')}
|
||||
action={
|
||||
<Button onClick={() => setReload((value) => value + 1)}>
|
||||
{intl.get('重试')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Table<TaskResult>
|
||||
loading={loading}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
scroll={{ x: 650 }}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
columns={[
|
||||
{
|
||||
title: intl.get('定时任务'),
|
||||
dataIndex: 'name',
|
||||
ellipsis: true,
|
||||
render: (name, task) => (
|
||||
<>
|
||||
{name} {task.deleted && <Tag>{intl.get('已删除')}</Tag>}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ title: intl.get('命令'), dataIndex: 'command', ellipsis: true },
|
||||
{
|
||||
title: intl.get(isSuccess ? '成功次数' : '失败次数'),
|
||||
dataIndex: isSuccess ? 'successCount' : 'failCount',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: intl.get('日志'),
|
||||
width: 120,
|
||||
render: (_, task) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={task.deleted}
|
||||
onClick={() => {
|
||||
localStorage.setItem('logCron', String(task.id));
|
||||
setLogCron(task);
|
||||
}}
|
||||
>
|
||||
{intl.get('最新日志')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
{logCron && (
|
||||
<CronLogModal cron={logCron} handleCancel={() => setLogCron(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -465,49 +465,33 @@ const Dependence = () => {
|
||||
}, [logDependence]);
|
||||
|
||||
const handleMessage = useCallback((payload: any) => {
|
||||
const { message, references } = payload;
|
||||
let status: number | undefined = undefined;
|
||||
if (message.includes('开始时间') && references.length > 0) {
|
||||
status = message.includes('安装') ? Status.安装中 : Status.删除中;
|
||||
}
|
||||
if (message.includes('结束时间') && references.length > 0) {
|
||||
if (message.includes('安装')) {
|
||||
status = message.includes('成功') ? Status.已安装 : Status.安装失败;
|
||||
} else {
|
||||
status = message.includes('成功') ? Status.已删除 : Status.删除失败;
|
||||
}
|
||||
const { references, status } = payload;
|
||||
if (typeof status !== 'number' || !references?.length) return;
|
||||
|
||||
if (status === Status.已删除) {
|
||||
setTimeout(() => {
|
||||
setValue((p) => {
|
||||
const _result = [...p];
|
||||
for (let i = 0; i < references.length; i++) {
|
||||
const index = p.findIndex((x) => x.id === references[i]);
|
||||
if (index !== -1) {
|
||||
_result.splice(index, 1);
|
||||
}
|
||||
}
|
||||
return _result;
|
||||
});
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (typeof status === 'number') {
|
||||
setValue((p) => {
|
||||
const result = [...p];
|
||||
for (let i = 0; i < references.length; i++) {
|
||||
const index = p.findIndex((x) => x.id === references[i]);
|
||||
if (index !== -1) {
|
||||
result.splice(index, 1, {
|
||||
...p[index],
|
||||
status,
|
||||
});
|
||||
if (status === Status.已删除) {
|
||||
setTimeout(() => {
|
||||
setValue((p) => {
|
||||
const _result = [...p];
|
||||
for (const refId of references) {
|
||||
const index = p.findIndex((x) => x.id === refId);
|
||||
if (index !== -1) _result.splice(index, 1);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
return _result;
|
||||
});
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
setValue((p) => {
|
||||
const result = [...p];
|
||||
for (const refId of references) {
|
||||
const index = p.findIndex((x) => x.id === refId);
|
||||
if (index !== -1) {
|
||||
result.splice(index, 1, { ...p[index], status });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -54,8 +54,11 @@ const DependenceLogModal = ({
|
||||
) {
|
||||
const log = (data?.log || []).join('') as string;
|
||||
setValue(log);
|
||||
setExecuting(!log.includes('结束时间'));
|
||||
setIsRemoveFailed(log.includes('删除失败'));
|
||||
const dbStatus = data?.status as number | undefined;
|
||||
setExecuting(
|
||||
dbStatus === Status.安装中 || dbStatus === Status.删除中,
|
||||
);
|
||||
setIsRemoveFailed(dbStatus === Status.删除失败);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -94,16 +97,17 @@ const DependenceLogModal = ({
|
||||
}, [dependence]);
|
||||
|
||||
const handleMessage = (payload: any) => {
|
||||
const { message, references } = payload;
|
||||
const { message, references, status } = payload;
|
||||
if (
|
||||
references.length > 0 &&
|
||||
references.includes(dependence.id) &&
|
||||
[Status.删除中, Status.安装中].includes(dependence.status)
|
||||
) {
|
||||
if (message.includes('结束时间')) {
|
||||
setExecuting(false);
|
||||
setIsRemoveFailed(message.includes('删除失败'));
|
||||
}
|
||||
!references?.length ||
|
||||
!references.includes(dependence.id)
|
||||
) return;
|
||||
|
||||
if (typeof status === 'number') {
|
||||
setExecuting(status === Status.安装中 || status === Status.删除中);
|
||||
setIsRemoveFailed(status === Status.删除失败);
|
||||
}
|
||||
if (message) {
|
||||
setValue((p) => `${p}${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
+25
-18
@@ -25,13 +25,16 @@ const Diff = () => {
|
||||
const [language, setLanguage] = useState<string>('shell');
|
||||
|
||||
const getConfig = () => {
|
||||
request
|
||||
.get(`${config.apiPrefix}configs/detail?path=${encodeURIComponent(current)}`)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
setCurrentValue(data);
|
||||
}
|
||||
});
|
||||
const url = current.startsWith('data/scripts/')
|
||||
? `${config.apiPrefix}scripts/detail?file=${encodeURIComponent(
|
||||
current.slice('data/scripts/'.length),
|
||||
)}`
|
||||
: `${config.apiPrefix}configs/detail?path=${encodeURIComponent(current)}`;
|
||||
request.get(url).then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
setCurrentValue(data);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getSample = () => {
|
||||
@@ -49,22 +52,26 @@ const Diff = () => {
|
||||
? editorRef.current.getModel().modified.getValue().replace(/\r\n/g, '\n')
|
||||
: currentValue;
|
||||
|
||||
request
|
||||
.post(`${config.apiPrefix}configs/save`, {
|
||||
content,
|
||||
name: current,
|
||||
})
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
message.success(intl.get('保存成功'));
|
||||
}
|
||||
});
|
||||
const saving = current.startsWith('data/scripts/')
|
||||
? request.post(`${config.apiPrefix}scripts`, {
|
||||
content,
|
||||
filename: current.slice('data/scripts/'.length),
|
||||
})
|
||||
: request.post(`${config.apiPrefix}configs/save`, {
|
||||
content,
|
||||
name: current,
|
||||
});
|
||||
saving.then(({ code }) => {
|
||||
if (code === 200) {
|
||||
message.success(intl.get('保存成功'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getFiles = () => {
|
||||
setLoading(true);
|
||||
request
|
||||
.get(`${config.apiPrefix}configs/sample`)
|
||||
.get(`${config.apiPrefix}configs/samples`)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
setFiles(data);
|
||||
|
||||
Vendored
+52
-3
@@ -36,7 +36,7 @@ import { useVT } from 'virtualizedtableforantd4';
|
||||
import Copy from '../../components/copy';
|
||||
import EditNameModal from './editNameModal';
|
||||
import './index.less';
|
||||
import EnvModal from './modal';
|
||||
import EnvModal, { EnvLabelModal } from './modal';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
const { Search } = Input;
|
||||
@@ -121,6 +121,25 @@ const Env = () => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: intl.get('标签'),
|
||||
dataIndex: 'labels',
|
||||
key: 'labels',
|
||||
render: (labels: string[] | null) => {
|
||||
const envLabels = Array.isArray(labels) ? labels : [];
|
||||
return (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{envLabels
|
||||
.filter((label) => label)
|
||||
.map((label) => (
|
||||
<Tag key={label} color="blue">
|
||||
{label}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: intl.get('更新时间'),
|
||||
dataIndex: 'timestamp',
|
||||
@@ -238,6 +257,7 @@ const Env = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [isEditNameModalVisible, setIsEditNameModalVisible] = useState(false);
|
||||
const [isLabelModalVisible, setIsLabelModalVisible] = useState(false);
|
||||
const [editedEnv, setEditedEnv] = useState();
|
||||
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
@@ -408,6 +428,13 @@ const Env = () => {
|
||||
getEnvs();
|
||||
};
|
||||
|
||||
const handleLabelCancel = (needUpdate?: boolean) => {
|
||||
setIsLabelModalVisible(false);
|
||||
if (needUpdate) {
|
||||
getEnvs();
|
||||
}
|
||||
};
|
||||
|
||||
const [vt, setVT] = useVT(
|
||||
() => ({ scroll: { y: tableScrollHeight } }),
|
||||
[tableScrollHeight],
|
||||
@@ -542,7 +569,12 @@ const Env = () => {
|
||||
const exportEnvs = () => {
|
||||
const envs = value
|
||||
.filter((x) => selectedRowIds.includes(x.id))
|
||||
.map((x) => ({ value: x.value, name: x.name, remarks: x.remarks }));
|
||||
.map((x) => ({
|
||||
value: x.value,
|
||||
name: x.name,
|
||||
remarks: x.remarks,
|
||||
labels: x.labels,
|
||||
}));
|
||||
exportJson('env.json', JSON.stringify(envs));
|
||||
};
|
||||
|
||||
@@ -550,6 +582,10 @@ const Env = () => {
|
||||
setIsEditNameModalVisible(true);
|
||||
};
|
||||
|
||||
const modifyLabels = () => {
|
||||
setIsLabelModalVisible(true);
|
||||
};
|
||||
|
||||
const onSearch = (value: string) => {
|
||||
setSearchText(value.trim());
|
||||
};
|
||||
@@ -567,7 +603,7 @@ const Env = () => {
|
||||
);
|
||||
|
||||
if (code === 200) {
|
||||
message.success(`成功上传${data.length}个环境变量`);
|
||||
message.success(`${intl.get('成功上传')}${data.length}${intl.get('个环境变量')}`);
|
||||
getEnvs();
|
||||
}
|
||||
setImportLoading(false);
|
||||
@@ -622,6 +658,13 @@ const Env = () => {
|
||||
>
|
||||
{intl.get('批量修改变量名称')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginBottom: 5, marginLeft: 8 }}
|
||||
onClick={modifyLabels}
|
||||
>
|
||||
{intl.get('批量修改标签')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginBottom: 5, marginLeft: 8 }}
|
||||
@@ -700,6 +743,12 @@ const Env = () => {
|
||||
ids={selectedRowIds}
|
||||
/>
|
||||
)}
|
||||
{isLabelModalVisible && (
|
||||
<EnvLabelModal
|
||||
handleCancel={handleLabelCancel}
|
||||
ids={selectedRowIds}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
Vendored
+98
-5
@@ -1,8 +1,9 @@
|
||||
import intl from 'react-intl-universal';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, message, Input, Form, Radio } from 'antd';
|
||||
import { Modal, message, Input, Form, Radio, Button } from 'antd';
|
||||
import { request } from '@/utils/http';
|
||||
import config from '@/utils/config';
|
||||
import EditableTagGroup from '@/components/tag';
|
||||
|
||||
const EnvModal = ({
|
||||
env,
|
||||
@@ -16,7 +17,7 @@ const EnvModal = ({
|
||||
|
||||
const handleOk = async (values: any) => {
|
||||
setLoading(true);
|
||||
const { value, split, name, remarks } = values;
|
||||
const { value, split, name, remarks, labels } = values;
|
||||
const method = env ? 'put' : 'post';
|
||||
let payload;
|
||||
if (!env) {
|
||||
@@ -27,13 +28,14 @@ const EnvModal = ({
|
||||
name: name,
|
||||
value: x,
|
||||
remarks: remarks,
|
||||
labels: labels || [],
|
||||
};
|
||||
});
|
||||
} else {
|
||||
payload = [{ value, name, remarks }];
|
||||
payload = [{ value, name, remarks, labels: labels || [] }];
|
||||
}
|
||||
} else {
|
||||
payload = { ...values, id: env.id };
|
||||
payload = { ...values, labels: values.labels || [], id: env.id };
|
||||
}
|
||||
try {
|
||||
const { code, data } = await request[method](
|
||||
@@ -123,9 +125,100 @@ const EnvModal = ({
|
||||
<Form.Item name="remarks" label={intl.get('备注')}>
|
||||
<Input placeholder={intl.get('请输入备注')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="labels" label={intl.get('标签')}>
|
||||
<EditableTagGroup />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvModal;
|
||||
export { EnvModal as default };
|
||||
|
||||
export const EnvLabelModal = ({
|
||||
ids,
|
||||
handleCancel,
|
||||
}: {
|
||||
ids: string[];
|
||||
handleCancel: (needUpdate?: boolean) => void;
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const update = async (action: 'add' | 'delete') => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = { ids, labels: values.labels };
|
||||
setLoading(true);
|
||||
const { code } =
|
||||
action === 'add'
|
||||
? await request.post(`${config.apiPrefix}envs/labels`, payload)
|
||||
: await request.delete(`${config.apiPrefix}envs/labels`, {
|
||||
data: payload,
|
||||
});
|
||||
|
||||
if (code === 200) {
|
||||
message.success(
|
||||
action === 'add'
|
||||
? intl.get('添加标签成功')
|
||||
: intl.get('删除标签成功'),
|
||||
);
|
||||
handleCancel(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.errorFields) {
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={intl.get('批量修改标签')}
|
||||
open={true}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => handleCancel(false)}>
|
||||
{intl.get('取消')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="delete"
|
||||
type="primary"
|
||||
danger
|
||||
loading={loading}
|
||||
onClick={() => update('delete')}
|
||||
>
|
||||
{intl.get('删除')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="add"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => update('add')}
|
||||
>
|
||||
{intl.get('添加')}
|
||||
</Button>,
|
||||
]}
|
||||
centered
|
||||
maskClosable={false}
|
||||
forceRender
|
||||
onCancel={() => handleCancel(false)}
|
||||
>
|
||||
<Form form={form} layout="vertical" name="env_label_modal">
|
||||
<Form.Item
|
||||
name="labels"
|
||||
label={intl.get('标签')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.get('请至少输入一个标签'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<EditableTagGroup />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ const Error = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.username) {
|
||||
history.push('/crontab');
|
||||
history.push('/dashboard');
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
@@ -87,7 +87,7 @@ const Error = () => {
|
||||
<div>{intl.get('2. 容器内执行 ql check、ql update')}</div>
|
||||
<div>
|
||||
{intl.get(
|
||||
'3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果',
|
||||
'3. 如果无法解决,容器内执行 ql log,拷贝执行结果',
|
||||
)}
|
||||
<Typography.Link href="https://github.com/whyour/qinglong/issues/new?assignees=&labels=&template=bug_report.yml">
|
||||
{intl.get('提交 issue')}
|
||||
|
||||
@@ -59,7 +59,7 @@ const Log = () => {
|
||||
.get(
|
||||
`${config.apiPrefix}logs/detail?file=${node.title}&path=${
|
||||
node.parent || ''
|
||||
}`,
|
||||
}&tail=true&limit=${1024 * 1024}`,
|
||||
)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
@@ -131,7 +131,7 @@ const Log = () => {
|
||||
|
||||
const deleteFile = () => {
|
||||
Modal.confirm({
|
||||
title: `确认删除`,
|
||||
title: intl.get('确认删除'),
|
||||
content: (
|
||||
<>
|
||||
{intl.get('确认删除')}
|
||||
@@ -155,7 +155,7 @@ const Log = () => {
|
||||
})
|
||||
.then(({ code }) => {
|
||||
if (code === 200) {
|
||||
message.success(`删除成功`);
|
||||
message.success(intl.get('删除成功'));
|
||||
let newData = [...data];
|
||||
if (currentNode.parent) {
|
||||
newData = depthFirstSearch(
|
||||
|
||||
@@ -50,7 +50,7 @@ const Login = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const completeTowFactor = (values: any) => {
|
||||
const completeTwoFactor = (values: any) => {
|
||||
setVerifying(true);
|
||||
request
|
||||
.put(`${config.apiPrefix}user/two-factor/login`, {
|
||||
@@ -109,7 +109,7 @@ const Login = () => {
|
||||
),
|
||||
});
|
||||
reloadUser(true);
|
||||
history.push('/crontab');
|
||||
history.push('/dashboard');
|
||||
} else if (code === 410) {
|
||||
setWaitTime(data);
|
||||
} else if (code === 420) {
|
||||
@@ -129,14 +129,14 @@ const Login = () => {
|
||||
const { value } = e.target as any;
|
||||
const regx = /^[0-9]{6}$/;
|
||||
if (regx.test(value)) {
|
||||
completeTowFactor({ code: value });
|
||||
completeTwoFactor({ code: value });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const isAuth = localStorage.getItem(config.authKey);
|
||||
if (isAuth) {
|
||||
history.push('/crontab');
|
||||
history.push('/dashboard');
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -156,7 +156,7 @@ const Login = () => {
|
||||
</div>
|
||||
<div className={styles.main}>
|
||||
{twoFactor ? (
|
||||
<Form layout="vertical" onFinish={completeTowFactor}>
|
||||
<Form layout="vertical" onFinish={completeTwoFactor}>
|
||||
<FormItem
|
||||
name="code"
|
||||
label={intl.get('验证码')}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
:global(.ant-typography) {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
:global(.ant-btn) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.labels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.diff {
|
||||
flex: 1;
|
||||
min-height: 260px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--ant-border-color-split, #d9d9d9);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
:global(.ant-btn) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 32px;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
.labels {
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.diff {
|
||||
flex: none;
|
||||
min-height: 200px;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Drawer,
|
||||
Empty,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { DiffEditor } from '@monaco-editor/react';
|
||||
import ReactDiffViewer from 'react-diff-viewer';
|
||||
import intl from 'react-intl-universal';
|
||||
import config from '@/utils/config';
|
||||
import { request } from '@/utils/http';
|
||||
import styles from './historyDrawer.module.less';
|
||||
|
||||
interface Version {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
source: 'baseline' | 'save' | 'restore' | 'external';
|
||||
identical: boolean;
|
||||
}
|
||||
interface Detail {
|
||||
version: Version & { content: string };
|
||||
current: string;
|
||||
currentHash: string;
|
||||
}
|
||||
interface Props {
|
||||
filename: string;
|
||||
directory: string;
|
||||
isPhone: boolean;
|
||||
theme: string;
|
||||
language: string;
|
||||
hasUnsavedChanges: () => boolean;
|
||||
onClose: () => void;
|
||||
onRestored: (content: string) => void;
|
||||
}
|
||||
|
||||
const sourceLabel = (source: Version['source']) =>
|
||||
intl.get(
|
||||
{
|
||||
baseline: '首次修改前',
|
||||
save: '手动保存',
|
||||
restore: '从历史版本恢复',
|
||||
external: '面板外修改后',
|
||||
}[source],
|
||||
);
|
||||
const versionLabel = (v: Version) =>
|
||||
`${new Date(v.createdAt).toLocaleString()} · ${sourceLabel(v.source)}`;
|
||||
|
||||
export default function HistoryDrawer(props: Props) {
|
||||
const {
|
||||
filename,
|
||||
directory,
|
||||
isPhone,
|
||||
theme,
|
||||
language,
|
||||
onClose,
|
||||
onRestored,
|
||||
hasUnsavedChanges,
|
||||
} = props;
|
||||
const [versions, setVersions] = useState<Version[]>([]);
|
||||
const [selected, setSelected] = useState<string>();
|
||||
const [detail, setDetail] = useState<Detail>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [detailError, setDetailError] = useState(false);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const active = useRef(true);
|
||||
const detailRequest = useRef(0);
|
||||
const params = { filename, path: directory };
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setDetail(undefined);
|
||||
setSelected(undefined);
|
||||
detailRequest.current++;
|
||||
try {
|
||||
const result = await request.get(`${config.apiPrefix}scripts/history`, {
|
||||
params,
|
||||
});
|
||||
if (result.code !== 200) throw new Error(result.message);
|
||||
if (!active.current) return;
|
||||
setVersions(result.data.versions);
|
||||
setLimit(result.data.limit);
|
||||
setSelected(result.data.versions[0]?.id);
|
||||
} catch (e: any) {
|
||||
if (active.current)
|
||||
setError(e?.response?.data?.message || intl.get('加载失败'));
|
||||
} finally {
|
||||
if (active.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetail = async (id: string) => {
|
||||
const sequence = ++detailRequest.current;
|
||||
setDetail(undefined);
|
||||
setDetailLoading(true);
|
||||
setDetailError(false);
|
||||
try {
|
||||
const result = await request.get(
|
||||
`${config.apiPrefix}scripts/history/detail`,
|
||||
{ params: { ...params, id } },
|
||||
);
|
||||
if (result.code !== 200) throw new Error(result.message);
|
||||
if (active.current && sequence === detailRequest.current)
|
||||
setDetail(result.data);
|
||||
} catch {
|
||||
if (active.current && sequence === detailRequest.current)
|
||||
setDetailError(true);
|
||||
} finally {
|
||||
if (active.current && sequence === detailRequest.current)
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
active.current = true;
|
||||
load();
|
||||
return () => {
|
||||
active.current = false;
|
||||
detailRequest.current++;
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (selected) loadDetail(selected);
|
||||
}, [selected]);
|
||||
|
||||
const restore = () => {
|
||||
if (!detail) return;
|
||||
if (hasUnsavedChanges()) {
|
||||
message.warning(intl.get('请先保存或取消当前修改,再恢复历史版本'));
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: intl.get('恢复此版本'),
|
||||
content: (
|
||||
<>
|
||||
<p>{versionLabel(detail.version)}</p>
|
||||
<p>{intl.get('当前已保存的内容会保留在历史版本中。')}</p>
|
||||
</>
|
||||
),
|
||||
okText: intl.get('恢复此版本'),
|
||||
onOk: async () => {
|
||||
if (hasUnsavedChanges()) {
|
||||
message.warning(intl.get('请先保存或取消当前修改,再恢复历史版本'));
|
||||
throw new Error('Unsaved changes');
|
||||
}
|
||||
setRestoring(true);
|
||||
try {
|
||||
const result = await request.put(
|
||||
`${config.apiPrefix}scripts/history/restore`,
|
||||
{
|
||||
...params,
|
||||
id: detail.version.id,
|
||||
expectedHash: detail.currentHash,
|
||||
},
|
||||
);
|
||||
if (result.code !== 200) throw new Error(result.message);
|
||||
if (!active.current) return;
|
||||
onRestored(result.data.content);
|
||||
if (result.data.cleanupPending) {
|
||||
message.warning(
|
||||
intl.get('已保存,但旧历史清理失败,请检查存储权限和空间'),
|
||||
);
|
||||
} else {
|
||||
message.success(intl.get('已恢复,恢复前的内容已保留'));
|
||||
}
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 409) {
|
||||
setDetail(undefined);
|
||||
setDetailError(true);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
if (active.current) setRestoring(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const identical = detail?.version.content === detail?.current;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Space direction="vertical" size={0}>
|
||||
<span>{intl.get('历史版本')}</span>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, wordBreak: 'break-all' }}
|
||||
>
|
||||
{directory ? `${directory}/` : ''}
|
||||
{filename}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
open
|
||||
width={isPhone ? '100%' : 'min(1100px, 90vw)'}
|
||||
onClose={restoring ? undefined : onClose}
|
||||
closable={!restoring}
|
||||
maskClosable={!restoring}
|
||||
keyboard={!restoring}
|
||||
bodyStyle={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
padding: isPhone ? 16 : 24,
|
||||
}}
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Typography.Text type="secondary">
|
||||
{intl.get(
|
||||
'最多保留 {count} 个历史版本,容量不足时自动清理旧历史。',
|
||||
{ count: limit },
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={restore}
|
||||
loading={restoring}
|
||||
disabled={!detail || detailLoading || identical}
|
||||
>
|
||||
{intl.get('恢复此版本')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={intl.get(
|
||||
'仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。',
|
||||
)}
|
||||
/>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
message={error}
|
||||
action={<Button onClick={load}>{intl.get('重试')}</Button>}
|
||||
/>
|
||||
) : versions.length === 0 ? (
|
||||
<Empty
|
||||
description={intl.get(
|
||||
'暂无历史版本,下次保存修改时会自动保留当前内容',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
aria-label={intl.get('选择历史版本')}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
disabled={restoring}
|
||||
options={versions.map((v) => ({
|
||||
value: v.id,
|
||||
label: versionLabel(v),
|
||||
}))}
|
||||
/>
|
||||
{detailLoading ? (
|
||||
<Spin />
|
||||
) : detailError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
message={intl.get('历史版本加载失败,请刷新重试')}
|
||||
action={<Button onClick={load}>{intl.get('重试')}</Button>}
|
||||
/>
|
||||
) : (
|
||||
detail && (
|
||||
<>
|
||||
<div className={styles.labels}>
|
||||
<span>
|
||||
{isPhone ? '− ' : ''}
|
||||
{intl.get('历史版本')}
|
||||
</span>
|
||||
<span>
|
||||
{isPhone ? '+ ' : ''}
|
||||
{intl.get('当前已保存文件')}
|
||||
</span>
|
||||
</div>
|
||||
{identical && (
|
||||
<Alert type="success" message={intl.get('与当前文件一致')} />
|
||||
)}
|
||||
<div className={styles.diff}>
|
||||
{isPhone ? (
|
||||
<ReactDiffViewer
|
||||
oldValue={detail.version.content}
|
||||
newValue={detail.current}
|
||||
splitView={false}
|
||||
disableWordDiff
|
||||
useDarkTheme={theme.includes('dark')}
|
||||
styles={{
|
||||
contentText: {
|
||||
wordBreak: 'break-all',
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DiffEditor
|
||||
language={language}
|
||||
original={detail.version.content}
|
||||
modified={detail.current}
|
||||
theme={theme}
|
||||
options={{
|
||||
readOnly: true,
|
||||
originalEditable: false,
|
||||
fontSize: 12,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
renderSideBySide: true,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
+96
-49
@@ -10,6 +10,7 @@ import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
EllipsisOutlined,
|
||||
HistoryOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { PageContainer } from '@ant-design/pro-layout';
|
||||
@@ -40,6 +41,8 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import intl from 'react-intl-universal';
|
||||
import SplitPane from 'react-split-pane';
|
||||
import EditModal from './editModal';
|
||||
import HistoryDrawer from './historyDrawer';
|
||||
import { saveWithHistory } from './saveWithHistory';
|
||||
import EditScriptNameModal from './editNameModal';
|
||||
import styles from './index.module.less';
|
||||
import RenameModal from './renameModal';
|
||||
@@ -65,6 +68,26 @@ const Script = () => {
|
||||
const [currentNode, setCurrentNode] = useState<any>();
|
||||
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
|
||||
const [showMonaco, setShowMonaco] = useState(true);
|
||||
const [historyVisible, setHistoryVisible] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savingRef = useRef(false);
|
||||
const savedContentRef = useRef('');
|
||||
const currentKeyRef = useRef(currentNode?.key);
|
||||
currentKeyRef.current = currentNode?.key;
|
||||
|
||||
useEffect(() => {
|
||||
setHistoryVisible(false);
|
||||
}, [currentNode?.key]);
|
||||
|
||||
const hasUnsavedChanges = () => {
|
||||
const content =
|
||||
!isPhone && editorRef.current ? editorRef.current.getValue() : value;
|
||||
return (
|
||||
isEditing &&
|
||||
content.replace(/\r\n/g, '\n') !==
|
||||
savedContentRef.current.replace(/\r\n/g, '\n')
|
||||
);
|
||||
};
|
||||
|
||||
const handleIsEditing = (filename: string, value: boolean) => {
|
||||
setIsEditing(value && canPreviewInMonaco(filename));
|
||||
@@ -89,10 +112,11 @@ const Script = () => {
|
||||
.get(
|
||||
`${config.apiPrefix}scripts/detail?file=${encodeURIComponent(
|
||||
node.title,
|
||||
)}&path=${node.parent || ''}`,
|
||||
)}&path=${encodeURIComponent(node.parent || '')}`,
|
||||
)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
if (code === 200 && currentKeyRef.current === node.key) {
|
||||
savedContentRef.current = data;
|
||||
setValue(data);
|
||||
if (options.callback) {
|
||||
options.callback();
|
||||
@@ -179,7 +203,7 @@ const Script = () => {
|
||||
const currentContent = editorRef.current
|
||||
? editorRef.current.getValue().replace(/\r\n/g, '\n')
|
||||
: value;
|
||||
const originalContent = value.replace(/\r\n/g, '\n');
|
||||
const originalContent = savedContentRef.current.replace(/\r\n/g, '\n');
|
||||
|
||||
if (currentContent !== originalContent && isEditing) {
|
||||
Modal.confirm({
|
||||
@@ -247,47 +271,41 @@ const Script = () => {
|
||||
getDetail(currentNode);
|
||||
};
|
||||
|
||||
const saveFile = () => {
|
||||
Modal.confirm({
|
||||
title: `确认保存`,
|
||||
content: (
|
||||
<>
|
||||
{intl.get('确认保存文件')}
|
||||
<Text style={{ wordBreak: 'break-all' }} type="warning">
|
||||
{' '}
|
||||
{currentNode.title}
|
||||
</Text>
|
||||
{intl.get(',保存后不可恢复')}
|
||||
</>
|
||||
),
|
||||
onOk() {
|
||||
const content = editorRef.current
|
||||
? editorRef.current.getValue().replace(/\r\n/g, '\n')
|
||||
: value;
|
||||
return new Promise((resolve, reject) => {
|
||||
request
|
||||
.put(`${config.apiPrefix}scripts`, {
|
||||
filename: currentNode.title,
|
||||
path: currentNode.parent || '',
|
||||
content,
|
||||
})
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
message.success(`保存成功`);
|
||||
setValue(content);
|
||||
handleIsEditing(currentNode.title, false);
|
||||
}
|
||||
resolve(null);
|
||||
})
|
||||
.catch((e) => reject(e));
|
||||
});
|
||||
},
|
||||
});
|
||||
const saveFile = async () => {
|
||||
if (savingRef.current || !currentNode) return;
|
||||
const node = currentNode;
|
||||
const content =
|
||||
!isPhone && editorRef.current
|
||||
? editorRef.current.getValue().replace(/\r\n/g, '\n')
|
||||
: value;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await saveWithHistory(
|
||||
'put',
|
||||
`${config.apiPrefix}scripts`,
|
||||
{
|
||||
filename: node.title,
|
||||
path: node.parent || '',
|
||||
content,
|
||||
},
|
||||
);
|
||||
if (result?.code === 200 && currentKeyRef.current === node.key) {
|
||||
savedContentRef.current = content;
|
||||
setValue(content);
|
||||
handleIsEditing(node.title, false);
|
||||
}
|
||||
} catch {
|
||||
// The shared HTTP handler presents the server's error; keep the draft.
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFile = () => {
|
||||
Modal.confirm({
|
||||
title: `确认删除`,
|
||||
title: intl.get('确认删除'),
|
||||
content: (
|
||||
<>
|
||||
{intl.get('确认删除')}
|
||||
@@ -311,7 +329,7 @@ const Script = () => {
|
||||
})
|
||||
.then(({ code }) => {
|
||||
if (code === 200) {
|
||||
message.success(`删除成功`);
|
||||
message.success(intl.get('删除成功'));
|
||||
let newData = [...data];
|
||||
if (currentNode.parent) {
|
||||
newData = depthFirstSearch(
|
||||
@@ -548,8 +566,20 @@ const Script = () => {
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
extra={
|
||||
isPhone
|
||||
extra={[
|
||||
...(currentNode?.type === 'file' && showMonaco
|
||||
? [
|
||||
<Button
|
||||
key="history"
|
||||
icon={<HistoryOutlined />}
|
||||
disabled={saving}
|
||||
onClick={() => setHistoryVisible(true)}
|
||||
>
|
||||
{!isPhone && intl.get('历史版本')}
|
||||
</Button>,
|
||||
]
|
||||
: []),
|
||||
...(isPhone
|
||||
? [
|
||||
<TreeSelect
|
||||
treeExpandAction="click"
|
||||
@@ -562,7 +592,7 @@ const Script = () => {
|
||||
treeNodeFilterProp="title"
|
||||
showSearch
|
||||
allowClear
|
||||
onSelect={onSelect}
|
||||
onSelect={(key, node) => onTreeSelect([key], { node })}
|
||||
/>,
|
||||
<Dropdown menu={menu} trigger={['click']}>
|
||||
<Button type="primary" icon={<EllipsisOutlined />} />
|
||||
@@ -570,7 +600,7 @@ const Script = () => {
|
||||
]
|
||||
: isEditing
|
||||
? [
|
||||
<Button type="primary" onClick={saveFile}>
|
||||
<Button type="primary" onClick={saveFile} loading={saving}>
|
||||
{intl.get('保存')}
|
||||
</Button>,
|
||||
<Button type="primary" onClick={cancelEdit}>
|
||||
@@ -625,12 +655,29 @@ const Script = () => {
|
||||
>
|
||||
{intl.get('调试')}
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
]),
|
||||
]}
|
||||
header={{
|
||||
style: headerStyle,
|
||||
}}
|
||||
>
|
||||
{historyVisible && currentNode && (
|
||||
<HistoryDrawer
|
||||
key={currentNode.key}
|
||||
filename={currentNode.title}
|
||||
directory={currentNode.parent || ''}
|
||||
isPhone={isPhone}
|
||||
theme={theme}
|
||||
language={mode}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
onClose={() => setHistoryVisible(false)}
|
||||
onRestored={(content) => {
|
||||
savedContentRef.current = content;
|
||||
setValue(content);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={`${styles['log-container']} log-container`}>
|
||||
{!isPhone && (
|
||||
/*// @ts-ignore*/
|
||||
@@ -682,7 +729,7 @@ const Script = () => {
|
||||
value={value}
|
||||
theme={theme}
|
||||
options={{
|
||||
readOnly: !isEditing,
|
||||
readOnly: !isEditing || saving,
|
||||
fontSize: 12,
|
||||
lineNumbersMinChars: 3,
|
||||
glyphMargin: false,
|
||||
@@ -704,7 +751,7 @@ const Script = () => {
|
||||
mode ? [langs[mode as keyof typeof langs]()] : undefined
|
||||
}
|
||||
theme={theme.includes('dark') ? 'dark' : 'light'}
|
||||
readOnly={!isEditing}
|
||||
readOnly={!isEditing || saving}
|
||||
onChange={(value) => {
|
||||
setValue(value);
|
||||
}}
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Modal, message, Input, Form } from 'antd';
|
||||
import { request } from '@/utils/http';
|
||||
import config from '@/utils/config';
|
||||
import { saveWithHistory } from './saveWithHistory';
|
||||
|
||||
const SaveModal = ({
|
||||
file,
|
||||
@@ -16,18 +17,21 @@ const SaveModal = ({
|
||||
|
||||
const handleOk = async (values: any) => {
|
||||
setLoading(true);
|
||||
const payload = { ...values, originFilename: file.title, content: file.content };
|
||||
request
|
||||
.post(`${config.apiPrefix}scripts`, payload)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
message.success(intl.get('保存文件成功'));
|
||||
handleCancel(data);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
const payload = {
|
||||
...values,
|
||||
originFilename: file.title,
|
||||
content: file.content,
|
||||
};
|
||||
try {
|
||||
const result = await saveWithHistory(
|
||||
'post',
|
||||
`${config.apiPrefix}scripts`,
|
||||
payload,
|
||||
);
|
||||
if (result?.code === 200) handleCancel(result.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Modal, message } from 'antd';
|
||||
import intl from 'react-intl-universal';
|
||||
import { IResponseData, request } from '@/utils/http';
|
||||
|
||||
/** Shared by the editor and debugger, so neither can silently skip history. */
|
||||
export async function saveWithHistory(
|
||||
method: 'put' | 'post',
|
||||
url: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<IResponseData | undefined> {
|
||||
const send = async (options: Record<string, unknown> = {}) => {
|
||||
const result = await request[method](
|
||||
url,
|
||||
{ ...payload, ...options },
|
||||
{
|
||||
onError: (response) => Promise.reject({ response }),
|
||||
},
|
||||
);
|
||||
if (result.code !== 200)
|
||||
throw new Error(result.message || intl.get('保存失败'));
|
||||
return result;
|
||||
};
|
||||
const report = (error: any) =>
|
||||
message.error(
|
||||
error?.response?.data?.message || error?.message || intl.get('保存失败'),
|
||||
);
|
||||
let result: IResponseData | undefined;
|
||||
try {
|
||||
result = await send();
|
||||
} catch (error: any) {
|
||||
const response = error?.response;
|
||||
if (response?.status !== 413 || !response.data?.historyUnavailable) {
|
||||
report(error);
|
||||
return undefined;
|
||||
}
|
||||
result = await new Promise<IResponseData | undefined>((resolve) => {
|
||||
const confirmation = Modal.confirm({
|
||||
title: intl.get('不记录历史直接保存?'),
|
||||
content: intl.get(
|
||||
'当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。',
|
||||
),
|
||||
okText: intl.get('仍然保存'),
|
||||
maskClosable: false,
|
||||
onCancel: () => resolve(undefined),
|
||||
onOk: async () => {
|
||||
confirmation.update({
|
||||
cancelButtonProps: { disabled: true },
|
||||
keyboard: false,
|
||||
});
|
||||
try {
|
||||
const saved = await send({
|
||||
skipHistory: true,
|
||||
expectedHash: response.data.currentHash,
|
||||
});
|
||||
resolve(saved);
|
||||
} catch (failure) {
|
||||
report(failure);
|
||||
// Close the confirmation and release the editor. A conflict needs a
|
||||
// fresh read, not another retry with the stale confirmation hash.
|
||||
resolve(undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
if (result?.data?.historyRecorded === false) {
|
||||
message.warning(intl.get('已保存,本次未记录历史版本'));
|
||||
} else if (result?.data?.cleanupPending) {
|
||||
message.warning(intl.get('已保存,但旧历史清理失败,请检查存储权限和空间'));
|
||||
} else if (result) {
|
||||
message.success(intl.get('保存成功'));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
||||
const [updateLoading, setUpdateLoading] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const modalRef = useRef<any>();
|
||||
const lastStatusRef = useRef<string | undefined>();
|
||||
|
||||
const checkUpgrade = () => {
|
||||
if (updateLoading) return;
|
||||
@@ -166,7 +167,7 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) return;
|
||||
const updateFailed = value.includes("失败,请检查");
|
||||
const updateFailed = lastStatusRef.current === 'failed';
|
||||
|
||||
modalRef.current.update({
|
||||
maskClosable: updateFailed,
|
||||
@@ -184,26 +185,29 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
||||
}, [value]);
|
||||
|
||||
const handleMessage = useCallback((payload: any) => {
|
||||
let { message: _message } = payload;
|
||||
const updateFailed = _message.includes("失败,请检查");
|
||||
const { message: _message, status } = payload;
|
||||
|
||||
if (updateFailed) {
|
||||
if (status === 'failed') {
|
||||
lastStatusRef.current = 'failed';
|
||||
message.error(intl.get("更新失败,请检查网络及日志或稍后再试"));
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
lastStatusRef.current = 'success';
|
||||
setTimeout(() => {
|
||||
showReloadModal();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
document
|
||||
.querySelector("#log-identifier")
|
||||
?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 600);
|
||||
|
||||
if (_message.includes("更新包下载成功")) {
|
||||
setTimeout(() => {
|
||||
showReloadModal();
|
||||
}, 1000);
|
||||
if (_message) {
|
||||
setValue((p) => `${p}${_message}`);
|
||||
}
|
||||
|
||||
setValue((p) => `${p}${_message}`);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Descriptions,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import config from '@/utils/config';
|
||||
import { request } from '@/utils/http';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
type TrustProxySource = 'default' | 'system' | 'environment';
|
||||
type TrustProxyMode = 'direct' | 'single' | 'hops' | 'custom';
|
||||
|
||||
interface TrustProxyConfig {
|
||||
trustProxy: string;
|
||||
source: TrustProxySource;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
interface ClientIpDiagnostic extends TrustProxyConfig {
|
||||
remoteAddress: string;
|
||||
forwardedFor: string[];
|
||||
expressIps: string[];
|
||||
clientIp: string;
|
||||
hops: Array<{
|
||||
ip: string;
|
||||
hop: number;
|
||||
status: 'trusted' | 'client' | 'not_checked';
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseSetting(setting: string) {
|
||||
if (setting === 'false' || setting === '0') {
|
||||
return { mode: 'direct' as const, hops: 2, custom: '' };
|
||||
}
|
||||
if (setting === '1') {
|
||||
return { mode: 'single' as const, hops: 2, custom: '' };
|
||||
}
|
||||
if (/^\d+$/.test(setting)) {
|
||||
return { mode: 'hops' as const, hops: Number(setting), custom: '' };
|
||||
}
|
||||
return { mode: 'custom' as const, hops: 2, custom: setting };
|
||||
}
|
||||
|
||||
const ClientIp = () => {
|
||||
const [configInfo, setConfigInfo] = useState<TrustProxyConfig>();
|
||||
const [diagnostic, setDiagnostic] = useState<ClientIpDiagnostic>();
|
||||
const [mode, setMode] = useState<TrustProxyMode>('direct');
|
||||
const [hops, setHops] = useState(2);
|
||||
const [custom, setCustom] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [diagnosing, setDiagnosing] = useState(false);
|
||||
|
||||
const applyConfig = (data: TrustProxyConfig) => {
|
||||
setConfigInfo(data);
|
||||
const parsed = parseSetting(data.trustProxy);
|
||||
setMode(parsed.mode);
|
||||
setHops(parsed.hops);
|
||||
setCustom(parsed.custom);
|
||||
};
|
||||
|
||||
const getConfig = async () => {
|
||||
const response = await request.get(
|
||||
`${config.apiPrefix}system/client-ip/config`,
|
||||
);
|
||||
if (response.code === 200) {
|
||||
applyConfig(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
const diagnose = async () => {
|
||||
setDiagnosing(true);
|
||||
try {
|
||||
const response = await request.get(
|
||||
`${config.apiPrefix}system/client-ip/diagnose`,
|
||||
);
|
||||
if (response.code === 200) {
|
||||
setDiagnostic(response.data);
|
||||
}
|
||||
} finally {
|
||||
setDiagnosing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSetting = () => {
|
||||
if (mode === 'direct') return 'false';
|
||||
if (mode === 'single') return '1';
|
||||
if (mode === 'hops') return String(hops);
|
||||
return custom.trim();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const trustProxy = getSetting();
|
||||
if (!trustProxy) {
|
||||
message.error(intl.get('请输入可信代理地址或网段'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await request.put(
|
||||
`${config.apiPrefix}system/client-ip/config`,
|
||||
{ trustProxy },
|
||||
);
|
||||
if (response.code === 200) {
|
||||
applyConfig(response.data);
|
||||
message.success(intl.get('更新成功'));
|
||||
await diagnose();
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getConfig();
|
||||
diagnose();
|
||||
}, []);
|
||||
|
||||
const sourceMap: Record<TrustProxySource, string> = {
|
||||
default: intl.get('默认配置'),
|
||||
system: intl.get('系统设置'),
|
||||
environment: intl.get('环境变量'),
|
||||
};
|
||||
const statusMap = {
|
||||
trusted: { color: 'green', text: intl.get('可信代理') },
|
||||
client: { color: 'blue', text: intl.get('最终客户端') },
|
||||
not_checked: { color: 'default', text: intl.get('未检查') },
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 960, padding: '12px 0 32px' }}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={intl.get('可信代理配置说明')}
|
||||
description={
|
||||
<div>
|
||||
<Paragraph>
|
||||
{intl.get(
|
||||
'系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。',
|
||||
)}
|
||||
</Paragraph>
|
||||
<Paragraph style={{ marginBottom: 0 }}>
|
||||
{intl.get(
|
||||
'代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。',
|
||||
)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{configInfo?.source === 'environment' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={intl.get('当前由环境变量 QL_TRUST_PROXY 管理')}
|
||||
description={intl.get(
|
||||
'请修改容器环境变量并重启,系统设置不能覆盖环境变量。',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Paragraph strong>{intl.get('Trust Proxy 自定义设置')}</Paragraph>
|
||||
<Radio.Group
|
||||
value={mode}
|
||||
onChange={(event) => setMode(event.target.value)}
|
||||
disabled={!configInfo?.editable}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="direct">{intl.get('直接访问')}</Radio.Button>
|
||||
<Radio.Button value="single">{intl.get('一层代理')}</Radio.Button>
|
||||
<Radio.Button value="hops">{intl.get('固定多层')}</Radio.Button>
|
||||
<Radio.Button value="custom">
|
||||
{intl.get('指定地址或网段')}
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{mode === 'hops' && (
|
||||
<InputNumber
|
||||
min={2}
|
||||
max={20}
|
||||
value={hops}
|
||||
onChange={(value) => setHops(value || 2)}
|
||||
addonAfter={intl.get('层')}
|
||||
disabled={!configInfo?.editable}
|
||||
/>
|
||||
)}
|
||||
{mode === 'custom' && (
|
||||
<Input
|
||||
style={{ maxWidth: 620 }}
|
||||
value={custom}
|
||||
onChange={(event) => setCustom(event.target.value)}
|
||||
disabled={!configInfo?.editable}
|
||||
placeholder="loopback,172.18.0.0/16,10.20.0.8/32"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Space style={{ marginTop: 12 }} wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={save}
|
||||
loading={saving}
|
||||
disabled={!configInfo?.editable}
|
||||
>
|
||||
{intl.get('保存配置')}
|
||||
</Button>
|
||||
<Text type="secondary">
|
||||
{intl.get('当前生效值')}:
|
||||
<Text copyable>{configInfo?.trustProxy || '-'}</Text>
|
||||
{configInfo && `(${sourceMap[configInfo.source]})`}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Paragraph strong style={{ marginBottom: 0 }}>
|
||||
{intl.get('客户端 IP 诊断')}
|
||||
</Paragraph>
|
||||
<Button onClick={diagnose} loading={diagnosing}>
|
||||
{intl.get('重新诊断')}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{diagnostic && (
|
||||
<>
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label={intl.get('Socket 地址')}>
|
||||
<Text copyable>{diagnostic.remoteAddress || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="X-Forwarded-For">
|
||||
<Text copyable>
|
||||
{diagnostic.forwardedFor.join(', ') || '-'}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={intl.get('最终客户端 IP')}>
|
||||
<Text strong copyable>
|
||||
{diagnostic.clientIp || '-'}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Table
|
||||
style={{ marginTop: 12 }}
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey={(record) => `${record.hop}-${record.ip}`}
|
||||
dataSource={diagnostic.hops}
|
||||
columns={[
|
||||
{
|
||||
title: intl.get('距离青龙'),
|
||||
dataIndex: 'hop',
|
||||
width: 120,
|
||||
render: (value) => `${value} ${intl.get('跳')}`,
|
||||
},
|
||||
{ title: 'IP', dataIndex: 'ip' },
|
||||
{
|
||||
title: intl.get('判定'),
|
||||
dataIndex: 'status',
|
||||
width: 140,
|
||||
render: (value: keyof typeof statusMap) => (
|
||||
<Tag color={statusMap[value].color}>
|
||||
{statusMap[value].text}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientIp;
|
||||
@@ -75,12 +75,9 @@ const Dependence = () => {
|
||||
};
|
||||
|
||||
const handleMessage = (payload: any) => {
|
||||
const { message } = payload;
|
||||
setLog((p) => `${p}${message}`);
|
||||
if (
|
||||
message.includes('update node mirror end') ||
|
||||
message.includes('update linux mirror end')
|
||||
) {
|
||||
const { message, status } = payload;
|
||||
if (message) setLog((p) => `${p}${message}`);
|
||||
if (status === 'completed') {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -109,7 +106,7 @@ const Dependence = () => {
|
||||
ws.subscribe('updateLinuxMirror', handleMessage);
|
||||
|
||||
return () => {
|
||||
ws.subscribe('updateNodeMirror', handleMessage);
|
||||
ws.unsubscribe('updateNodeMirror', handleMessage);
|
||||
ws.unsubscribe('updateLinuxMirror', handleMessage);
|
||||
};
|
||||
}, []);
|
||||
@@ -215,12 +212,12 @@ const Dependence = () => {
|
||||
<Form.Item
|
||||
label={intl.get('Linux 软件包镜像源')}
|
||||
name="linux"
|
||||
tooltip={intl.get('alpine linux 镜像源')}
|
||||
tooltip={intl.get('debian linux 镜像源')}
|
||||
>
|
||||
<Input.Group compact>
|
||||
<Input
|
||||
style={{ width: 250 }}
|
||||
placeholder={'https://mirrors.aliyun.com'}
|
||||
placeholder={'http://mirrors.aliyun.com'}
|
||||
value={systemConfig?.linuxMirror}
|
||||
onChange={(e) => {
|
||||
setSystemConfig({
|
||||
|
||||
@@ -36,6 +36,7 @@ import './index.less';
|
||||
import useResizeObserver from '@react-hook/resize-observer';
|
||||
import SystemLog from './systemLog';
|
||||
import Dependence from './dependence';
|
||||
import ClientIp from './clientIp';
|
||||
|
||||
const { Text } = Typography;
|
||||
const isDemoEnv = window.__ENV__DeployEnv === 'demo';
|
||||
@@ -47,10 +48,10 @@ const Setting = () => {
|
||||
user,
|
||||
theme,
|
||||
reloadUser,
|
||||
reloadSystemConfig,
|
||||
reloadTheme,
|
||||
systemInfo,
|
||||
} = useOutletContext<SharedContext>();
|
||||
console.log('user',user)
|
||||
const columns = [
|
||||
{
|
||||
title: intl.get('名称'),
|
||||
@@ -366,6 +367,11 @@ const Setting = () => {
|
||||
label: intl.get('通知日志'),
|
||||
children: <NotifyLog height={height} data={notifyLogData} />,
|
||||
},
|
||||
{
|
||||
key: 'client-ip',
|
||||
label: intl.get('客户端 IP'),
|
||||
children: <ClientIp />,
|
||||
},
|
||||
{
|
||||
key: 'dependence',
|
||||
label: intl.get('依赖设置'),
|
||||
@@ -375,7 +381,11 @@ const Setting = () => {
|
||||
key: 'other',
|
||||
label: intl.get('其他设置'),
|
||||
children: (
|
||||
<Other reloadTheme={reloadTheme} systemInfo={systemInfo} />
|
||||
<Other
|
||||
reloadSystemConfig={reloadSystemConfig}
|
||||
reloadTheme={reloadTheme}
|
||||
systemInfo={systemInfo}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import intl from 'react-intl-universal';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Typography, Table, Tag, Button, Spin, message } from 'antd';
|
||||
import { Table, Tag, Button, Popconfirm, message } from 'antd';
|
||||
import { request } from '@/utils/http';
|
||||
import config from '@/utils/config';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text, Link } = Typography;
|
||||
|
||||
enum LoginStatus {
|
||||
'成功',
|
||||
'失败',
|
||||
@@ -17,70 +15,111 @@ enum LoginStatusColor {
|
||||
'error',
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: intl.get('序号'),
|
||||
width: 50,
|
||||
render: (text: string, record: any, index: number) => {
|
||||
return index + 1;
|
||||
const LoginLog = ({ data, height }: { data: Array<any>; height: number }) => {
|
||||
const [blockedIps, setBlockedIps] = useState<string[]>([]);
|
||||
|
||||
const getIpBlacklist = () => {
|
||||
request
|
||||
.get(`${config.apiPrefix}user/ip-blacklist`)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
setBlockedIps(data || []);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getIpBlacklist();
|
||||
}, []);
|
||||
|
||||
const updateIpBlacklist = async (ip: string, blocked: boolean) => {
|
||||
const response = blocked
|
||||
? await request.delete(`${config.apiPrefix}user/ip-blacklist`, {
|
||||
data: { ip },
|
||||
})
|
||||
: await request.put(`${config.apiPrefix}user/ip-blacklist`, { ip });
|
||||
if (response.code === 200) {
|
||||
setBlockedIps(response.data || []);
|
||||
message.success(response.message);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: intl.get('序号'),
|
||||
width: 50,
|
||||
render: (text: string, record: any, index: number) => index + 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: intl.get('登录时间'),
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
width: 120,
|
||||
render: (text: string, record: any) => {
|
||||
return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss');
|
||||
{
|
||||
title: intl.get('登录时间'),
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
width: 120,
|
||||
render: (text: string, record: any) =>
|
||||
dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: intl.get('登录地址'),
|
||||
dataIndex: 'address',
|
||||
width: 120,
|
||||
key: 'address',
|
||||
},
|
||||
{
|
||||
title: intl.get('登录IP'),
|
||||
dataIndex: 'ip',
|
||||
width: 100,
|
||||
key: 'ip',
|
||||
},
|
||||
{
|
||||
title: intl.get('登录设备'),
|
||||
dataIndex: 'platform',
|
||||
key: 'platform',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: intl.get('登录状态'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (text: string, record: any) => {
|
||||
return (
|
||||
{
|
||||
title: intl.get('登录地址'),
|
||||
dataIndex: 'address',
|
||||
width: 120,
|
||||
key: 'address',
|
||||
},
|
||||
{
|
||||
title: intl.get('登录IP'),
|
||||
dataIndex: 'ip',
|
||||
width: 100,
|
||||
key: 'ip',
|
||||
},
|
||||
{
|
||||
title: intl.get('登录设备'),
|
||||
dataIndex: 'platform',
|
||||
key: 'platform',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: intl.get('登录状态'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (text: string, record: any) => (
|
||||
<Tag color={LoginStatusColor[record.status]} style={{ marginRight: 0 }}>
|
||||
{intl.get(LoginStatus[record.status])}
|
||||
</Tag>
|
||||
);
|
||||
),
|
||||
},
|
||||
},
|
||||
];
|
||||
{
|
||||
title: intl.get('操作'),
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (text: string, record: any) => {
|
||||
if (!record.ip || record.status !== 1) {
|
||||
return null;
|
||||
}
|
||||
const blocked = blockedIps.includes(record.ip);
|
||||
const label = blocked ? '移出黑名单' : '加入黑名单';
|
||||
return (
|
||||
<Popconfirm
|
||||
title={intl.get(
|
||||
blocked ? '确认移出 IP 黑名单' : '确认加入 IP 黑名单',
|
||||
)}
|
||||
onConfirm={() => updateIpBlacklist(record.ip, blocked)}
|
||||
>
|
||||
<Button type="link" danger={!blocked} size="small">
|
||||
{intl.get(label)}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const LoginLog = ({
|
||||
data,
|
||||
height,
|
||||
}: {
|
||||
data: Array<object>;
|
||||
height: number;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
rowKey={(record) => `${record.ip}-${record.timestamp}`}
|
||||
size="middle"
|
||||
scroll={{ x: 1000, y: height }}
|
||||
/>
|
||||
|
||||
@@ -70,7 +70,7 @@ const NotificationSetting = ({ data }: any) => {
|
||||
key={x.label}
|
||||
label={x.label}
|
||||
name={x.label}
|
||||
extra={x.tip}
|
||||
extra={<span dangerouslySetInnerHTML={{ __html: x.tip }} />}
|
||||
rules={[{ required: x.required }]}
|
||||
style={{ maxWidth: 400 }}
|
||||
>
|
||||
|
||||
+201
-2
@@ -27,6 +27,7 @@ import { disableBody } from '@/utils';
|
||||
import { TIMEZONES } from '@/utils/const';
|
||||
|
||||
const dataMap = {
|
||||
'panel-title': 'panelTitle',
|
||||
'log-remove-frequency': 'logRemoveFrequency',
|
||||
'cron-concurrency': 'cronConcurrency',
|
||||
timezone: 'timezone',
|
||||
@@ -48,14 +49,21 @@ const exportModules = [
|
||||
|
||||
const Other = ({
|
||||
systemInfo,
|
||||
reloadSystemConfig,
|
||||
reloadTheme,
|
||||
}: Pick<SharedContext, 'reloadTheme' | 'systemInfo'>) => {
|
||||
}: Pick<
|
||||
SharedContext,
|
||||
'reloadSystemConfig' | 'reloadTheme' | 'systemInfo'
|
||||
>) => {
|
||||
const defaultTheme = localStorage.getItem('qinglong_dark_theme') || 'auto';
|
||||
const [systemConfig, setSystemConfig] = useState<{
|
||||
panelTitle?: string | null;
|
||||
logRemoveFrequency?: number | null;
|
||||
cronConcurrency?: number | null;
|
||||
timezone?: string | null;
|
||||
globalSshKey?: string | null;
|
||||
runningInstanceRetentionDays?: number | null;
|
||||
cronStatRetentionDays?: number | null;
|
||||
}>();
|
||||
const [form] = Form.useForm();
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
@@ -63,6 +71,11 @@ const Other = ({
|
||||
const showDownloadProgress = useProgress(intl.get('下载'));
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [selectedModules, setSelectedModules] = useState<string[]>(['base']);
|
||||
const [cleanupLoading, setCleanupLoading] = useState(false);
|
||||
const [dependenceCacheTypes, setDependenceCacheTypes] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [compactDatabase, setCompactDatabase] = useState(false);
|
||||
|
||||
const {
|
||||
enable: enableDarkMode,
|
||||
@@ -89,6 +102,10 @@ const Other = ({
|
||||
|
||||
const handleLangChange = (v: string) => {
|
||||
localStorage.setItem('lang', v);
|
||||
const backendLang = v || navigator.language?.slice(0, 2) || 'zh';
|
||||
request
|
||||
.put(`${config.apiPrefix}system/config/lang`, { lang: backendLang })
|
||||
.catch(() => {});
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
@@ -116,6 +133,9 @@ const Other = ({
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
message.success(intl.get('更新成功'));
|
||||
if (path === 'panel-title') {
|
||||
reloadSystemConfig();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
@@ -123,6 +143,93 @@ const Other = ({
|
||||
});
|
||||
};
|
||||
|
||||
const retentionPayload = () => ({
|
||||
runningInstanceRetentionDays:
|
||||
systemConfig?.runningInstanceRetentionDays || 0,
|
||||
cronStatRetentionDays: systemConfig?.cronStatRetentionDays || 0,
|
||||
});
|
||||
|
||||
const saveRetentionPolicy = () => {
|
||||
request
|
||||
.put(
|
||||
`${config.apiPrefix}system/storage-retention/config`,
|
||||
retentionPayload(),
|
||||
)
|
||||
.then(({ code }) => {
|
||||
if (code === 200) {
|
||||
message.success(intl.get('更新成功'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
||||
const unit = Math.min(
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||
units.length - 1,
|
||||
);
|
||||
return `${(bytes / 1024 ** unit).toFixed(unit ? 1 : 0)} ${units[unit]}`;
|
||||
};
|
||||
|
||||
const previewStorageCleanup = () => {
|
||||
setCleanupLoading(true);
|
||||
const payload = {
|
||||
...retentionPayload(),
|
||||
dependenceCacheTypes,
|
||||
compactDatabase,
|
||||
};
|
||||
request
|
||||
.post(`${config.apiPrefix}system/storage-retention/preview`, payload)
|
||||
.then(({ code, data }) => {
|
||||
if (code !== 200) return;
|
||||
Modal.confirm({
|
||||
width: 560,
|
||||
centered: true,
|
||||
title: intl.get('确认清理存储数据'),
|
||||
okText: intl.get('确认清理'),
|
||||
cancelText: intl.get('取消'),
|
||||
okButtonProps: { danger: true },
|
||||
content: (
|
||||
<div>
|
||||
<p>
|
||||
{intl.get('将删除历史运行实例')}: {data.runningInstances}
|
||||
</p>
|
||||
<p>
|
||||
{intl.get('将删除任务统计')}: {data.cronStats}
|
||||
</p>
|
||||
<p>
|
||||
{intl.get('将清除依赖缓存')}: {data.dependenceCaches.length} (
|
||||
{formatBytes(data.dependenceCacheBytes)})
|
||||
</p>
|
||||
{data.dependenceCaches.length > 0 && (
|
||||
<p>{intl.get('清理依赖缓存后相关依赖需要重新安装')}</p>
|
||||
)}
|
||||
{compactDatabase && (
|
||||
<p>{intl.get('数据库压缩期间可能暂时阻塞请求')}</p>
|
||||
)}
|
||||
<p>{intl.get('此操作不可恢复,请确认已完成必要备份')}</p>
|
||||
</div>
|
||||
),
|
||||
onOk: () => {
|
||||
setCleanupLoading(true);
|
||||
return request
|
||||
.post(`${config.apiPrefix}system/storage-retention/cleanup`, {
|
||||
...payload,
|
||||
confirmation: 'CLEAN',
|
||||
})
|
||||
.then(({ code }) => {
|
||||
if (code === 200) {
|
||||
message.success(intl.get('清理完成'));
|
||||
}
|
||||
})
|
||||
.finally(() => setCleanupLoading(false));
|
||||
},
|
||||
});
|
||||
})
|
||||
.finally(() => setCleanupLoading(false));
|
||||
};
|
||||
|
||||
const exportData = () => {
|
||||
setExportLoading(true);
|
||||
request
|
||||
@@ -232,6 +339,35 @@ const Other = ({
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={intl.get('面板标题')}
|
||||
name="panelTitle"
|
||||
tooltip={intl.get('自定义面板的站点标题,留空使用默认值“青龙”')}
|
||||
>
|
||||
<Input.Group compact>
|
||||
<Input
|
||||
style={{ width: 180 }}
|
||||
maxLength={100}
|
||||
value={systemConfig?.panelTitle || ''}
|
||||
placeholder={intl.get('留空使用默认值“青龙”')}
|
||||
onChange={(e) => {
|
||||
setSystemConfig({
|
||||
...systemConfig,
|
||||
panelTitle: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
updateSystemConfig('panel-title');
|
||||
}}
|
||||
style={{ width: 84 }}
|
||||
>
|
||||
{intl.get('确认')}
|
||||
</Button>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={intl.get('日志删除频率')}
|
||||
name="frequency"
|
||||
@@ -260,6 +396,69 @@ const Other = ({
|
||||
</Button>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={intl.get('历史数据保留与手动清理')}
|
||||
tooltip={intl.get('保留天数为0时禁用对应清理,预览不会删除数据')}
|
||||
>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<InputNumber
|
||||
addonBefore={intl.get('运行实例')}
|
||||
addonAfter={intl.get('天')}
|
||||
min={0}
|
||||
max={3650}
|
||||
value={systemConfig?.runningInstanceRetentionDays || 0}
|
||||
onChange={(value) => {
|
||||
setSystemConfig({
|
||||
...systemConfig,
|
||||
runningInstanceRetentionDays: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<InputNumber
|
||||
addonBefore={intl.get('任务统计')}
|
||||
addonAfter={intl.get('天')}
|
||||
min={0}
|
||||
max={3650}
|
||||
value={systemConfig?.cronStatRetentionDays || 0}
|
||||
onChange={(value) => {
|
||||
setSystemConfig({
|
||||
...systemConfig,
|
||||
cronStatRetentionDays: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Checkbox.Group
|
||||
value={dependenceCacheTypes}
|
||||
options={[
|
||||
{ label: intl.get('清除 Node 依赖缓存'), value: 'node' },
|
||||
{ label: intl.get('清除 Python 依赖缓存'), value: 'python3' },
|
||||
]}
|
||||
onChange={(value) => setDependenceCacheTypes(value as string[])}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Checkbox
|
||||
checked={compactDatabase}
|
||||
onChange={(event) => setCompactDatabase(event.target.checked)}
|
||||
>
|
||||
{intl.get('清理后压缩数据库')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Button onClick={saveRetentionPolicy} style={{ marginRight: 8 }}>
|
||||
{intl.get('保存设置')}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
loading={cleanupLoading}
|
||||
onClick={previewStorageCleanup}
|
||||
>
|
||||
{intl.get('预览清理')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item label={intl.get('定时任务并发数')} name="frequency">
|
||||
<Input.Group compact>
|
||||
<InputNumber
|
||||
@@ -375,7 +574,7 @@ const Other = ({
|
||||
showReloadModal();
|
||||
}
|
||||
if (file.status === 'error') {
|
||||
message.error('上传失败');
|
||||
message.error(intl.get('上传失败'));
|
||||
}
|
||||
}}
|
||||
name="data"
|
||||
|
||||
@@ -37,18 +37,18 @@ const SecuritySettings = ({ user, userChange }: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
const activeOrDeactiveTwoFactor = () => {
|
||||
const activeOrDeactivateTwoFactor = () => {
|
||||
if (twoFactorActivated) {
|
||||
deactiveTowFactor();
|
||||
deactivateTwoFactor();
|
||||
} else {
|
||||
getTwoFactorInfo();
|
||||
setTwoFactoring(true);
|
||||
}
|
||||
};
|
||||
|
||||
const deactiveTowFactor = () => {
|
||||
const deactivateTwoFactor = () => {
|
||||
request
|
||||
.put(`${config.apiPrefix}user/two-factor/deactive`)
|
||||
.put(`${config.apiPrefix}user/two-factor/deactivate`)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200 && data) {
|
||||
setTwoFactorActivated(false);
|
||||
@@ -60,7 +60,7 @@ const SecuritySettings = ({ user, userChange }: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
const completeTowFactor = () => {
|
||||
const completeTwoFactor = () => {
|
||||
setLoading(true);
|
||||
request
|
||||
.put(`${config.apiPrefix}user/two-factor/active`, { code })
|
||||
@@ -162,7 +162,7 @@ const SecuritySettings = ({ user, userChange }: any) => {
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="123456"
|
||||
/>
|
||||
<Button type="primary" loading={loading} onClick={completeTowFactor}>
|
||||
<Button type="primary" loading={loading} onClick={completeTwoFactor}>
|
||||
{intl.get('完成设置')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -230,7 +230,7 @@ const SecuritySettings = ({ user, userChange }: any) => {
|
||||
<Button
|
||||
type="primary"
|
||||
danger={twoFactorActivated}
|
||||
onClick={activeOrDeactiveTwoFactor}
|
||||
onClick={activeOrDeactivateTwoFactor}
|
||||
>
|
||||
{twoFactorActivated ? intl.get('禁用') : intl.get('启用')}
|
||||
</Button>
|
||||
|
||||
@@ -29,7 +29,9 @@ const SystemLog = ({ height, theme }: any) => {
|
||||
const { loading, refresh } = useRequest(
|
||||
() => {
|
||||
return request.get<Blob>(
|
||||
`${config.apiPrefix}system/log?startTime=${range[0]}&endTime=${range[1]}`,
|
||||
`${config.apiPrefix}system/log?startTime=${range[0]}&endTime=${
|
||||
range[1]
|
||||
}&limit=${1024 * 1024}`,
|
||||
{
|
||||
responseType: 'blob',
|
||||
},
|
||||
@@ -51,7 +53,7 @@ const SystemLog = ({ height, theme }: any) => {
|
||||
|
||||
const deleteLog = () => {
|
||||
request.delete(`${config.apiPrefix}system/log`).then((x) => {
|
||||
message.success('删除成功');
|
||||
message.success(intl.get('删除成功'));
|
||||
refresh();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ import isNil from 'lodash/isNil';
|
||||
|
||||
const { Option } = Select;
|
||||
const repoUrlRegx = /([^\/\:]+\/[^\/]+)(?=\.git)/;
|
||||
const fileUrlRegx = /([^\/\:]+\/[^\/\.]+)\.[a-z]+$/;
|
||||
const fileUrlRegx = /([^\/\:]+\/[^\/\.]+)\.[a-z]+(\?[^\s]*)?(#.*)?$/;
|
||||
|
||||
const SubscriptionModal = ({
|
||||
subscription,
|
||||
@@ -205,21 +205,47 @@ const SubscriptionModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
const parseArgs = (text: string): string[] => {
|
||||
const args: string[] = [];
|
||||
let current = '';
|
||||
let inDouble = false;
|
||||
let inSingle = false;
|
||||
let justClosed = false;
|
||||
for (const ch of text) {
|
||||
if (inDouble) {
|
||||
if (ch === '"') { inDouble = false; justClosed = true; continue; }
|
||||
current += ch;
|
||||
} else if (inSingle) {
|
||||
if (ch === "'") { inSingle = false; justClosed = true; continue; }
|
||||
current += ch;
|
||||
} else if (ch === '"') {
|
||||
inDouble = true;
|
||||
justClosed = false;
|
||||
} else if (ch === "'") {
|
||||
inSingle = true;
|
||||
justClosed = false;
|
||||
} else if (ch === ' ' || ch === '\t') {
|
||||
if (current || justClosed) { args.push(current); current = ''; justClosed = false; }
|
||||
} else {
|
||||
current += ch;
|
||||
justClosed = false;
|
||||
}
|
||||
}
|
||||
if (current || justClosed) args.push(current);
|
||||
return args;
|
||||
};
|
||||
|
||||
const onPaste = useCallback((e: any) => {
|
||||
const text = e.clipboardData.getData('text') as string;
|
||||
if (text.startsWith('ql ')) {
|
||||
const [
|
||||
,
|
||||
type,
|
||||
url,
|
||||
whitelist,
|
||||
blacklist,
|
||||
dependences,
|
||||
branch,
|
||||
extensions,
|
||||
] = text
|
||||
.split(' ')
|
||||
.map((x) => x.trim().replace(/\"/g, '').replace(/\'/, ''));
|
||||
const args = parseArgs(text);
|
||||
const type = args[1];
|
||||
const url = args[2] || '';
|
||||
const whitelist = args[3] || '';
|
||||
const blacklist = args[4] || '';
|
||||
const dependences = args[5] || '';
|
||||
const branch = args[6] || '';
|
||||
const extensions = args[7] || '';
|
||||
const _type =
|
||||
type === 'raw'
|
||||
? 'file'
|
||||
|
||||
+58
-23
@@ -15,29 +15,6 @@ export default {
|
||||
exclude: [/(\/(en|zh))*\/login/],
|
||||
},
|
||||
],
|
||||
|
||||
/* I18n configuration, `languages` and `defaultLanguage` are required currently. */
|
||||
i18n: {
|
||||
/* Countrys flags: https://www.flaticon.com/packs/countrys-flags */
|
||||
languages: [
|
||||
{
|
||||
key: 'pt-br',
|
||||
title: 'Português',
|
||||
flag: '/portugal.svg',
|
||||
},
|
||||
{
|
||||
key: 'en',
|
||||
title: 'English',
|
||||
flag: '/america.svg',
|
||||
},
|
||||
{
|
||||
key: 'zh',
|
||||
title: intl.get('中文'),
|
||||
flag: '/china.svg',
|
||||
},
|
||||
],
|
||||
defaultLanguage: 'en',
|
||||
},
|
||||
scopes: [
|
||||
{
|
||||
name: intl.get('定时任务'),
|
||||
@@ -71,6 +48,10 @@ export default {
|
||||
name: intl.get('系统信息'),
|
||||
value: 'system',
|
||||
},
|
||||
{
|
||||
name: intl.get('仪表盘'),
|
||||
value: 'dashboard',
|
||||
},
|
||||
],
|
||||
scopesMap: {
|
||||
crons: intl.get('定时任务'),
|
||||
@@ -81,6 +62,7 @@ export default {
|
||||
logs: intl.get('日志管理'),
|
||||
dependencies: intl.get('依赖管理'),
|
||||
system: intl.get('系统信息'),
|
||||
dashboard: intl.get('仪表盘'),
|
||||
},
|
||||
notificationModes: [
|
||||
{ value: 'gotify', label: 'Gotify' },
|
||||
@@ -98,6 +80,9 @@ export default {
|
||||
{ value: 'pushPlus', label: 'PushPlus' },
|
||||
{ value: 'wePlusBot', label: intl.get('微加机器人') },
|
||||
{ value: 'wxPusherBot', label: 'wxPusher' },
|
||||
{ value: 'wxPusherSpt', label: 'WxPusher(极简推送SPT-推荐)' },
|
||||
{ value: 'openiLink', label: 'OpeniLink' },
|
||||
{ value: 'wpush', label: 'WPUSH' },
|
||||
{ value: 'chat', label: intl.get('群晖chat') },
|
||||
{ value: 'email', label: intl.get('邮箱') },
|
||||
{ value: 'lark', label: intl.get('飞书机器人') },
|
||||
@@ -387,6 +372,55 @@ export default {
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
wxPusherSpt: [
|
||||
{
|
||||
label: 'wxPusherSptList',
|
||||
tip: intl.get('wxPusherSpt的SPT'),
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
wpush: [
|
||||
{
|
||||
label: 'wpushApiKey',
|
||||
tip: intl.get(
|
||||
'WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs',
|
||||
),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'wpushChannel',
|
||||
tip: intl.get(
|
||||
'推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat',
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'wpushTopicCode',
|
||||
tip: intl.get(
|
||||
'可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs',
|
||||
),
|
||||
},
|
||||
],
|
||||
openiLink: [
|
||||
{
|
||||
label: 'openiLinkAppToken',
|
||||
tip: intl.get(
|
||||
'OpeniLink的app_token,在OpeniLink Hub后台安装App后获取,参考 https://openilink.com/docs/hub/apps',
|
||||
),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'openiLinkHubUrl',
|
||||
tip: intl.get(
|
||||
'OpeniLink Hub地址,默认为 https://hub.openilink.com,自建Hub时填写自己的地址',
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'openiLinkContextToken',
|
||||
tip: intl.get(
|
||||
'OpeniLink的context_token,用于标识消息会话上下文,可从消息事件中获取',
|
||||
),
|
||||
},
|
||||
],
|
||||
lark: [
|
||||
{
|
||||
label: 'larkKey',
|
||||
@@ -513,6 +547,7 @@ export default {
|
||||
'/setting': intl.get('系统设置'),
|
||||
'/error': intl.get('错误日志'),
|
||||
'/dependence': intl.get('依赖管理'),
|
||||
'/dashboard': intl.get('仪表盘'),
|
||||
},
|
||||
dependenceTypes: ['nodejs', 'python3', 'linux'],
|
||||
};
|
||||
|
||||
+9
-8
@@ -9,8 +9,9 @@ import axios, {
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import { getErrorDetails, ValidationErrorResponse } from './httpError';
|
||||
|
||||
export interface IResponseData {
|
||||
export interface IResponseData extends ValidationErrorResponse {
|
||||
code?: number;
|
||||
data?: any;
|
||||
message?: string;
|
||||
@@ -43,13 +44,14 @@ const errorHandler = function (
|
||||
const msg = error.response.data
|
||||
? error.response.data.message || error.message
|
||||
: error.response.statusText;
|
||||
const errorDetails = getErrorDetails(error.response.data);
|
||||
const responseStatus = error.response.status;
|
||||
if ([502, 504].includes(responseStatus)) {
|
||||
history.push('/error');
|
||||
} else if (responseStatus === 401) {
|
||||
localStorage.removeItem(config.authKey);
|
||||
if (history.location.pathname !== '/login') {
|
||||
message.error(intl.get('登录已过期,请重新登录'));
|
||||
localStorage.removeItem(config.authKey);
|
||||
history.push('/login');
|
||||
}
|
||||
} else {
|
||||
@@ -60,12 +62,10 @@ const errorHandler = function (
|
||||
msg &&
|
||||
notification.error({
|
||||
message: msg,
|
||||
description: error.response?.data?.errors ? (
|
||||
description: errorDetails.length ? (
|
||||
<>
|
||||
{error.response?.data?.errors?.map((item: any) => (
|
||||
<div>
|
||||
{item.message} ({item.value})
|
||||
</div>
|
||||
{errorDetails.map((detail, index) => (
|
||||
<div key={`${index}-${detail}`}>{detail}</div>
|
||||
))}
|
||||
</>
|
||||
) : undefined,
|
||||
@@ -84,6 +84,7 @@ let _request = axios.create({
|
||||
});
|
||||
|
||||
const apiWhiteList = [
|
||||
`${config.baseUrl}api/health`,
|
||||
`${config.baseUrl}api/user/login`,
|
||||
`${config.baseUrl}open/auth/token`,
|
||||
`${config.baseUrl}api/user/two-factor/login`,
|
||||
@@ -106,8 +107,8 @@ _request.interceptors.response.use(async (response) => {
|
||||
if ([502, 504].includes(responseStatus)) {
|
||||
history.push('/error');
|
||||
} else if (responseStatus === 401) {
|
||||
localStorage.removeItem(config.authKey);
|
||||
if (history.location.pathname !== '/login') {
|
||||
localStorage.removeItem(config.authKey);
|
||||
history.push('/login');
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface ValidationErrorResponse {
|
||||
errors?: Array<{ message?: string; value?: unknown }>;
|
||||
validation?: Record<
|
||||
string,
|
||||
{ source?: string; keys?: string[]; message?: string }
|
||||
>;
|
||||
}
|
||||
|
||||
export const getErrorDetails = (data?: ValidationErrorResponse) => {
|
||||
const details =
|
||||
data?.errors?.map((item) =>
|
||||
item.value === undefined
|
||||
? item.message
|
||||
: `${item.message} (${String(item.value)})`,
|
||||
) || [];
|
||||
|
||||
Object.values(data?.validation || {}).forEach((validation) => {
|
||||
if (validation.keys?.length) {
|
||||
validation.keys.forEach((key) => {
|
||||
details.push(
|
||||
validation.message ? `${key}: ${validation.message}` : key,
|
||||
);
|
||||
});
|
||||
} else if (validation.message) {
|
||||
details.push(validation.message);
|
||||
}
|
||||
});
|
||||
|
||||
return details.filter((detail): detail is string => Boolean(detail));
|
||||
};
|
||||
+3
-3
@@ -179,7 +179,7 @@ export default function browserType() {
|
||||
/**
|
||||
* 获取第一个表格的可视化高度
|
||||
* @param {*} extraHeight 额外的高度(表格底部的内容高度 Number类型,默认为74)
|
||||
* @param {*} id 当前页面中有多个table时需要制定table的id
|
||||
* @param {*} id 当前页面中有多个table时需要指定table的id
|
||||
*/
|
||||
export function getTableScroll({
|
||||
extraHeight,
|
||||
@@ -208,7 +208,7 @@ export function getTableScroll({
|
||||
}
|
||||
|
||||
// 自动触发点击事件
|
||||
function automaticClick(elment: HTMLElement) {
|
||||
function automaticClick(element: HTMLElement) {
|
||||
const ev = document.createEvent('MouseEvents');
|
||||
ev.initMouseEvent(
|
||||
'click',
|
||||
@@ -227,7 +227,7 @@ function automaticClick(elment: HTMLElement) {
|
||||
0,
|
||||
null,
|
||||
);
|
||||
elment.dispatchEvent(ev);
|
||||
element.dispatchEvent(ev);
|
||||
}
|
||||
|
||||
// 导出文件
|
||||
|
||||
@@ -10,6 +10,12 @@ interface FileTypeConfig {
|
||||
|
||||
// 文件类型分类配置(只包含特殊文件类型)
|
||||
const fileTypeConfigs: Record<string, FileTypeConfig> = {
|
||||
config: {
|
||||
// Keep common text configuration files previewable even when a Monaco
|
||||
// build has not registered its optional INI language contribution yet.
|
||||
extensions: ['.ini'],
|
||||
},
|
||||
|
||||
// 前端特殊文件
|
||||
frontend: {
|
||||
extensions: [
|
||||
|
||||
Reference in New Issue
Block a user