From 9dae6a0fc61407242595e51b7ff4c695ae984a5c Mon Sep 17 00:00:00 2001 From: KirCute_ECT <951206789@qq.com> Date: Wed, 25 Dec 2024 21:09:33 +0800 Subject: [PATCH] feat(task): add speed monitor (#209) * feat(task): add speed monitor * feat(task): display average speed for completed tasks * fix: tasks that have not started continuously changes order when sorting by progress --- src/lang/en/tasks.json | 2 + src/pages/manage/tasks/Task.tsx | 130 ++++++++++++++++++---- src/pages/manage/tasks/Tasks.tsx | 183 ++++++++++++++++++------------- src/types/task.ts | 3 + 4 files changed, 221 insertions(+), 97 deletions(-) diff --git a/src/lang/en/tasks.json b/src/lang/en/tasks.json index f8789e6521..7e0d6afe89 100644 --- a/src/lang/en/tasks.json +++ b/src/lang/en/tasks.json @@ -33,6 +33,7 @@ "creator": "Creator", "state": "State", "progress": "Progress", + "speed": "Speed", "operation": "Operation", "copy": { "src": "Source Path", @@ -47,6 +48,7 @@ "transfer_src": "Source Path", "transfer_dst": "Destination Path" }, + "time_elapsed": "Time Elapsed", "status": "Status", "err": "Error" }, diff --git a/src/pages/manage/tasks/Task.tsx b/src/pages/manage/tasks/Task.tsx index c7210ba0eb..4786eb89d6 100644 --- a/src/pages/manage/tasks/Task.tsx +++ b/src/pages/manage/tasks/Task.tsx @@ -12,13 +12,14 @@ import { Progress, ProgressIndicator, Spacer, + Text, VStack, } from "@hope-ui/solid" import { createSignal, For, Show } from "solid-js" import { useT, useFetch } from "~/hooks" -import { PEmptyResp, TaskInfo } from "~/types" +import { PEmptyResp } from "~/types" import { handleResp, notify, r } from "~/utils" -import { TasksProps } from "./Tasks" +import { TaskAttribute, TaskLocalSetter, TasksProps } from "./Tasks" import { me } from "~/store" enum TaskStateEnum { @@ -79,29 +80,41 @@ export const TaskState = (props: { state: number }) => { export type TaskOrderBy = "name" | "creator" | "state" | "progress" export interface TaskCol { - name: TaskOrderBy | "operation" + name: TaskOrderBy | "speed" | "operation" textAlign: "left" | "right" | "center" w: any } -export interface TaskControlCallback { - setSelected: (id: string, v: boolean) => void - setExpanded: (id: string, v: boolean) => void -} - export const cols: TaskCol[] = [ { name: "name", textAlign: "left", - w: me().role === 2 ? "calc(100% - 660px)" : "calc(100% - 540px)", + w: me().role === 2 ? "calc(100% - 660px)" : "calc(100% - 560px)", }, - { name: "creator", textAlign: "center", w: me().role === 2 ? "120px" : "0" }, + { name: "creator", textAlign: "center", w: me().role === 2 ? "100px" : "0" }, { name: "state", textAlign: "center", w: "100px" }, - { name: "progress", textAlign: "left", w: "160px" }, - { name: "operation", textAlign: "right", w: "280px" }, + { name: "progress", textAlign: "left", w: "140px" }, + { name: "speed", textAlign: "center", w: "100px" }, + { name: "operation", textAlign: "right", w: "220px" }, ] -export const Task = (props: TaskInfo & TasksProps & TaskControlCallback) => { +export interface TaskLocal { + selected: boolean + expanded: boolean +} + +const toTimeNumber = (n: number) => { + return Math.floor(n).toString().padStart(2, "0") +} + +const getTimeStr = (millisecond: number) => { + const sec = (millisecond / 1000) % 60 + const min = (millisecond / 1000 / 60) % 60 + const hour = millisecond / 1000 / 3600 + return `${toTimeNumber(hour)}:${toTimeNumber(min)}:${toTimeNumber(sec)}` +} + +export const Task = (props: TaskAttribute & TasksProps & TaskLocalSetter) => { const t = useT() const operateName = props.done === "undone" ? "cancel" : "delete" const canRetry = props.done === "done" && props.state === TaskStateEnum.Failed @@ -118,6 +131,49 @@ export const Task = (props: TaskInfo & TasksProps & TaskControlCallback) => { ) const title = matches === null ? props.name : props.nameAnalyzer.title(matches) + const startTime = + props.start_time === null ? -1 : new Date(props.start_time).getTime() + const endTime = + props.end_time === null + ? new Date().getTime() + : new Date(props.end_time).getTime() + let speedText = "-" + const parseSpeedText = (timeDelta: number, lengthDelta: number) => { + let delta = lengthDelta / timeDelta + let unit = "bytes/s" + if (delta > 1024) { + delta /= 1024 + unit = "KB/s" + } + if (delta > 1024) { + delta /= 1024 + unit = "MB/s" + } + if (delta > 1024) { + delta /= 1024 + unit = "GB/s" + } + return `${delta.toFixed(2)} ${unit}` + } + if (props.done) { + if ( + props.start_time !== props.end_time && + props.progress > 0 && + startTime !== -1 + ) { + const timeDelta = (endTime - startTime) / 1000 + const lengthDelta = (props.total_bytes * props.progress) / 100 + speedText = parseSpeedText(timeDelta, lengthDelta) + } + } else if ( + props.prevProgress !== undefined && + props.prevFetchTime !== undefined + ) { + const timeDelta = (props.curFetchTime - props.prevFetchTime) / 1000 + const lengthDelta = + ((props.progress - props.prevProgress) * props.total_bytes) / 100 + speedText = parseSpeedText(timeDelta, lengthDelta) + } return ( @@ -127,9 +183,12 @@ export const Task = (props: TaskInfo & TasksProps & TaskControlCallback) => { on:click={(e: MouseEvent) => { e.stopPropagation() }} - checked={props.selected} + checked={props.local.selected} onChange={(e: any) => { - props.setSelected(props.id, e.target.checked as boolean) + props.setLocal({ + selected: e.target.checked as boolean, + expanded: props.local.expanded, + }) }} /> { rounded="$full" size="sm" value={props.progress} + mr="$1" > {/* */} - +
+ + {speedText} + +
+
- + { columnGap="$4" mb="$2" > + + + {t(`tasks.attr.time_elapsed`)} + + + {getTimeStr(endTime - startTime)} + + {(entry) => ( @@ -249,7 +339,7 @@ export const Task = (props: TaskInfo & TasksProps & TaskControlCallback) => { > {t(`tasks.attr.err`)} - {props.error} + {props.error} diff --git a/src/pages/manage/tasks/Tasks.tsx b/src/pages/manage/tasks/Tasks.tsx index d17283904f..4bd09de880 100644 --- a/src/pages/manage/tasks/Tasks.tsx +++ b/src/pages/manage/tasks/Tasks.tsx @@ -23,7 +23,7 @@ import { Paginator } from "~/components" import { useFetch, useT } from "~/hooks" import { PEmptyResp, PResp, TaskInfo } from "~/types" import { handleResp, notify, r } from "~/utils" -import { TaskCol, cols, Task, TaskOrderBy } from "./Task" +import { TaskCol, cols, Task, TaskOrderBy, TaskLocal } from "./Task" import { me } from "~/store" export interface TaskNameAnalyzer { @@ -40,16 +40,27 @@ export interface TasksProps { } export interface TaskViewAttribute { - selected: boolean - expanded: boolean + curFetchTime: number + prevFetchTime?: number + prevProgress?: number } +export interface TaskLocalContainer { + local: TaskLocal +} + +export interface TaskLocalSetter { + setLocal: (l: TaskLocal) => void +} + +export type TaskAttribute = TaskInfo & TaskViewAttribute & TaskLocalContainer + export const Tasks = (props: TasksProps) => { const t = useT() const [loading, get] = useFetch( (): PResp => r.get(`/task/${props.type}/${props.done}`), ) - const [tasks, setTasks] = createSignal<(TaskInfo & TaskViewAttribute)[]>([]) + const [tasks, setTasks] = createSignal([]) const [orderBy, setOrderBy] = createSignal("name") const [orderReverse, setOrderReverse] = createSignal(false) const sorter: Record number> = { @@ -64,7 +75,14 @@ export const Tasks = (props: TasksProps) => { : -1, state: (a, b) => a.state === b.state ? (a.id > b.id ? 1 : -1) : a.state > b.state ? 1 : -1, - progress: (a, b) => (a.progress < b.progress ? 1 : -1), + progress: (a, b) => + a.progress === b.progress + ? a.id > b.id + ? 1 + : -1 + : a.progress < b.progress + ? 1 + : -1, } const curSorter = createMemo(() => { return (a: TaskInfo, b: TaskInfo) => @@ -72,25 +90,47 @@ export const Tasks = (props: TasksProps) => { }) const refresh = async () => { const resp = await get() - const selectMap: Record = {} - const expandMap: Record = {} - for (const task of tasks()) { - selectMap[task.id] = task.selected ?? false - expandMap[task.id] = task.expanded ?? false - } - handleResp(resp, (data) => + handleResp(resp, (data) => { + const fetchTime = new Date().getTime() + const curFetchTimeMap: Record = {} + const prevFetchTimeMap: Record = {} + const curProgressMap: Record = {} + const prevProgressMap: Record = {} + const taskLocalMap: Record = {} + for (const task of tasks()) { + curFetchTimeMap[task.id] = task.curFetchTime + prevFetchTimeMap[task.id] = task.prevFetchTime + curProgressMap[task.id] = task.progress + prevProgressMap[task.id] = task.prevProgress + taskLocalMap[task.id] = task.local + } setTasks( data ?.map((task) => { + let prevFetchTime: number | undefined + let prevProgress: number | undefined + if (task.progress === curProgressMap[task.id]) { + prevFetchTime = prevFetchTimeMap[task.id] // may be undefined + prevProgress = prevProgressMap[task.id] // may be undefined + } else { + prevFetchTime = curFetchTimeMap[task.id] + prevProgress = curProgressMap[task.id] + } + const taskLocal = taskLocalMap[task.id] ?? { + selected: false, + expanded: false, + } return { ...task, - selected: selectMap[task.id] ?? false, - expanded: expandMap[task.id] ?? false, + curFetchTime: fetchTime, + prevFetchTime: prevFetchTime, + prevProgress: prevProgress, + local: taskLocal, } }) .sort(curSorter()) ?? [], - ), - ) + ) + }) } refresh() if (props.done === "undone") { @@ -108,14 +148,13 @@ export const Tasks = (props: TasksProps) => { ) const [regexFilterValue, setRegexFilterValue] = createSignal("") const [regexFilter, setRegexFilter] = createSignal(new RegExp("")) - const [regexFilterCompileFailed, setRegexFilterCompileFailed] = - createSignal(false) + const [regexCompileFailed, setRegexCompileFailed] = createSignal(false) createEffect(() => { try { setRegexFilter(new RegExp(regexFilterValue())) - setRegexFilterCompileFailed(false) + setRegexCompileFailed(false) } catch (_) { - setRegexFilterCompileFailed(true) + setRegexCompileFailed(true) } }) const [showOnlyMine, setShowOnlyMine] = createSignal(me().role !== 2) @@ -128,60 +167,43 @@ export const Tasks = (props: TasksProps) => { const filteredTask = createMemo(() => { return tasks().filter(taskFilter()) }) - const allChecked = createMemo(() => { - return filteredTask() - .map((task) => task.selected) - .every(Boolean) - }) - const isIndeterminate = createMemo(() => { - return ( + const allSelected = createMemo(() => + filteredTask() + .map((task) => task.local.selected) + .every(Boolean), + ) + const isIndeterminate = createMemo( + () => filteredTask() - .map((task) => task.selected) - .some(Boolean) && !allChecked() - ) - }) - const setSelected = (id: string, v: boolean) => { - setTasks( - tasks().map((task) => { - if (task.id === id) task.selected = v - return task - }), - ) - } - const selectAll = (v: boolean) => { - const filter = taskFilter() - setTasks( - tasks().map((task) => { - if (filter(task)) task.selected = v - return task - }), - ) - } - const allExpanded = createMemo(() => { - return filteredTask() - .map((task) => task.expanded) - .every(Boolean) - }) - const setExpanded = (id: string, v: boolean) => { + .map((task) => task.local.selected) + .some(Boolean) && !allSelected(), + ) + const selectAll = (v: boolean) => setTasks( tasks().map((task) => { - if (task.id === id) task.expanded = v + if (taskFilter()(task)) { + task.local.selected = v + } return task }), ) - } - const expandedAll = (v: boolean) => { - const filter = taskFilter() + const allExpanded = createMemo(() => + filteredTask() + .map((task) => task.local.expanded) + .every(Boolean), + ) + const expandAll = (v: boolean) => setTasks( tasks().map((task) => { - if (filter(task)) task.expanded = v + if (taskFilter()(task)) { + task.local.expanded = v + } return task }), ) - } const getSelectedId = () => filteredTask() - .filter((task) => task.selected) + .filter((task) => task.local.selected) .map((task) => task.id) const [retrySelectedLoading, retrySelected] = useFetch( (): PEmptyResp => r.post(`/task/${props.type}/retry_some`, getSelectedId()), @@ -227,6 +249,17 @@ export const Tasks = (props: TasksProps) => { }, } } + const getLocalSetter = (id: string) => { + return (l: TaskLocal) => + setTasks( + tasks().map((t) => { + if (t.id === id) { + t.local = l + } + return t + }), + ) + } return ( {t(`tasks.${props.done}`)} @@ -298,7 +331,7 @@ export const Tasks = (props: TasksProps) => { placeholder={t(`tasks.filter`)} value={regexFilterValue()} onInput={(e: any) => setRegexFilterValue(e.target.value as string)} - invalid={regexFilterCompileFailed()} + invalid={regexCompileFailed()} /> { selectAll(e.target.checked as boolean)} /> @@ -352,31 +385,27 @@ export const Tasks = (props: TasksProps) => { > {t(`tasks.attr.${cols[3].name}`)} - + + {t(`tasks.attr.${cols[4].name}`)} + + - - {t(`tasks.attr.${cols[4].name}`)} + + {t(`tasks.attr.${cols[5].name}`)} - - {(_, i) => ( - - )} - + {curTasks().map((task) => ( + + ))}