feat: add exec log clean

This commit is contained in:
engigu
2025-12-21 22:19:55 +08:00
parent 0ff136cfcd
commit 86fa6a4340
18 changed files with 431 additions and 31 deletions
+13 -9
View File
@@ -23,9 +23,11 @@ func NewTaskController(taskService *services.TaskService, cronService *services.
func (tc *TaskController) CreateTask(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Command string `json:"command" binding:"required"`
Schedule string `json:"schedule" binding:"required"`
Name string `json:"name" binding:"required"`
Command string `json:"command" binding:"required"`
Schedule string `json:"schedule" binding:"required"`
Timeout int `json:"timeout"`
CleanConfig string `json:"clean_config"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -38,7 +40,7 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
return
}
task := tc.taskService.CreateTask(req.Name, req.Command, req.Schedule)
task := tc.taskService.CreateTask(req.Name, req.Command, req.Schedule, req.Timeout, req.CleanConfig)
tc.cronService.AddTask(task)
utils.Success(c, task)
@@ -76,10 +78,12 @@ func (tc *TaskController) UpdateTask(c *gin.Context) {
}
var req struct {
Name string `json:"name"`
Command string `json:"command"`
Schedule string `json:"schedule"`
Enabled bool `json:"enabled"`
Name string `json:"name"`
Command string `json:"command"`
Schedule string `json:"schedule"`
Timeout int `json:"timeout"`
CleanConfig string `json:"clean_config"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -94,7 +98,7 @@ func (tc *TaskController) UpdateTask(c *gin.Context) {
}
}
task := tc.taskService.UpdateTask(id, req.Name, req.Command, req.Schedule, req.Enabled)
task := tc.taskService.UpdateTask(id, req.Name, req.Command, req.Schedule, req.Timeout, req.CleanConfig, req.Enabled)
if task == nil {
utils.NotFound(c, "任务不存在")
return
+19 -12
View File
@@ -6,19 +6,26 @@ import (
"gorm.io/gorm"
)
// CleanConfig 清理配置结构
type CleanConfig struct {
Type string `json:"type"` // "day" 或 "count"
Keep int `json:"keep"` // 保留天数或条数
}
// Task represents a scheduled task
type Task struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:255;not null"`
Command string `json:"command" gorm:"type:text;not null"`
Schedule string `json:"schedule" gorm:"size:100"` // cron expression
Timeout int `json:"timeout" gorm:"default:30"` // 超时时间(分钟),默认30分钟
Enabled bool `json:"enabled" gorm:"default:true"`
LastRun *LocalTime `json:"last_run"`
NextRun *LocalTime `json:"next_run"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:255;not null"`
Command string `json:"command" gorm:"type:text;not null"`
Schedule string `json:"schedule" gorm:"size:100"` // cron expression
Timeout int `json:"timeout" gorm:"default:30"` // 超时时间(分钟),默认30分钟
CleanConfig string `json:"clean_config" gorm:"size:255;default:''"` // 清理配置 JSON
Enabled bool `json:"enabled" gorm:"default:true"`
LastRun *LocalTime `json:"last_run"`
NextRun *LocalTime `json:"next_run"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (Task) TableName() string {
@@ -39,4 +46,4 @@ type TaskLog struct {
func (TaskLog) TableName() string {
return constant.TablePrefix + "task_logs"
}
}
+49
View File
@@ -3,6 +3,7 @@ package services
import (
"bytes"
"context"
"encoding/json"
"os/exec"
"sync"
"time"
@@ -47,6 +48,7 @@ func NewExecutorService(taskService *TaskService) *ExecutorService {
// 注册默认回调
es.RegisterCallback(es.saveTaskLogCallback)
es.RegisterCallback(es.updateStatsCallback)
es.RegisterCallback(es.cleanLogsCallback)
return es
}
@@ -112,6 +114,53 @@ func (es *ExecutorService) updateStatsCallback(taskID uint, _ string, result *Ex
}
}
// CleanConfig 清理配置结构
type CleanConfig struct {
Type string `json:"type"` // "day" 或 "count"
Keep int `json:"keep"` // 保留天数或条数
}
// cleanLogsCallback 清理日志的回调
func (es *ExecutorService) cleanLogsCallback(taskID uint, _ string, _ *ExecutionResult) {
task := es.taskService.GetTaskByID(int(taskID))
if task == nil || task.CleanConfig == "" {
return
}
var config CleanConfig
if err := json.Unmarshal([]byte(task.CleanConfig), &config); err != nil {
logger.Errorf("Failed to parse clean config: %v", err)
return
}
if config.Keep <= 0 {
return
}
var deleted int64
switch config.Type {
case "day":
// 按天清理:删除 N 天前的日志
cutoff := time.Now().AddDate(0, 0, -config.Keep)
result := database.DB.Where("task_id = ? AND created_at < ?", taskID, cutoff).Delete(&models.TaskLog{})
deleted = result.RowsAffected
case "count":
// 按条数清理:使用子查询删除超出保留数量的旧日志
// 先获取第 N 条的 ID 作为边界
var boundaryLog models.TaskLog
err := database.DB.Where("task_id = ?", taskID).Order("id DESC").Offset(config.Keep - 1).Limit(1).First(&boundaryLog).Error
if err == nil {
// 删除 ID 小于边界的所有日志
result := database.DB.Where("task_id = ? AND id < ?", taskID, boundaryLog.ID).Delete(&models.TaskLog{})
deleted = result.RowsAffected
}
}
if deleted > 0 {
logger.Infof("Cleaned %d logs for task %d", deleted, taskID)
}
}
// ExecuteTask executes a task by ID
func (es *ExecutorService) ExecuteTask(taskID int) *ExecutionResult {
task := es.taskService.GetTaskByID(taskID)
+10 -6
View File
@@ -11,12 +11,14 @@ func NewTaskService() *TaskService {
return &TaskService{}
}
func (ts *TaskService) CreateTask(name, command, schedule string) *models.Task {
func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, cleanConfig string) *models.Task {
task := &models.Task{
Name: name,
Command: command,
Schedule: schedule,
Enabled: true,
Name: name,
Command: command,
Schedule: schedule,
Timeout: timeout,
CleanConfig: cleanConfig,
Enabled: true,
}
database.DB.Create(task)
return task
@@ -52,7 +54,7 @@ func (ts *TaskService) GetTaskByID(id int) *models.Task {
return &task
}
func (ts *TaskService) UpdateTask(id int, name, command, schedule string, enabled bool) *models.Task {
func (ts *TaskService) UpdateTask(id int, name, command, schedule string, timeout int, cleanConfig string, enabled bool) *models.Task {
var task models.Task
if err := database.DB.First(&task, id).Error; err != nil {
return nil
@@ -60,6 +62,8 @@ func (ts *TaskService) UpdateTask(id int, name, command, schedule string, enable
task.Name = name
task.Command = command
task.Schedule = schedule
task.Timeout = timeout
task.CleanConfig = cleanConfig
task.Enabled = enabled
database.DB.Save(&task)
return &task
+1
View File
@@ -187,6 +187,7 @@ export interface Task {
command: string
schedule: string
timeout: number
clean_config: string
enabled: boolean
last_run: string
next_run: string
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot
v-slot="slotProps"
data-slot="select"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</SelectRoot>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { SelectScrollDownButton, SelectScrollUpButton } from "."
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "popper",
},
)
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')">
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectGroupProps } from "reka-ui"
import { SelectGroup } from "reka-ui"
const props = defineProps<SelectGroupProps>()
</script>
<template>
<SelectGroup
data-slot="select-group"
v-bind="props"
>
<slot />
</SelectGroup>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { SelectItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class,
)
"
>
<span class="absolute right-2 flex size-3.5 items-center justify-center">
<SelectItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectItemTextProps } from "reka-ui"
import { SelectItemText } from "reka-ui"
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText
data-slot="select-item-text"
v-bind="props"
>
<slot />
</SelectItemText>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { SelectLabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { SelectLabel } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>()
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)"
>
<slot />
</SelectLabel>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollDownButton
data-slot="select-scroll-down-button"
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot>
<ChevronDown class="size-4" />
</slot>
</SelectScrollDownButton>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronUp } from "lucide-vue-next"
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollUpButton
data-slot="select-scroll-up-button"
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot>
<ChevronUp class="size-4" />
</slot>
</SelectScrollUpButton>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SelectSeparator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
/>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { SelectTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
{ size: "default" },
)
const delegatedProps = reactiveOmit(props, "class", "size")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="cn(
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectValueProps } from "reka-ui"
import { SelectValue } from "reka-ui"
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue
data-slot="select-value"
v-bind="props"
>
<slot />
</SelectValue>
</template>
+11
View File
@@ -0,0 +1,11 @@
export { default as Select } from "./Select.vue"
export { default as SelectContent } from "./SelectContent.vue"
export { default as SelectGroup } from "./SelectGroup.vue"
export { default as SelectItem } from "./SelectItem.vue"
export { default as SelectItemText } from "./SelectItemText.vue"
export { default as SelectLabel } from "./SelectLabel.vue"
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
export { default as SelectSeparator } from "./SelectSeparator.vue"
export { default as SelectTrigger } from "./SelectTrigger.vue"
export { default as SelectValue } from "./SelectValue.vue"
+48 -4
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import Pagination from '@/components/Pagination.vue'
import { Plus, Play, Pencil, Trash2, Search } from 'lucide-vue-next'
import { api, type Task } from '@/api'
@@ -20,6 +21,10 @@ const isEdit = ref(false)
const showDeleteDialog = ref(false)
const deleteTaskId = ref<number | null>(null)
// 清理配置
const cleanType = ref('')
const cleanKeep = ref(30)
const filterName = ref('')
const currentPage = ref(1)
const total = ref(0)
@@ -37,6 +42,12 @@ const cronPresets = [
{ label: '每月1号', value: '0 0 0 1 * *' },
]
// 计算清理配置 JSON
const cleanConfig = computed(() => {
if (!cleanType.value || cleanType.value === 'none' || cleanKeep.value <= 0) return ''
return JSON.stringify({ type: cleanType.value, keep: cleanKeep.value })
})
async function loadTasks() {
try {
const res = await api.tasks.list({ page: currentPage.value, page_size: pageSize.value, name: filterName.value || undefined })
@@ -59,19 +70,36 @@ function handlePageChange(page: number) {
}
function openCreate() {
editingTask.value = { name: '', command: '', schedule: '0 * * * * *', timeout: 30, enabled: true }
editingTask.value = { name: '', command: '', schedule: '0 * * * * *', timeout: 30, enabled: true, clean_config: '' }
cleanType.value = 'none'
cleanKeep.value = 30
isEdit.value = false
showDialog.value = true
}
function openEdit(task: Task) {
editingTask.value = { ...task }
// 解析清理配置
if (task.clean_config) {
try {
const config = JSON.parse(task.clean_config)
cleanType.value = config.type || 'none'
cleanKeep.value = config.keep || 30
} catch {
cleanType.value = 'none'
cleanKeep.value = 30
}
} else {
cleanType.value = 'none'
cleanKeep.value = 30
}
isEdit.value = true
showDialog.value = true
}
async function saveTask() {
try {
editingTask.value.clean_config = cleanConfig.value
if (isEdit.value && editingTask.value.id) {
await api.tasks.update(editingTask.value.id, editingTask.value)
toast.success('任务已更新')
@@ -106,7 +134,7 @@ async function runTask(id: number) {
async function toggleTask(task: Task, enabled: boolean) {
try {
await api.tasks.update(task.id, { name: task.name, command: task.command, schedule: task.schedule, timeout: task.timeout, enabled })
await api.tasks.update(task.id, { name: task.name, command: task.command, schedule: task.schedule, timeout: task.timeout, clean_config: task.clean_config, enabled })
toast.success(enabled ? '任务已启用' : '任务已禁用')
loadTasks()
} catch { toast.error('操作失败') }
@@ -182,7 +210,7 @@ onMounted(loadTasks)
</div>
<Dialog v-model:open="showDialog">
<DialogContent class="sm:max-w-[425px]">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{{ isEdit ? '编辑任务' : '新建任务' }}</DialogTitle>
</DialogHeader>
@@ -219,6 +247,22 @@ onMounted(loadTasks)
<Label class="text-right">超时(分钟)</Label>
<Input v-model.number="editingTask.timeout" type="number" placeholder="30" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">日志清理</Label>
<div class="col-span-3 flex gap-2">
<Select :model-value="cleanType" @update:model-value="(v) => cleanType = String(v || 'none')">
<SelectTrigger class="w-28">
<SelectValue placeholder="不清理" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">不清理</SelectItem>
<SelectItem value="day">按天数</SelectItem>
<SelectItem value="count">按条数</SelectItem>
</SelectContent>
</Select>
<Input v-if="cleanType && cleanType !== 'none'" v-model.number="cleanKeep" type="number" :placeholder="cleanType === 'day' ? '保留天数' : '保留条数'" class="flex-1" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showDialog = false">取消</Button>