feat(dashboard): cleanup tasks and events (#2368)

This commit is contained in:
Aaron Liu 2025-07-05 11:52:13 +08:00
parent 6a6fd722f3
commit e9b91c4e03
10 changed files with 351 additions and 2 deletions

View File

@ -1267,6 +1267,12 @@
"forceDeleteDes": "Whether to delete the Blob record regardless of whether the physical file is deleted."
},
"event": {
"cleanup": "Cleanup",
"cleanupAuditLog": "Event cleanup",
"cleanupAuditLogDescription": "Delete all events that meet the following conditions:",
"cleanupNotAfter": "Before this date",
"cleanupEventTypes": "Event types",
"cleanupEventTypesDes": "Select the event types to clean up. Leave blank to clean up all types.",
"initiator": "Initiator",
"event": "Event",
"userID": "User ID",
@ -1319,6 +1325,13 @@
"file": "File"
},
"task": {
"cleanupTasks": "Cleanup tasks",
"cleanupTasksDescription": "Cleanup all tasks that meet the following conditions:",
"cleanupNotAfter": "Before this date",
"cleanupTaskTypes": "Task types",
"cleanupTaskTypesDes": "Select the task types to clean up. Leave blank to clean up all types.",
"cleanupTaskStatuses": "Task statuses",
"cleanupTaskStatusesDes": "Select the task statuses to clean up. Leave blank to clean up all completed status tasks.",
"confirmDelete": "Are you sure you want to delete this task?",
"confirmBatchDelete": "Are you sure you want to delete {{num}} tasks?",
"deleteXTasks": "Delete {{num}} tasks",

View File

@ -1253,6 +1253,12 @@
"forceDeleteDes": "物理ファイルの削除成功可否に関わらず、Blobレコードは削除されます。"
},
"event": {
"cleanup": "イベント清理",
"cleanupAuditLog": "イベント清理",
"cleanupAuditLogDescription": "以下条件を満たすすべてのイベントを削除します:",
"cleanupNotAfter": "この日付以前",
"cleanupEventTypes": "イベント種類",
"cleanupEventTypesDes": "イベント種類を選択してください。空の場合はすべての種類を削除します。",
"initiator": "発信者",
"event": "イベント",
"userID": "ユーザーID",
@ -1305,6 +1311,13 @@
"file": "ファイル"
},
"task": {
"cleanupTasks": "タスク清理",
"cleanupTasksDescription": "以下条件を満たすすべてのタスクを削除します:",
"cleanupNotAfter": "この日付以前",
"cleanupTaskTypes": "タスク種類",
"cleanupTaskTypesDes": "タスク種類を選択してください。空の場合はすべての種類を削除します。",
"cleanupTaskStatuses": "タスクステータス",
"cleanupTaskStatusesDes": "タスクステータスを選択してください。空の場合はすべての完了ステータスのタスクを削除します。",
"import": "ファイルのインポート",
"confirmDelete": "このタスクを削除しますか?",
"confirmBatchDelete": "{{num}}個のタスクを削除しますか?",

View File

@ -1253,7 +1253,12 @@
"forceDeleteDes": "无论物理文件是否删除成功,都会删除 Blob 记录。"
},
"event": {
"initiator": "发起者",
"cleanup": "清理",
"cleanupAuditLog": "清理事件",
"cleanupAuditLogDescription": "删除满足以下条件的所有事件:",
"cleanupNotAfter": "在此日期之前",
"cleanupEventTypes": "事件类型",
"cleanupEventTypesDes": "选择要清理的事件类型,留空表示清理所有类型。",
"event": "事件",
"userID": "用户 ID",
"ip": "IP",
@ -1305,6 +1310,13 @@
"file": "文件"
},
"task": {
"cleanupTasks": "清理任务",
"cleanupTasksDescription": "清理满足以下条件的所有任务:",
"cleanupNotAfter": "在此日期之前",
"cleanupTaskTypes": "任务类型",
"cleanupTaskTypesDes": "选择要清理的任务类型,留空表示清理所有类型。",
"cleanupTaskStatuses": "任务状态",
"cleanupTaskStatusesDes": "选择要清理的任务状态,留空表示清理所有已完成状态的任务。",
"import": "导入文件",
"confirmDelete": "确认要删除这个任务?",
"confirmBatchDelete": "确认要删除 {{num}} 个任务?",

View File

@ -1249,6 +1249,12 @@
"forceDeleteDes": "無論物理檔案是否刪除成功,都會刪除 Blob 記錄。"
},
"event": {
"cleanup": "清理",
"cleanupAuditLog": "事件清理",
"cleanupAuditLogDescription": "刪除滿足以下條件的所有事件:",
"cleanupNotAfter": "在此日期之前",
"cleanupEventTypes": "事件類型",
"cleanupEventTypesDes": "選擇要清理的事件類型,留空表示清理所有類型。",
"initiator": "發起者",
"event": "事件",
"userID": "使用者 ID",
@ -1301,6 +1307,13 @@
"file": "檔案"
},
"task": {
"cleanupTasks": "清理任務",
"cleanupTasksDescription": "清理滿足以下條件的所有任務:",
"cleanupNotAfter": "在此日期之前",
"cleanupTaskTypes": "任務類型",
"cleanupTaskTypesDes": "選擇要清理的任務類型,留空表示清理所有類型。",
"cleanupTaskStatuses": "任務狀態",
"cleanupTaskStatusesDes": "選擇要清理的任務狀態,留空表示清理所有已完成狀態的任務。",
"import": "導入檔案",
"confirmDelete": "確認要刪除這個任務?",
"confirmBatchDelete": "確認要刪除 {{num}} 個任務?",

View File

@ -4,6 +4,7 @@ import {
AdminListGroupResponse,
AdminListService,
BatchIDService,
CleanupTaskService,
CreateStoragePolicyCorsService,
Entity,
FetchWOPIDiscoveryService,
@ -1992,3 +1993,17 @@ export function sendPatchViewSync(args: PatchViewSyncService): ThunkResponse<voi
);
};
}
export function sendCleanupTask(args: CleanupTaskService): ThunkResponse<void> {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/admin/queue/cleanup`,
{ method: "POST", data: args },
{
...defaultOpts,
},
),
);
};
}

View File

@ -1,6 +1,6 @@
import { EntityType, PaginationResults, PolicyType } from "./explorer.ts";
import { Capacity } from "./user.ts";
import { TaskStatus, TaskSummary } from "./workflow.ts";
import { TaskStatus, TaskSummary, TaskType } from "./workflow.ts";
export interface MetricsSummary {
dates: string[];
@ -541,3 +541,9 @@ export interface ListShareResponse {
shares: Share[];
pagination: PaginationResults;
}
export interface CleanupTaskService {
not_after: string;
types?: TaskType[];
status?: TaskStatus[];
}

View File

@ -0,0 +1,124 @@
import { Button, DialogContent, SelectChangeEvent, Stack, Typography } from "@mui/material";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs from "dayjs";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { sendCleanupTask } from "../../../api/api";
import { CleanupTaskService } from "../../../api/dashboard";
import { TaskStatus, TaskType } from "../../../api/workflow";
import { useAppDispatch } from "../../../redux/hooks";
import DraggableDialog from "../../Dialogs/DraggableDialog";
import SettingForm from "../../Pages/Setting/SettingForm";
import TaskStatusSelector from "./TaskStatusSelector";
import TaskTypeSelector from "./TaskTypeSelector";
export interface TaskCleanupDialogProps {
open: boolean;
onClose: () => void;
onCleanupComplete?: () => void;
}
const TaskCleanupDialog = ({ open, onClose, onCleanupComplete }: TaskCleanupDialogProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [notAfter, setNotAfter] = useState<dayjs.Dayjs | null>(null);
const [selectedTypes, setSelectedTypes] = useState<TaskType[]>([]);
const [selectedStatuses, setSelectedStatuses] = useState<TaskStatus[]>([]);
const [loading, setLoading] = useState(false);
const handleCleanup = async () => {
if (!notAfter) {
return;
}
setLoading(true);
try {
const args: CleanupTaskService = {
not_after: notAfter.toISOString(),
types: selectedTypes.length > 0 ? selectedTypes : undefined,
status: selectedStatuses.length > 0 ? selectedStatuses : undefined,
};
await dispatch(sendCleanupTask(args));
onCleanupComplete?.();
onClose();
} finally {
setLoading(false);
}
};
const handleReset = () => {
setNotAfter(null);
setSelectedTypes([]);
setSelectedStatuses([]);
};
return (
<DraggableDialog
title={t("task.cleanupTasks")}
dialogProps={{
open,
onClose,
maxWidth: "sm",
fullWidth: true,
}}
showActions={true}
showCancel={true}
onAccept={handleCleanup}
loading={loading}
disabled={!notAfter}
okText={t("event.cleanup")}
secondaryAction={
<Button onClick={handleReset} disabled={loading}>
{t("user.reset")}
</Button>
}
>
<DialogContent>
<Stack spacing={3}>
<Typography variant="body2" color="text.secondary">
{t("task.cleanupTasksDescription")}
</Typography>
<SettingForm title={t("task.cleanupNotAfter")} noContainer lgWidth={12}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateTimePicker
value={notAfter}
onChange={(newValue) => setNotAfter(newValue)}
slotProps={{
textField: {
fullWidth: true,
size: "small",
},
}}
/>
</LocalizationProvider>
</SettingForm>
<SettingForm title={t("task.type")} noContainer lgWidth={12}>
<TaskTypeSelector
value={selectedTypes}
onChange={(e: SelectChangeEvent<unknown>) => setSelectedTypes(e.target.value as TaskType[])}
helperText={t("task.cleanupTaskTypesDes")}
showAllOption={false}
displayEmpty={true}
/>
</SettingForm>
<SettingForm title={t("task.status")} noContainer lgWidth={12}>
<TaskStatusSelector
value={selectedStatuses}
onChange={(e: SelectChangeEvent<unknown>) => setSelectedStatuses(e.target.value as TaskStatus[])}
helperText={t("task.cleanupTaskStatusesDes")}
showAllOption={false}
displayEmpty={true}
/>
</SettingForm>
</Stack>
</DialogContent>
</DraggableDialog>
);
};
export default TaskCleanupDialog;

View File

@ -27,6 +27,7 @@ import { useAppDispatch } from "../../../redux/hooks";
import { confirmOperation } from "../../../redux/thunks/dialog";
import { NoWrapTableCell, SecondaryButton, StyledTableContainerPaper } from "../../Common/StyledComponents";
import ArrowSync from "../../Icons/ArrowSync";
import Broom from "../../Icons/Broom";
import Filter from "../../Icons/Filter";
import PageContainer from "../../Pages/PageContainer";
import PageHeader from "../../Pages/PageHeader";
@ -34,6 +35,7 @@ import TablePagination from "../Common/TablePagination";
import EntityDialog from "../Entity/EntityDialog/EntityDialog";
import { OrderByQuery, OrderDirectionQuery, PageQuery, PageSizeQuery } from "../StoragePolicy/StoragePolicySetting";
import UserDialog from "../User/UserDialog/UserDialog";
import TaskCleanupDialog from "./TaskCleanupDialog";
import TaskDialog from "./TaskDialog/TaskDialog";
import TaskFilterPopover from "./TaskFilterPopover";
import TaskRow from "./TaskRow";
@ -77,6 +79,7 @@ const TaskList = () => {
const [openTask, setOpenTask] = useState<number | undefined>(undefined);
const [openTaskDialogOpen, setOpenTaskDialogOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [cleanupDialogOpen, setCleanupDialogOpen] = useState(false);
const pageInt = parseInt(page) ?? 1;
const pageSizeInt = parseInt(pageSize) ?? 10;
@ -199,6 +202,11 @@ const TaskList = () => {
<UserDialog open={userDialogOpen} onClose={() => setUserDialogOpen(false)} userID={userDialogID} />
<EntityDialog open={openEntityDialogOpen} onClose={() => setOpenEntityDialogOpen(false)} entityID={openEntity} />
<TaskDialog open={openTaskDialogOpen} onClose={() => setOpenTaskDialogOpen(false)} taskID={openTask} />
<TaskCleanupDialog
open={cleanupDialogOpen}
onClose={() => setCleanupDialogOpen(false)}
onCleanupComplete={fetchTasks}
/>
<Container maxWidth="xl">
<PageHeader title={t("dashboard:nav.tasks")} />
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
@ -225,6 +233,10 @@ const TaskList = () => {
</SecondaryButton>
</Badge>
<SecondaryButton startIcon={<Broom />} variant="contained" onClick={() => setCleanupDialogOpen(true)}>
{t("event.cleanup")}
</SecondaryButton>
{selected.length > 0 && !isMobile && (
<>
<Divider orientation="vertical" flexItem />

View File

@ -0,0 +1,71 @@
import { Box, FormHelperText, ListItemText, SelectChangeEvent } from "@mui/material";
import { useTranslation } from "react-i18next";
import { TaskStatus } from "../../../api/workflow";
import { DenseSelect, SquareChip } from "../../Common/StyledComponents";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu";
import { getTaskStatusText } from "../../Pages/Tasks/TaskProps";
interface TaskStatusSelectorProps {
value: TaskStatus[];
onChange: (event: SelectChangeEvent<unknown>) => void;
renderValue?: (selected: unknown) => React.ReactNode;
helperText?: string;
showAllOption?: boolean;
allOptionText?: string;
fullWidth?: boolean;
displayEmpty?: boolean;
}
const TaskStatusSelector = ({
value,
onChange,
renderValue,
helperText,
showAllOption = false,
allOptionText,
fullWidth = true,
displayEmpty = false,
}: TaskStatusSelectorProps) => {
const { t } = useTranslation("dashboard");
const defaultRenderValue = (selected: unknown) => {
const values = Array.isArray(selected) ? selected : [];
return (
<Box display="flex" flexWrap="wrap" gap={0.5}>
{values.map((val) => (
<SquareChip key={val} label={getTaskStatusText(val as TaskStatus, t)} size="small" />
))}
</Box>
);
};
return (
<>
<DenseSelect
fullWidth={fullWidth}
multiple
value={value}
onChange={onChange}
renderValue={renderValue || defaultRenderValue}
displayEmpty={displayEmpty}
>
{showAllOption && (
<SquareMenuItem value={[]} disabled>
<ListItemText
primary={allOptionText || t("task.allTaskStatuses")}
slotProps={{ primary: { variant: "body2", style: { fontStyle: "italic" } } }}
/>
</SquareMenuItem>
)}
{Object.values(TaskStatus).map((status) => (
<SquareMenuItem value={status} key={status}>
<ListItemText primary={getTaskStatusText(status, t)} slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
))}
</DenseSelect>
{helperText && <FormHelperText>{helperText}</FormHelperText>}
</>
);
};
export default TaskStatusSelector;

View File

@ -0,0 +1,70 @@
import { Box, FormHelperText, ListItemText, SelectChangeEvent } from "@mui/material";
import { useTranslation } from "react-i18next";
import { TaskType } from "../../../api/workflow";
import { DenseSelect, SquareChip } from "../../Common/StyledComponents";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu";
interface TaskTypeSelectorProps {
value: TaskType[];
onChange: (event: SelectChangeEvent<unknown>) => void;
renderValue?: (selected: unknown) => React.ReactNode;
helperText?: string;
showAllOption?: boolean;
allOptionText?: string;
fullWidth?: boolean;
displayEmpty?: boolean;
}
const TaskTypeSelector = ({
value,
onChange,
renderValue,
helperText,
showAllOption = false,
allOptionText,
fullWidth = true,
displayEmpty = false,
}: TaskTypeSelectorProps) => {
const { t } = useTranslation("dashboard");
const defaultRenderValue = (selected: unknown) => {
const values = Array.isArray(selected) ? selected : [];
return (
<Box display="flex" flexWrap="wrap" gap={0.5}>
{values.map((val) => (
<SquareChip key={val} label={t(`task.${val}`)} size="small" />
))}
</Box>
);
};
return (
<>
<DenseSelect
fullWidth={fullWidth}
multiple
value={value}
onChange={onChange}
renderValue={renderValue || defaultRenderValue}
displayEmpty={displayEmpty}
>
{showAllOption && (
<SquareMenuItem value={[]} disabled>
<ListItemText
primary={allOptionText || t("task.allTaskTypes")}
slotProps={{ primary: { variant: "body2", style: { fontStyle: "italic" } } }}
/>
</SquareMenuItem>
)}
{Object.values(TaskType).map((type) => (
<SquareMenuItem value={type} key={type}>
<ListItemText primary={t(`task.${type}`)} slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
))}
</DenseSelect>
{helperText && <FormHelperText>{helperText}</FormHelperText>}
</>
);
};
export default TaskTypeSelector;