fix(notification): bound audit logs without breaking delivery

This commit is contained in:
whyour
2026-10-12 00:23:20 +08:00
492 changed files with 56188 additions and 5037 deletions
+1 -3
View File
@@ -66,9 +66,7 @@ const EditableTagGroup = ({
}, [inputVisible]);
useEffect(() => {
if (value) {
setTags(value);
}
setTags(value || []);
}, [value]);
return (
+4 -4
View File
@@ -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) => {
+7 -1
View File
@@ -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('定时任务'),
+1
View File
@@ -212,6 +212,7 @@ body {
&.crontab-wrapper,
&.log-wrapper,
&.env-wrapper,
&.dashboard-wrapper,
&.config-wrapper {
.CodeMirror {
width: calc(100vw - 24px);
+54 -10
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+672 -523
View File
File diff suppressed because it is too large Load Diff
+199 -95
View File
@@ -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}
/>
)}
+25 -7
View File
@@ -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}
+58 -24
View File
@@ -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]);
+14 -2
View File
@@ -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('执行前')}
+2 -2
View File
@@ -1,8 +1,8 @@
export enum CrontabStatus {
'running' = 0,
'queued' = 0.5,
'idle' = 1,
'disabled',
'disabled' = 2,
'queued' = 3,
}
export enum OperationName {
+1
View File
@@ -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 })),
};
+384
View File
@@ -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;
+127
View File
@@ -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)} />
)}
</>
);
}
+24 -40
View File
@@ -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(() => {
+15 -11
View File
@@ -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
View File
@@ -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);
+52 -3
View File
@@ -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>
);
};
+98 -5
View File
@@ -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>
);
};
+2 -2
View File
@@ -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')}
+3 -3
View File
@@ -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(
+5 -5
View File
@@ -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;
}
}
+333
View File
@@ -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
View File
@@ -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);
}}
+16 -12
View File
@@ -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 (
+74
View File
@@ -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;
}
+14 -10
View File
@@ -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(() => {
+292
View File
@@ -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;
+6 -9
View File
@@ -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({
+12 -2
View File
@@ -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}
/>
),
},
{
+93 -54
View File
@@ -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 }}
/>
+1 -1
View File
@@ -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
View File
@@ -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"
+7 -7
View File
@@ -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>
+4 -2
View File
@@ -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();
});
};
+39 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+30
View File
@@ -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
View File
@@ -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);
}
// 导出文件
+6
View File
@@ -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: [