From 9398dd983cce00959af2a4885e92da6acfe2f57e Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Thu, 3 Jul 2025 20:59:26 +0200 Subject: [PATCH] feat(tasks): allow management of job intervals and disabling them (#3408) --- .../tools/tasks/_components/jobs-list.tsx | 315 ++- apps/tasks/package.json | 3 +- apps/tasks/src/job-manager.ts | 109 + apps/tasks/src/main.ts | 39 +- packages/api/package.json | 2 +- packages/api/src/router/cron-jobs.ts | 51 +- packages/common/src/cookie.ts | 4 +- .../eslint.config.js | 0 .../package.json | 20 +- packages/cron-job-api/src/client.ts | 20 + packages/cron-job-api/src/constants.ts | 3 + packages/cron-job-api/src/env.ts | 11 + packages/cron-job-api/src/index.ts | 82 + .../tsconfig.json | 0 packages/cron-job-runner/index.ts | 1 - packages/cron-job-runner/src/index.ts | 45 - packages/cron-job-runner/src/register.ts | 12 - packages/cron-jobs-core/package.json | 1 + packages/cron-jobs-core/src/creator.ts | 62 +- packages/cron-jobs-core/src/group.ts | 46 +- packages/cron-jobs/src/jobs/analytics.ts | 1 + packages/cron-jobs/src/jobs/icons-updater.ts | 15 +- .../mysql/0033_add_cron_job_configuration.sql | 6 + .../migrations/mysql/meta/0033_snapshot.json | 2093 +++++++++++++++++ .../db/migrations/mysql/meta/_journal.json | 7 + .../0033_add_cron_job_configuration.sql | 5 + .../migrations/sqlite/meta/0033_snapshot.json | 2008 ++++++++++++++++ .../db/migrations/sqlite/meta/_journal.json | 7 + packages/db/schema/index.ts | 1 + packages/db/schema/mysql.ts | 6 + packages/db/schema/sqlite.ts | 6 + packages/translation/src/lang/en.json | 18 +- packages/ui/package.json | 6 +- packages/ui/src/icons/create.ts | 27 + packages/ui/src/icons/index.ts | 12 + pnpm-lock.yaml | 373 ++- scripts/run.sh | 2 + 37 files changed, 5224 insertions(+), 195 deletions(-) create mode 100644 apps/tasks/src/job-manager.ts rename packages/{cron-job-runner => cron-job-api}/eslint.config.js (100%) rename packages/{cron-job-runner => cron-job-api}/package.json (61%) create mode 100644 packages/cron-job-api/src/client.ts create mode 100644 packages/cron-job-api/src/constants.ts create mode 100644 packages/cron-job-api/src/env.ts create mode 100644 packages/cron-job-api/src/index.ts rename packages/{cron-job-runner => cron-job-api}/tsconfig.json (100%) delete mode 100644 packages/cron-job-runner/index.ts delete mode 100644 packages/cron-job-runner/src/index.ts delete mode 100644 packages/cron-job-runner/src/register.ts create mode 100644 packages/db/migrations/mysql/0033_add_cron_job_configuration.sql create mode 100644 packages/db/migrations/mysql/meta/0033_snapshot.json create mode 100644 packages/db/migrations/sqlite/0033_add_cron_job_configuration.sql create mode 100644 packages/db/migrations/sqlite/meta/0033_snapshot.json create mode 100644 packages/ui/src/icons/create.ts create mode 100644 packages/ui/src/icons/index.ts diff --git a/apps/nextjs/src/app/[locale]/manage/tools/tasks/_components/jobs-list.tsx b/apps/nextjs/src/app/[locale]/manage/tools/tasks/_components/jobs-list.tsx index 53058ad80..763550829 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/tasks/_components/jobs-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/tasks/_components/jobs-list.tsx @@ -1,89 +1,216 @@ "use client"; -import React from "react"; -import { ActionIcon, Badge, Card, Group, Stack, Text } from "@mantine/core"; -import { useListState } from "@mantine/hooks"; -import { IconPlayerPlay } from "@tabler/icons-react"; +import React, { useState, useTransition } from "react"; +import { ActionIcon, Badge, Button, Card, Group, Select, Stack, Text } from "@mantine/core"; +import { useMap } from "@mantine/hooks"; +import { IconPlayerPlay, IconPower, IconSettings } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { useTimeAgo } from "@homarr/common"; +import { getMantineColor, useTimeAgo } from "@homarr/common"; import type { TaskStatus } from "@homarr/cron-job-status"; -import type { TranslationKeys } from "@homarr/translation"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useForm } from "@homarr/form"; +import { createModal, useModalAction } from "@homarr/modals"; +import { TranslationFunction } from "@homarr/translation"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { IconPowerOff } from "@homarr/ui/icons"; interface JobsListProps { initialJobs: RouterOutputs["cronJobs"]["getJobs"]; } -interface JobState { - job: JobsListProps["initialJobs"][number]; +type JobName = RouterOutputs["cronJobs"]["getJobs"][number]["name"]; + +export const JobsList = ({ initialJobs }: JobsListProps) => { + const [jobs] = clientApi.cronJobs.getJobs.useSuspenseQuery(undefined, { + initialData: initialJobs, + refetchOnMount: false, + }); + + const jobStatusMap = useMap(initialJobs.map(({ name }) => [name, null] as const)); + + clientApi.cronJobs.subscribeToStatusUpdates.useSubscription(undefined, { + onData: (data) => { + jobStatusMap.set(data.name, data); + }, + }); + + return ( + + {jobs.map((job) => { + const status = jobStatusMap.get(job.name); + + return ; + })} + + ); +}; + +const cronExpressions = [ + { + value: "*/5 * * * * *", + label: (t) => t("management.page.tool.tasks.interval.seconds", { interval: 5 }), + }, + { + value: "*/10 * * * * *", + label: (t) => t("management.page.tool.tasks.interval.seconds", { interval: 10 }), + }, + { + value: "*/20 * * * * *", + label: (t) => t("management.page.tool.tasks.interval.seconds", { interval: 20 }), + }, + { + value: "* * * * *", + label: (t) => t("management.page.tool.tasks.interval.minutes", { interval: 1 }), + }, + { + value: "*/5 * * * *", + label: (t) => t("management.page.tool.tasks.interval.minutes", { interval: 5 }), + }, + { + value: "*/10 * * * *", + label: (t) => t("management.page.tool.tasks.interval.minutes", { interval: 10 }), + }, + { + value: "*/15 * * * *", + label: (t) => t("management.page.tool.tasks.interval.minutes", { interval: 15 }), + }, + { + value: "0 * * * *", + label: (t) => t("management.page.tool.tasks.interval.hours", { interval: 1 }), + }, + { + value: "0 0 * * */1", + label: (t) => t("management.page.tool.tasks.interval.midnight"), + }, + { + value: "0 0 * * 1", + label: (t) => t("management.page.tool.tasks.interval.weeklyMonday"), + }, +] satisfies { value: string; label: (t: TranslationFunction) => string }[]; + +interface JobCardProps { + job: RouterOutputs["cronJobs"]["getJobs"][number]; status: TaskStatus | null; } -export const JobsList = ({ initialJobs }: JobsListProps) => { - const t = useScopedI18n("management.page.tool.tasks"); - const [jobs, handlers] = useListState( - initialJobs.map((job) => ({ - job, - status: null, - })), - ); - clientApi.cronJobs.subscribeToStatusUpdates.useSubscription(undefined, { - onData: (data) => { - const jobByName = jobs.find((job) => job.job.name === data.name); - if (!jobByName) { - return; - } - handlers.applyWhere( - (job) => job.job.name === data.name, - (job) => ({ ...job, status: data }), - ); - }, - }); - const { mutateAsync } = clientApi.cronJobs.triggerJob.useMutation(); +const JobCard = ({ job, status }: JobCardProps) => { + const t = useI18n(); + const tTasks = useScopedI18n("management.page.tool.tasks"); + const triggerMutation = clientApi.cronJobs.triggerJob.useMutation(); const handleJobTrigger = React.useCallback( - async (job: JobState) => { - if (job.status?.status === "running") { - return; - } - await mutateAsync(job.job.name); + async (name: JobName) => { + if (status?.status === "running") return; + await triggerMutation.mutateAsync(name); }, - [mutateAsync], + [triggerMutation, status], ); - return ( - - {jobs.map((job) => ( - - - - - {t(`job.${job.job.name}.label` as TranslationKeys)} - {job.status?.status === "idle" && {t("status.idle")}} - {job.status?.status === "running" && {t("status.running")}} - {job.status?.lastExecutionStatus === "error" && {t("status.error")}} - - {job.status && } - - {!job.job.preventManualExecution && ( - handleJobTrigger(job)} - disabled={job.status?.status === "running"} - variant={"default"} - size={"xl"} - radius={"xl"} - > - - + const { openModal } = useModalAction(TaskConfigurationModal); + const [isEnabled, setEnabled] = useState(job.isEnabled); + const disableMutation = clientApi.cronJobs.disableJob.useMutation(); + const enableMutation = clientApi.cronJobs.enableJob.useMutation(); + + const [activeStatePending, startActiveTransition] = useTransition(); + const handleActiveChange = () => + startActiveTransition(async () => { + if (isEnabled) { + await disableMutation.mutateAsync(job.name, { + onSuccess() { + setEnabled(false); + }, + }); + } else { + await enableMutation.mutateAsync(job.name, { + onSuccess() { + setEnabled(true); + }, + }); + } + }); + + return ( + + + + + {tTasks(`job.${job.name}.label`)} + + {status?.lastExecutionStatus === "error" && {tTasks("status.error")}} + + + {status && ( + <> + + + • + + + {cronExpressions.find((expression) => expression.value === job.cron)?.label(t) ?? job.cron} + + )} - - ))} - + + + + {!job.preventManualExecution && ( + handleJobTrigger(job.name)} + disabled={status?.status === "running"} + loading={triggerMutation.isPending} + variant="default" + size="xl" + radius="xl" + > + + + )} + + {isEnabled ? ( + + ) : ( + + )} + + + openModal( + { job }, + { + title: tTasks("settings.title", { + jobName: tTasks(`job.${job.name}.label`), + }), + }, + ) + } + variant={"default"} + size={"xl"} + radius={"xl"} + > + + + + + ); }; +interface StatusBadgeProps { + isEnabled: boolean; + status: TaskStatus | null; +} + +const StatusBadge = ({ isEnabled, status }: StatusBadgeProps) => { + const t = useScopedI18n("management.page.tool.tasks"); + if (!isEnabled) return {t("status.disabled")}; + + if (!status) return null; + + if (status.status === "running") return {t("status.running")}; + return {t("status.idle")}; +}; + const TimeAgo = ({ timestamp }: { timestamp: string }) => { const timeAgo = useTimeAgo(new Date(timestamp)); @@ -93,3 +220,65 @@ const TimeAgo = ({ timestamp }: { timestamp: string }) => { ); }; + +const TaskConfigurationModal = createModal<{ + job: RouterOutputs["cronJobs"]["getJobs"][number]; +}>(({ actions, innerProps }) => { + const t = useI18n(); + const form = useForm({ + initialValues: { + cron: innerProps.job.cron, + }, + }); + const { mutateAsync, isPending } = clientApi.cronJobs.updateJobInterval.useMutation(); + const utils = clientApi.useUtils(); + + return ( +
{ + utils.cronJobs.getJobs.setData(undefined, (data) => + data?.map((job) => + job.name === innerProps.job.name + ? { + ...job, + cron: values.cron, + } + : job, + ), + ); + await mutateAsync( + { + name: innerProps.job.name, + cron: values.cron, + }, + { + onSuccess() { + actions.closeModal(); + }, + async onSettled() { + await utils.cronJobs.getJobs.invalidate(); + }, + }, + ); + })} + > + +