feat: support group management for cronjob (#9735)

Refs #9258
This commit is contained in:
ssongliu 2025-07-30 12:08:50 +08:00 committed by GitHub
parent 951ab2502e
commit a801140a82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 219 additions and 25 deletions

View file

@ -270,6 +270,28 @@ func (b *BaseApi) UpdateCronjob(c *gin.Context) {
helper.Success(c)
}
// @Tags Cronjob
// @Summary Update cronjob group
// @Accept json
// @Param request body dto.ChangeGroup true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /cronjobs/group/update [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"cronjobs","output_column":"name","output_value":"name"}],"formatZH":"更新计划任务分组 [name]","formatEN":"update cronjob group [name]"}
func (b *BaseApi) UpdateCronjobGroup(c *gin.Context) {
var req dto.ChangeGroup
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := cronjobService.UpdateGroup(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Cronjob
// @Summary Update cronjob status
// @Accept json

View file

@ -79,3 +79,8 @@ type ForceDelete struct {
IDs []uint `json:"ids"`
ForceDelete bool `json:"forceDelete"`
}
type ChangeGroup struct {
ID uint `json:"id" validate:"required"`
GroupID uint `json:"groupID" validate:"required"`
}

View file

@ -6,9 +6,10 @@ import (
type PageCronjob struct {
PageInfo
Info string `json:"info"`
OrderBy string `json:"orderBy" validate:"required,oneof=name status createdAt"`
Order string `json:"order" validate:"required,oneof=null ascending descending"`
Info string `json:"info"`
GroupIDs []uint `json:"groupIDs"`
OrderBy string `json:"orderBy" validate:"required,oneof=name status createdAt"`
Order string `json:"order" validate:"required,oneof=null ascending descending"`
}
type CronjobSpec struct {
@ -19,6 +20,7 @@ type CronjobOperate struct {
ID uint `json:"id"`
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required"`
GroupID uint `json:"groupID"`
SpecCustom bool `json:"specCustom"`
Spec string `json:"spec" validate:"required"`
@ -85,6 +87,7 @@ type CronjobInfo struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
GroupID uint `json:"groupID"`
SpecCustom bool `json:"specCustom"`
Spec string `json:"spec"`
@ -129,6 +132,7 @@ type CronjobImport struct {
type CronjobTrans struct {
Name string `json:"name"`
Type string `json:"type"`
GroupID uint `json:"groupID"`
SpecCustom bool `json:"specCustom"`
Spec string `json:"spec"`

View file

@ -11,6 +11,7 @@ type Cronjob struct {
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"`
GroupID uint `json:"groupID"`
SpecCustom bool `json:"specCustom"`
Spec string `gorm:"not null" json:"spec"`

View file

@ -104,6 +104,15 @@ func WithByDate(startTime, endTime time.Time) DBOption {
}
}
func WithByGroups(groupIDs []uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
if len(groupIDs) == 0 {
return g
}
return g.Where("group_id in (?)", groupIDs)
}
}
func WithByCreatedAt(startTime, endTime time.Time) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("created_at > ? AND created_at < ?", startTime, endTime)

View file

@ -32,6 +32,7 @@ type ICronjobService interface {
HandleOnce(id uint) error
Update(id uint, req dto.CronjobOperate) error
UpdateStatus(id uint, status string) error
UpdateGroup(req dto.ChangeGroup) error
Delete(req dto.CronjobBatchDelete) error
Download(down dto.CronjobDownload) (string, error)
StartJob(cronjob *model.Cronjob, isUpdate bool) (string, error)
@ -50,7 +51,11 @@ func NewICronjobService() ICronjobService {
}
func (u *CronjobService) SearchWithPage(search dto.PageCronjob) (int64, interface{}, error) {
total, cronjobs, err := cronjobRepo.Page(search.Page, search.PageSize, repo.WithByLikeName(search.Info), repo.WithOrderRuleBy(search.OrderBy, search.Order))
total, cronjobs, err := cronjobRepo.Page(search.Page,
search.PageSize,
repo.WithByGroups(search.GroupIDs),
repo.WithByLikeName(search.Info),
repo.WithOrderRuleBy(search.OrderBy, search.Order))
var dtoCronjobs []dto.CronjobInfo
for _, cronjob := range cronjobs {
var item dto.CronjobInfo
@ -116,6 +121,7 @@ func (u *CronjobService) Export(req dto.OperateByIDs) (string, error) {
item := dto.CronjobTrans{
Name: cronjob.Name,
Type: cronjob.Type,
GroupID: cronjob.GroupID,
SpecCustom: cronjob.SpecCustom,
Spec: cronjob.Spec,
Executor: cronjob.Executor,
@ -211,6 +217,7 @@ func (u *CronjobService) Import(req []dto.CronjobTrans) error {
cronjob := model.Cronjob{
Name: item.Name,
Type: item.Type,
GroupID: item.GroupID,
SpecCustom: item.SpecCustom,
Spec: item.Spec,
Executor: item.Executor,
@ -774,6 +781,14 @@ func (u *CronjobService) UpdateStatus(id uint, status string) error {
return cronjobRepo.Update(cronjob.ID, map[string]interface{}{"status": status, "entry_ids": entryIDs})
}
func (u *CronjobService) UpdateGroup(req dto.ChangeGroup) error {
cronjob, _ := cronjobRepo.Get(repo.WithByID(req.ID))
if cronjob.ID == 0 {
return buserr.New("ErrRecordNotFound")
}
return cronjobRepo.Update(cronjob.ID, map[string]interface{}{"group_id": req.GroupID})
}
func (u *CronjobService) AddCronJob(cronjob *model.Cronjob) (int, error) {
addFunc := func() {
u.HandleJob(cronjob)

View file

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

View file

@ -19,6 +19,7 @@ func (s *CronjobRouter) InitRouter(Router *gin.RouterGroup) {
cmdRouter.GET("/script/options", baseApi.LoadScriptOptions)
cmdRouter.POST("/del", baseApi.DeleteCronjob)
cmdRouter.POST("/update", baseApi.UpdateCronjob)
cmdRouter.POST("/group/update", baseApi.UpdateCronjobGroup)
cmdRouter.POST("/status", baseApi.UpdateCronjobStatus)
cmdRouter.POST("/handle", baseApi.HandleOnce)
cmdRouter.POST("/download", baseApi.TargetDownload)

View file

@ -116,8 +116,6 @@ func (u *GroupService) Delete(id uint) error {
if err := xpack.UpdateGroup("node", id, defaultGroup.ID); err != nil {
return err
}
default:
return buserr.New("ErrNotSupportType")
}
if err != nil {
return err

View file

@ -22,6 +22,7 @@ func Init() {
migrations.UpdateOnedrive,
migrations.AddClusterMenu,
migrations.DeleteXpackHideMenu,
migrations.AddCronjobGroup,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)

View file

@ -531,3 +531,13 @@ var DeleteXpackHideMenu = &gormigrate.Migration{
return tx.Model(&model.Setting{}).Where("key = ?", "HideMenu").Update("value", string(updatedJSON)).Error
},
}
var AddCronjobGroup = &gormigrate.Migration{
ID: "20250729-add-cronjob-group",
Migrate: func(tx *gorm.DB) error {
if err := tx.Create(&model.Group{Name: "Default", Type: "cronjob", IsDefault: true}).Error; err != nil {
return err
}
return nil
},
}

View file

@ -5,6 +5,7 @@ export namespace Cronjob {
id: number;
name: string;
type: string;
groupID: number;
specCustom: boolean;
spec: string;
specs: Array<string>;

View file

@ -20,6 +20,7 @@ export interface ReqPage {
}
export interface SearchWithPage {
info: string;
groupIDs: Array<number>;
page: number;
pageSize: number;
orderBy?: string;

View file

@ -3,7 +3,7 @@ import { ResPage, SearchWithPage } from '../interface';
import { Cronjob } from '../interface/cronjob';
import { TimeoutEnum } from '@/enums/http-enum';
export const getCronjobPage = (params: SearchWithPage) => {
export const searchCronjobPage = (params: SearchWithPage) => {
return http.post<ResPage<Cronjob.CronjobInfo>>(`/cronjobs/search`, params);
};
@ -11,6 +11,10 @@ export const loadNextHandle = (spec: string) => {
return http.post<Array<String>>(`/cronjobs/next`, { spec: spec });
};
export const editCronjobGroup = (id: number, groupID: number) => {
return http.post(`/cronjobs/group/update`, { id: id, groupID: groupID });
};
export const importCronjob = (trans: Array<Cronjob.CronjobTrans>) => {
return http.post('cronjobs/import', { cronjobs: trans }, TimeoutEnum.T_60S);
};

View file

@ -5,7 +5,10 @@
<el-button type="primary" @click="onOpenDialog('')">
{{ $t('commons.button.create') }}{{ $t('menu.cronjob') }}
</el-button>
<el-button-group class="ml-4">
<el-button @click="onOpenGroupDialog()">
{{ $t('commons.table.group') }}
</el-button>
<el-button-group>
<el-button plain :disabled="selects.length === 0" @click="onBatchChangeStatus('enable')">
{{ $t('commons.button.enable') }}
</el-button>
@ -17,7 +20,7 @@
</el-button>
</el-button-group>
<el-button-group class="ml-4">
<el-button-group>
<el-button @click="onImport">
{{ $t('commons.button.import') }}
</el-button>
@ -27,6 +30,17 @@
</el-button-group>
</template>
<template #rightToolBar>
<el-select v-model="searchGroupID" @change="search()" clearable class="p-w-200">
<template #prefix>{{ $t('commons.table.group') }}</template>
<div v-for="item in groupOptions" :key="item.id">
<el-option
v-if="item.name === 'Default'"
:label="$t('commons.table.default')"
:value="item.id"
/>
<el-option v-else :label="item.name" :value="item.id" />
</div>
</el-select>
<TableSearch @search="search()" v-model:searchName="searchName" />
<TableRefresh @search="search()" />
<TableSetting title="cronjob-refresh" @search="search()" />
@ -54,6 +68,23 @@
</el-text>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.group')" min-width="120" prop="group">
<template #default="{ row }">
<fu-select-rw-switch v-model="row.groupID" @change="updateGroup(row)">
<template #read>
{{ row.groupBelong === 'Default' ? $t('commons.table.default') : row.groupBelong }}
</template>
<div v-for="item in groupOptions" :key="item.id">
<el-option
v-if="item.name === 'Default'"
:label="$t('commons.table.default')"
:value="item.id"
/>
<el-option v-else :label="item.name" :value="item.id" />
</div>
</fu-select-rw-switch>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.status')" :min-width="80" prop="status" sortable>
<template #default="{ row }">
<Status
@ -177,6 +208,7 @@
</template>
</OpDialog>
<OpDialog ref="opExportRef" @search="search" @submit="onSubmitExport()" />
<GroupDialog @search="loadGroups" ref="dialogGroupRef" />
<Records @search="search" ref="dialogRecordRef" />
<Import @search="search" ref="dialogImportRef" />
<Backups @search="search" ref="dialogBackupRef" />
@ -188,15 +220,24 @@ import Records from '@/views/cronjob/cronjob/record/index.vue';
import Backups from '@/views/cronjob/cronjob/backup/index.vue';
import Import from '@/views/cronjob/cronjob/import/index.vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { deleteCronjob, exportCronjob, getCronjobPage, handleOnce, updateStatus } from '@/api/modules/cronjob';
import {
deleteCronjob,
editCronjobGroup,
exportCronjob,
searchCronjobPage,
handleOnce,
updateStatus,
} from '@/api/modules/cronjob';
import i18n from '@/lang';
import { Cronjob } from '@/api/interface/cronjob';
import GroupDialog from '@/components/group/index.vue';
import { ElMessageBox } from 'element-plus';
import { MsgSuccess } from '@/utils/message';
import { hasBackup, transSpecToStr } from './helper';
import { GlobalStore } from '@/store';
import router from '@/routers';
import { getCurrentDateFormatted } from '@/utils/util';
import { getGroupList } from '@/api/modules/group';
const globalStore = GlobalStore();
const mobile = computed(() => {
@ -226,21 +267,32 @@ const paginationConfig = reactive({
});
const searchName = ref();
const defaultGroupID = ref<number>();
const searchGroupID = ref<number>();
const groupOptions = ref();
const dialogGroupRef = ref();
const search = async (column?: any) => {
paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy;
paginationConfig.order = column?.order ? column.order : paginationConfig.order;
let groupIDs;
if (searchGroupID.value) {
groupIDs = searchGroupID.value === defaultGroupID.value ? [searchGroupID.value, 0] : [searchGroupID.value];
}
let params = {
info: searchName.value,
groupIDs: groupIDs,
page: paginationConfig.currentPage,
pageSize: paginationConfig.pageSize,
orderBy: paginationConfig.orderBy,
order: paginationConfig.order,
};
loading.value = true;
await getCronjobPage(params)
await searchCronjobPage(params)
.then((res) => {
loading.value = false;
data.value = res.data.items || [];
loadGroups();
paginationConfig.total = res.data.total;
})
.catch(() => {
@ -343,6 +395,41 @@ const onSubmitExport = async () => {
});
};
const loadGroups = async () => {
const res = await getGroupList('cronjob');
groupOptions.value = res.data || [];
for (const item of data.value) {
if (item.groupID === 0) {
item.groupBelong = 'Default';
continue;
}
let hasGroup = false;
for (const group of groupOptions.value) {
if (group.name === 'Default') {
defaultGroupID.value = group.id;
}
if (item.groupID === group.id) {
hasGroup = true;
item.groupBelong = group.name;
}
}
if (!hasGroup) {
item.groupID = null;
item.groupBelong = '-';
}
}
};
const updateGroup = async (row: any) => {
await editCronjobGroup(row.id, row.groupID);
search();
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
};
const onOpenGroupDialog = () => {
dialogGroupRef.value!.acceptParams({ type: 'cronjob', hideDefaultButton: true });
};
const onChangeStatus = async (id: number, status: string) => {
ElMessageBox.confirm(i18n.global.t('cronjob.' + status + 'Msg'), i18n.global.t('cronjob.changeStatus'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),

View file

@ -70,14 +70,27 @@
</el-link>
</span>
</el-form-item>
<el-form-item :label="$t('cronjob.taskName')" prop="name">
<el-input
class="mini-form-item"
:disabled="!isCreate"
clearable
v-model.trim="form.name"
/>
</el-form-item>
<el-row :gutter="20">
<LayoutCol>
<el-form-item :label="$t('cronjob.taskName')" prop="name">
<el-input :disabled="!isCreate" clearable v-model.trim="form.name" />
</el-form-item>
</LayoutCol>
<LayoutCol>
<el-form-item :label="$t('commons.table.group')" prop="groupID">
<el-select filterable v-model="form.groupID" clearable>
<div v-for="item in groupOptions" :key="item.id">
<el-option
v-if="item.name === 'Default'"
:label="$t('commons.table.default')"
:value="item.id"
/>
<el-option v-else :label="item.name" :value="item.id" />
</div>
</el-select>
</el-form-item>
</LayoutCol>
</el-row>
</el-card>
<el-card class="mt-5">
@ -593,10 +606,11 @@
<el-input-number
class="selectClass"
:min="1"
:precision="0"
step-strictly
:step="1"
v-model.number="form.retainCopies"
></el-input-number>
/>
<span v-if="isBackup()" class="input-help">
{{ $t('cronjob.retainCopiesHelper1') }}
</span>
@ -629,7 +643,9 @@
</span>
</el-form-item>
</LayoutCol>
<LayoutCol :span="6">
</el-row>
<el-row :gutter="20">
<LayoutCol>
<el-form-item
:label="$t('xpack.alert.alertMethod')"
v-if="form.hasAlert"
@ -651,7 +667,7 @@
</el-select>
</el-form-item>
</LayoutCol>
<LayoutCol :span="6">
<LayoutCol>
<el-form-item
prop="alertCount"
v-if="form.hasAlert"
@ -660,10 +676,11 @@
<el-input-number
class="selectClass"
:min="1"
:precision="0"
step-strictly
:step="1"
v-model.number="form.alertCount"
></el-input-number>
/>
<span class="input-help">{{ $t('xpack.alert.alertCountHelper') }}</span>
</el-form-item>
</LayoutCol>
@ -696,10 +713,11 @@
<el-input-number
class="selectClass"
:min="0"
:precision="0"
step-strictly
:step="1"
v-model.number="form.retryTimes"
></el-input-number>
/>
<span class="input-help">{{ $t('cronjob.retryTimesHelper') }}</span>
</el-form-item>
</LayoutCol>
@ -756,6 +774,7 @@ import { storeToRefs } from 'pinia';
import { GlobalStore } from '@/store';
import LicenseImport from '@/components/license-import/index.vue';
import { transferTimeToSecond } from '@/utils/util';
import { getGroupList } from '@/api/modules/group';
const router = useRouter();
const globalStore = GlobalStore();
@ -769,6 +788,7 @@ const form = reactive<Cronjob.CronjobInfo>({
id: 0,
name: '',
type: 'shell',
groupID: null,
specCustom: false,
spec: '',
specs: [],
@ -943,6 +963,7 @@ const accountOptions = ref([]);
const appOptions = ref([]);
const userOptions = ref([]);
const scriptOptions = ref([]);
const groupOptions = ref([]);
const dbInfo = reactive({
isExist: false,
@ -1121,6 +1142,19 @@ const loadScriptDir = async (path: string) => {
form.script = path;
};
const loadGroups = async () => {
const res = await getGroupList('cronjob');
groupOptions.value = res.data || [];
if (isCreate.value) {
for (const item of groupOptions.value) {
if (item.isDefault) {
form.groupID = item.id;
break;
}
}
}
};
const goBack = () => {
router.push({ name: 'CronjobItem' });
};
@ -1411,6 +1445,7 @@ onMounted(() => {
} else {
isCreate.value = true;
}
loadGroups();
search();
});
</script>