feat: 计划任务支持自定义表达式,预览最近五次执行时间 (#6685)
Some checks failed
sync2gitee / repo-sync (push) Has been cancelled

Refs #4831
This commit is contained in:
ssongliu 2024-10-10 22:11:01 +08:00 committed by GitHub
parent 1184c7d60e
commit 72311617d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 264 additions and 110 deletions

View file

@ -32,6 +32,28 @@ func (b *BaseApi) CreateCronjob(c *gin.Context) {
helper.SuccessWithData(c, nil)
}
// @Tags Cronjob
// @Summary Load cronjob spec time
// @Description 预览最近五次执行时间
// @Accept json
// @Param request body dto.CronjobSpec true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /cronjobs/next [post]
func (b *BaseApi) LoadNextHandle(c *gin.Context) {
var req dto.CronjobSpec
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
list, err := cronjobService.LoadNextHandle(req.Spec)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, list)
}
// @Tags Cronjob
// @Summary Page cronjobs
// @Description 获取计划任务分页

View file

@ -11,10 +11,15 @@ type PageCronjob struct {
Order string `json:"order" validate:"required,oneof=null ascending descending"`
}
type CronjobCreate struct {
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required"`
type CronjobSpec struct {
Spec string `json:"spec" validate:"required"`
}
type CronjobCreate struct {
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required"`
SpecCustom bool `json:"specCustom"`
Spec string `json:"spec" validate:"required"`
Script string `json:"script"`
Command string `json:"command"`
@ -34,9 +39,10 @@ type CronjobCreate struct {
}
type CronjobUpdate struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Spec string `json:"spec" validate:"required"`
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
SpecCustom bool `json:"specCustom"`
Spec string `json:"spec" validate:"required"`
Script string `json:"script"`
Command string `json:"command"`
@ -77,10 +83,11 @@ type CronjobBatchDelete struct {
}
type CronjobInfo struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Spec string `json:"spec"`
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
SpecCustom bool `json:"specCustom"`
Spec string `json:"spec"`
Script string `json:"script"`
Command string `json:"command"`

View file

@ -9,9 +9,10 @@ import (
type Cronjob struct {
BaseModel
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"`
Spec string `gorm:"not null" json:"spec"`
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"`
SpecCustom bool `json:"specCustom"`
Spec string `gorm:"not null" json:"spec"`
Command string `json:"command"`
ContainerName string `json:"containerName"`

View file

@ -24,6 +24,7 @@ type ICronjobService interface {
SearchWithPage(search dto.PageCronjob) (int64, interface{}, error)
SearchRecords(search dto.SearchRecord) (int64, interface{}, error)
Create(cronjobDto dto.CronjobCreate) error
LoadNextHandle(spec string) ([]string, error)
HandleOnce(id uint) error
Update(id uint, req dto.CronjobUpdate) error
UpdateStatus(id uint, status string) error
@ -77,6 +78,23 @@ func (u *CronjobService) SearchRecords(search dto.SearchRecord) (int64, interfac
return total, dtoCronjobs, err
}
func (u *CronjobService) LoadNextHandle(specStr string) ([]string, error) {
spec := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
sched, err := spec.Parse(specStr)
if err != nil {
return nil, err
}
now := time.Now()
var nexts [5]string
for i := 0; i < 5; i++ {
nextTime := sched.Next(now)
nexts[i] = nextTime.Format("2006-01-02 15:04:05")
fmt.Println(nextTime)
now = nextTime
}
return nexts[:], nil
}
func (u *CronjobService) LoadRecordLog(req dto.OperateByID) string {
record, err := cronjobRepo.GetRecord(commonRepo.WithByID(req.ID))
if err != nil {
@ -261,6 +279,7 @@ func (u *CronjobService) Update(id uint, req dto.CronjobUpdate) error {
}
upMap["name"] = req.Name
upMap["spec_custom"] = req.SpecCustom
upMap["spec"] = spec
upMap["script"] = req.Script
upMap["command"] = req.Command

View file

@ -16,7 +16,7 @@ import (
)
var AddTable = &gormigrate.Migration{
ID: "20240903-add-table",
ID: "20241009-add-table",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
&model.AppDetail{},

View file

@ -12,6 +12,7 @@ func (s *CronjobRouter) InitRouter(Router *gin.RouterGroup) {
baseApi := v2.ApiGroupApp.BaseApi
{
cmdRouter.POST("", baseApi.CreateCronjob)
cmdRouter.POST("/next", baseApi.LoadNextHandle)
cmdRouter.POST("/del", baseApi.DeleteCronjob)
cmdRouter.POST("/update", baseApi.UpdateCronjob)
cmdRouter.POST("/status", baseApi.UpdateCronjobStatus)

View file

@ -5,7 +5,9 @@ export namespace Cronjob {
id: number;
name: string;
type: string;
specCustom: boolean;
spec: string;
specs: Array<string>;
specObjs: Array<SpecObj>;
script: string;
@ -31,6 +33,7 @@ export namespace Cronjob {
export interface CronjobCreate {
name: string;
type: string;
specCustom: boolean;
spec: string;
specObjs: Array<SpecObj>;
@ -57,6 +60,7 @@ export namespace Cronjob {
}
export interface CronjobUpdate {
id: number;
specCustom: boolean;
spec: string;
script: string;

View file

@ -7,6 +7,10 @@ export const getCronjobPage = (params: SearchWithPage) => {
return http.post<ResPage<Cronjob.CronjobInfo>>(`/cronjobs/search`, params);
};
export const loadNextHandle = (spec: string) => {
return http.post<Array<String>>(`/cronjobs/next`, { spec: spec });
};
export const getRecordLog = (id: number) => {
return http.post<string>(`/cronjobs/records/log`, { id: id });
};

View file

@ -857,6 +857,7 @@ const message = {
enableMsg:
'Enabling the scheduled task will allow the task to automatically execute on a regular basis. Do you want to continue?',
taskType: 'Type',
nextTime: 'Next 5 executions',
record: 'Records',
shell: 'Shell',
log: 'Backup Logs',
@ -902,6 +903,7 @@ const message = {
retainCopiesHelper1: 'Number of copies to retain for backup files',
retainCopiesUnit: ' copies (View)',
cronSpecRule: 'The execution period format in line {0} is incorrect. Please check and try again!',
cronSpecRule2: 'Execution period format is incorrect, please check and try again!',
perMonth: 'Every monthly',
perWeek: 'Every week',
perHour: 'Every hour',

View file

@ -818,6 +818,7 @@ const message = {
disableMsg: '停止計劃任務會導致該任務不再自動執行是否繼續',
enableMsg: '啟用計劃任務會讓該任務定期自動執行是否繼續',
taskType: '任務類型',
nextTime: ' 5 次執行',
record: '報告',
shell: 'Shell 腳本',
log: '備份日誌',
@ -857,6 +858,7 @@ const message = {
retainCopiesHelper1: '備份文件保留份数',
retainCopiesUnit: ' (查看)',
cronSpecRule: ' {0} 行中執行週期格式錯誤請檢查後重試',
cronSpecRule2: '執行週期格式錯誤請檢查後重試',
perMonth: '每月',
perWeek: '每周',
perHour: '每小時',

View file

@ -819,6 +819,7 @@ const message = {
disableMsg: '停止计划任务会导致该任务不再自动执行是否继续',
enableMsg: '启用计划任务会让该任务定期自动执行是否继续',
taskType: '任务类型',
nextTime: ' 5 次执行',
record: '报告',
shell: 'Shell 脚本',
log: '备份日志',
@ -858,6 +859,7 @@ const message = {
retainCopiesHelper1: '备份文件保留份数',
retainCopiesUnit: ' (查看)',
cronSpecRule: ' {0} 行中执行周期格式错误请检查后重试',
cronSpecRule2: '执行周期格式错误请检查后重试',
perMonth: '每月',
perWeek: '每周',
perHour: '每小时',

View file

@ -39,35 +39,22 @@ export function loadDefaultSpec(type: string) {
item.second = 0;
switch (type) {
case 'shell':
case 'clean':
case 'website':
case 'log':
case 'snapshot':
case 'curl':
item.specType = 'perWeek';
item.week = 1;
item.hour = 1;
item.minute = 30;
break;
case 'app':
item.specType = 'perDay';
item.hour = 2;
item.minute = 30;
break;
case 'database':
item.specType = 'perDay';
item.hour = 2;
item.minute = 30;
break;
case 'clean':
case 'website':
item.specType = 'perWeek';
item.week = 1;
item.hour = 1;
item.minute = 30;
break;
case 'log':
case 'snapshot':
item.specType = 'perWeek';
item.week = 1;
item.hour = 1;
item.minute = 30;
break;
case 'directory':
case 'cutWebsiteLog':
case 'ntp':
@ -75,16 +62,31 @@ export function loadDefaultSpec(type: string) {
item.hour = 1;
item.minute = 30;
break;
case 'curl':
item.specType = 'perWeek';
item.week = 1;
item.hour = 1;
item.minute = 30;
break;
}
return item;
}
export function loadDefaultSpecCustom(type: string) {
switch (type) {
case 'shell':
case 'clean':
case 'website':
case 'log':
case 'snapshot':
case 'curl':
return '30 1 * * 1';
case 'app':
case 'database':
return '30 2 * * *';
case 'directory':
case 'cutWebsiteLog':
case 'ntp':
return '30 1 * * *';
default:
return '30 1 * * 1';
}
}
export function transObjToSpec(specType: string, week, day, hour, minute, second): string {
switch (specType) {
case 'perMonth':

View file

@ -79,7 +79,7 @@
<div v-for="(item, index) of row.spec.split(',')" :key="index">
<div v-if="row.expand || (!row.expand && index < 3)">
<span>
{{ transSpecToStr(item) }}
{{ row.specCustom ? item : transSpecToStr(item) }}
</span>
</div>
</div>

View file

@ -68,67 +68,113 @@
<el-input :disabled="dialogData.title === 'edit'" clearable v-model.trim="dialogData.rowData!.name" />
</el-form-item>
<el-form-item :label="$t('cronjob.cronSpec')" prop="spec">
<div v-for="(specObj, index) of dialogData.rowData.specObjs" :key="index" style="width: 100%">
<el-select class="specTypeClass" v-model="specObj.specType" @change="changeSpecType(index)">
<el-option
v-for="item in specOptions"
:key="item.label"
:value="item.value"
:label="item.label"
/>
</el-select>
<el-select v-if="specObj.specType === 'perWeek'" class="specClass" v-model="specObj.week">
<el-option
v-for="item in weekOptions"
:key="item.label"
:value="item.value"
:label="item.label"
/>
</el-select>
<el-input v-if="hasDay(specObj)" class="specClass" v-model.number="specObj.day">
<template #append>
<div class="append">{{ $t('cronjob.day') }}</div>
</template>
</el-input>
<el-input v-if="hasHour(specObj)" class="specClass" v-model.number="specObj.hour">
<template #append>
<div class="append">{{ $t('commons.units.hour') }}</div>
</template>
</el-input>
<el-input
v-if="specObj.specType !== 'perNSecond'"
class="specClass"
v-model.number="specObj.minute"
>
<template #append>
<div class="append">{{ $t('commons.units.minute') }}</div>
</template>
</el-input>
<el-input
v-if="specObj.specType === 'perNSecond'"
class="specClass"
v-model.number="specObj.second"
>
<template #append>
<div class="append">{{ $t('commons.units.second') }}</div>
</template>
</el-input>
<el-button
class="ml-2.5"
link
type="primary"
@click="handleSpecDelete(index)"
v-if="dialogData.rowData.specObjs.length > 1"
>
{{ $t('commons.button.delete') }}
</el-button>
<el-divider v-if="dialogData.rowData.specObjs.length > 1" class="divider" />
</div>
<el-form-item :label="$t('cronjob.cronSpec')" prop="specCustom">
<el-checkbox :label="$t('container.custom')" v-model="dialogData.rowData!.specCustom" />
</el-form-item>
<el-button class="mb-3" @click="handleSpecAdd()">
{{ $t('commons.button.add') }}
</el-button>
<div v-if="!dialogData.rowData!.specCustom">
<el-form-item prop="spec">
<div v-for="(specObj, index) of dialogData.rowData.specObjs" :key="index" style="width: 100%">
<el-select class="specTypeClass" v-model="specObj.specType" @change="changeSpecType(index)">
<el-option
v-for="item in specOptions"
:key="item.label"
:value="item.value"
:label="item.label"
/>
</el-select>
<el-select v-if="specObj.specType === 'perWeek'" class="specClass" v-model="specObj.week">
<el-option
v-for="item in weekOptions"
:key="item.label"
:value="item.value"
:label="item.label"
/>
</el-select>
<el-input v-if="hasDay(specObj)" class="specClass" v-model.number="specObj.day">
<template #append>
<div class="append">{{ $t('cronjob.day') }}</div>
</template>
</el-input>
<el-input v-if="hasHour(specObj)" class="specClass" v-model.number="specObj.hour">
<template #append>
<div class="append">{{ $t('commons.units.hour') }}</div>
</template>
</el-input>
<el-input
v-if="specObj.specType !== 'perNSecond'"
class="specClass"
v-model.number="specObj.minute"
>
<template #append>
<div class="append">{{ $t('commons.units.minute') }}</div>
</template>
</el-input>
<el-input
v-if="specObj.specType === 'perNSecond'"
class="specClass"
v-model.number="specObj.second"
>
<template #append>
<div class="append">{{ $t('commons.units.second') }}</div>
</template>
</el-input>
<el-popover placement="top-start" :title="$t('cronjob.nextTime')" width="200" trigger="click">
<div v-for="(time, index_t) of nextTimes" :key="index_t">
<el-tag class="mt-2">{{ time }}</el-tag>
</div>
<template #reference>
<el-button class="ml-2.5" @click="loadNext(specObj)" link type="primary">
{{ $t('commons.button.preview') }}
</el-button>
</template>
</el-popover>
<el-button
class="ml-2.5"
link
type="primary"
@click="handleSpecDelete(index)"
v-if="dialogData.rowData.specObjs.length > 1"
>
{{ $t('commons.button.delete') }}
</el-button>
<el-divider v-if="dialogData.rowData.specObjs.length > 1" class="divider" />
</div>
</el-form-item>
<el-button class="mb-3" @click="handleSpecAdd()">
{{ $t('commons.button.add') }}
</el-button>
</div>
<div v-if="dialogData.rowData!.specCustom">
<el-form-item prop="spec">
<div v-for="(spec, index) of dialogData.rowData.specs" :key="index" style="width: 100%">
<el-input style="width: 80%" v-model="dialogData.rowData.specs[index]" />
<el-popover placement="top-start" :title="$t('cronjob.nextTime')" width="200" trigger="click">
<div v-for="(time, index_t) of nextTimes" :key="index_t">
<el-tag class="mt-2">{{ time }}</el-tag>
</div>
<template #reference>
<el-button class="ml-2.5" @click="loadNext(spec)" link type="primary">
{{ $t('commons.button.preview') }}
</el-button>
</template>
</el-popover>
<el-button
class="ml-2.5"
link
type="primary"
@click="handleSpecCustomDelete(index)"
v-if="dialogData.rowData.specs.length > 1"
>
{{ $t('commons.button.delete') }}
</el-button>
<el-divider v-if="dialogData.rowData.specs.length > 1" class="divider" />
</div>
</el-form-item>
<el-button class="mb-3" @click="handleSpecCustomAdd()">
{{ $t('commons.button.add') }}
</el-button>
</div>
<el-form-item v-if="hasScript()">
<el-checkbox v-model="dialogData.rowData!.inContainer">
@ -336,7 +382,7 @@ import { getBackupList } from '@/api/modules/backup';
import i18n from '@/lang';
import { ElForm } from 'element-plus';
import { Cronjob } from '@/api/interface/cronjob';
import { addCronjob, editCronjob } from '@/api/modules/cronjob';
import { addCronjob, editCronjob, loadNextHandle } from '@/api/modules/cronjob';
import { listDbItems } from '@/api/modules/database';
import { GetWebsiteOptions } from '@/api/modules/website';
import { MsgError, MsgSuccess } from '@/utils/message';
@ -344,7 +390,14 @@ import { useRouter } from 'vue-router';
import { listContainer } from '@/api/modules/container';
import { Database } from '@/api/interface/database';
import { ListAppInstalled } from '@/api/modules/app';
import { loadDefaultSpec, specOptions, transObjToSpec, transSpecToObj, weekOptions } from './../helper';
import {
loadDefaultSpec,
loadDefaultSpecCustom,
specOptions,
transObjToSpec,
transSpecToObj,
weekOptions,
} from './../helper';
const router = useRouter();
interface DialogProps {
@ -357,16 +410,22 @@ const drawerVisible = ref(false);
const dialogData = ref<DialogProps>({
title: '',
});
const nextTimes = ref([]);
const acceptParams = (params: DialogProps): void => {
dialogData.value = params;
if (dialogData.value.rowData?.spec) {
if (!dialogData.value.rowData?.specCustom && dialogData.value.rowData?.spec) {
let objs = [];
for (const item of dialogData.value.rowData.spec.split(',')) {
objs.push(transSpecToObj(item));
}
dialogData.value.rowData.specObjs = objs;
}
dialogData.value.rowData.specObjs = dialogData.value.rowData.specObjs || [];
if (dialogData.value.rowData?.specCustom && dialogData.value.rowData?.spec) {
dialogData.value.rowData.specs = dialogData.value.rowData.spec.split(',');
}
dialogData.value.rowData.specs = dialogData.value.rowData.specs || [];
if (dialogData.value.title === 'create') {
changeType();
dialogData.value.rowData.dbType = 'mysql';
@ -548,6 +607,22 @@ const hasHour = (item: any) => {
return item.specType !== 'perHour' && item.specType !== 'perNMinute' && item.specType !== 'perNSecond';
};
const loadNext = async (spec: any) => {
nextTimes.value = [];
let specItem = '';
if (!dialogData.value.rowData.specCustom) {
specItem = transObjToSpec(spec.specType, spec.week, spec.day, spec.hour, spec.minute, spec.second);
} else {
specItem = spec;
}
if (!specItem) {
MsgError(i18n.global.t('cronjob.cronSpecRule2'));
return;
}
const data = await loadNextHandle(specItem);
nextTimes.value = data.data || [];
};
const loadDatabases = async (dbType: string) => {
const data = await listDbItems(dbType);
dbInfo.dbs = data.data || [];
@ -555,6 +630,7 @@ const loadDatabases = async (dbType: string) => {
const changeType = () => {
dialogData.value.rowData!.specObjs = [loadDefaultSpec(dialogData.value.rowData.type)];
dialogData.value.rowData!.specs = [loadDefaultSpecCustom(dialogData.value.rowData.type)];
};
const changeSpecType = (index: number) => {
@ -598,10 +674,18 @@ const handleSpecAdd = () => {
dialogData.value.rowData!.specObjs.push(item);
};
const handleSpecCustomAdd = () => {
dialogData.value.rowData!.specs.push('');
};
const handleSpecDelete = (index: number) => {
dialogData.value.rowData!.specObjs.splice(index, 1);
};
const handleSpecCustomDelete = (index: number) => {
dialogData.value.rowData!.specs.splice(index, 1);
};
const loadBackups = async () => {
const res = await getBackupList();
backupOptions.value = [];
@ -679,14 +763,18 @@ function hasScript() {
}
const onSubmit = async (formEl: FormInstance | undefined) => {
const specs = [];
for (const item of dialogData.value.rowData.specObjs) {
const itemSpec = transObjToSpec(item.specType, item.week, item.day, item.hour, item.minute, item.second);
if (itemSpec === '') {
MsgError(i18n.global.t('cronjob.cronSpecHelper'));
return;
let specs = [];
if (!dialogData.value.rowData.specCustom) {
for (const item of dialogData.value.rowData.specObjs) {
const itemSpec = transObjToSpec(item.specType, item.week, item.day, item.hour, item.minute, item.second);
if (itemSpec === '') {
MsgError(i18n.global.t('cronjob.cronSpecHelper'));
return;
}
specs.push(itemSpec);
}
specs.push(itemSpec);
} else {
specs = dialogData.value.rowData.specs;
}
dialogData.value.rowData.sourceAccountIDs = dialogData.value.rowData.sourceAccounts.join(',');
dialogData.value.rowData.spec = specs.join(',');
@ -719,7 +807,7 @@ defineExpose({
</script>
<style scoped lang="scss">
.specClass {
width: 20% !important;
width: 17% !important;
margin-left: 20px;
.append {
width: 20px;