diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx index 31fba70..e0f2fd0 100644 --- a/web/src/app/(main)/function/schedules/page.tsx +++ b/web/src/app/(main)/function/schedules/page.tsx @@ -2,7 +2,18 @@ import * as React from "react"; -import { CalendarClock, Loader2, Pause, Play, Plus, Search, Trash2, Zap } from "lucide-react"; +import { + CalendarClock, + ChevronDown, + ChevronRight, + Loader2, + Pause, + Play, + Plus, + Search, + Trash2, + Zap, +} from "lucide-react"; import { toast } from "sonner"; import { DateSelect } from "@/components/date-select"; @@ -116,6 +127,83 @@ function scheduleErrorText(value?: string) { .replaceAll("schedule_type", "规则"); } +type ScheduleHistoryEntry = NonNullable[number]; + +function historyActionText(action: string) { + return ( + ( + { + running: "窗口开始", + outside: "窗口结束", + completed: "计划完成", + waiting: "等待窗口", + paused: "计划暂停", + resume: "恢复任务", + pause: "暂停任务", + error: "执行错误", + } as Record + )[action] ?? "状态变化" + ); +} + +function timelinePosition(event: ScheduleHistoryEntry, index: number, events: ScheduleHistoryEntry[]) { + if (events.length <= 1) return 50; + const timestamps = events.map((item) => Date.parse(item.created_at)).filter(Number.isFinite); + const time = Date.parse(event.created_at); + const min = Math.min(...timestamps); + const max = Math.max(...timestamps); + if (!Number.isFinite(time) || !Number.isFinite(min) || max <= min) return (index / (events.length - 1)) * 100; + return ((time - min) / (max - min)) * 100; +} + +function ScheduleTimeline({ schedule, loading }: { schedule?: TaskSchedule; loading: boolean }) { + if (loading) { + return ( +
+ + 加载运行时间线… +
+ ); + } + const events = [...(schedule?.history ?? [])].reverse(); + if (!events.length) { + return
暂无运行记录,计划进入第一个窗口后会显示时间线。
; + } + return ( +
+
运行时间线
+
+
+ {events.map((event, index) => ( +
+
+
+ {historyActionText(event.action)} +
+
+ ))} +
+
+ {events.slice(-20).map((event) => ( +
+ {historyActionText(event.action)} + {formatTime(event.created_at)} + {event.task_id ? 任务 #{event.task_id} : null} + {event.message ? ( + {event.message} + ) : null} +
+ ))} +
+
+ ); +} + function statusVariant(status: TaskSchedule["status"]): "default" | "destructive" | "secondary" { if (status === "running") return "default"; if (status === "error") return "destructive"; @@ -314,6 +402,12 @@ export default function SchedulesPage() { const [loading, setLoading] = React.useState(true); const [editing, setEditing] = React.useState<{ id?: number; value: ScheduleInput } | null>(null); const [saving, setSaving] = React.useState(false); + const [query, setQuery] = React.useState(""); + const [page, setPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState("10"); + const [expandedId, setExpandedId] = React.useState(null); + const [timelineById, setTimelineById] = React.useState>({}); + const [timelineLoadingId, setTimelineLoadingId] = React.useState(null); const load = React.useCallback(async () => { setLoading(true); try { @@ -329,6 +423,47 @@ export default function SchedulesPage() { React.useEffect(() => { void load(); }, [load]); + const filteredItems = React.useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) return items; + return items.filter((item) => + [item.name, String(item.id), periodLabel(item), windowLabel(item), ...item.task_ids.map(String)] + .join(" ") + .toLowerCase() + .includes(normalized), + ); + }, [items, query]); + const size = Number(pageSize); + const totalPages = Math.max(1, Math.ceil(filteredItems.length / size)); + const visibleItems = filteredItems.slice((page - 1) * size, page * size); + React.useEffect(() => { + setPage((current) => Math.min(current, totalPages)); + }, [totalPages]); + const updateQuery = (value: string) => { + setQuery(value); + setPage(1); + }; + const updatePageSize = (value: string) => { + setPageSize(value); + setPage(1); + }; + const toggleTimeline = async (id: number) => { + if (expandedId === id) { + setExpandedId(null); + return; + } + setExpandedId(id); + if (timelineById[id]) return; + setTimelineLoadingId(id); + try { + const detail = await api.schedule(id); + setTimelineById((current) => ({ ...current, [id]: detail })); + } catch (e) { + toast.error((e as Error).message); + } finally { + setTimelineLoadingId(null); + } + }; const save = async (value: ScheduleInput) => { if ( !value.name.trim() || @@ -388,105 +523,168 @@ export default function SchedulesPage() { ); } else { scheduleContent = ( -
- {items.map((item) => ( -
-
-
-
-
{item.name}
-
计划 #{item.id}
-
- - {statusText(item)} - -
-
{timezoneLabel(item)}
-
-
-
- 运行周期: - {periodLabel(item)} -
-
- 时间窗口: - {windowLabel(item)} -
-
- 绑定任务: - {item.task_ids.length - ? `${item.task_ids.length} 个(${item.task_ids.map((id) => `#${id}`).join("、")})` - : "无"} -
-
-
-
- 下一次开始: - {nextWindowLabel(item.next_start, item.timezone)} -
-
- 下一次结束: - {nextWindowLabel(item.next_end, item.timezone)} -
-
- 运行次数: - {item.run_count ?? 0} 次{item.last_run_at ? ` · 最近 ${formatTime(item.last_run_at)}` : ""} -
-
- 最近变化:{formatTime(item.last_transition_at)} - {item.last_error ? ` · ${scheduleErrorText(item.last_error)}` : ""} -
-
-
- - {item.enabled ? ( - - ) : ( - - )} - - -
+
+
+
+ + updateQuery(event.target.value)} + placeholder="检索计划名称、计划 ID 或任务 ID" + className="h-9 pl-8" + />
- ))} +
+ 每页 + + 共 {filteredItems.length} 条 +
+
+ {filteredItems.length === 0 ? ( + + 没有匹配的计划任务。 + + ) : ( +
+ {visibleItems.map((item) => ( + +
+
+
+
+
{item.name}
+
计划 #{item.id}
+
+ + {statusText(item)} + +
+
{timezoneLabel(item)}
+
+
+
+ 运行周期: + {periodLabel(item)} +
+
+ 时间窗口: + {windowLabel(item)} +
+
+ 绑定任务: + {item.task_ids.length + ? `${item.task_ids.length} 个(${item.task_ids.map((id) => `#${id}`).join("、")})` + : "无"} +
+
+
+
+ 下一次开始: + {nextWindowLabel(item.next_start, item.timezone)} +
+
+ 下一次结束: + {nextWindowLabel(item.next_end, item.timezone)} +
+
+ 运行次数: + {item.run_count ?? 0} 次{item.last_run_at ? ` · 最近 ${formatTime(item.last_run_at)}` : ""} +
+
+ 最近变化:{formatTime(item.last_transition_at)} + {item.last_error ? ` · ${scheduleErrorText(item.last_error)}` : ""} +
+
+
+ + + {item.enabled ? ( + + ) : ( + + )} + + +
+
+ {expandedId === item.id ? ( +
+ +
+ ) : null} +
+ ))} +
+ )} +
+ + 第 {page} / {totalPages} 页 + +
+ + +
+
); }