From 9d14fcba3646485d73d20455552e56602a346736 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 15 Aug 2025 20:57:07 +0200 Subject: [PATCH] feat(tasks): replace card layout with table interface for better UX (#3804) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Meier Lukas --- .../tools/tasks/_components/jobs-list.tsx | 284 ----------- .../tools/tasks/_components/tasks-table.tsx | 451 ++++++++++++++++++ .../app/[locale]/manage/tools/tasks/page.tsx | 18 +- packages/translation/src/lang/en.json | 62 ++- 4 files changed, 524 insertions(+), 291 deletions(-) delete mode 100644 apps/nextjs/src/app/[locale]/manage/tools/tasks/_components/jobs-list.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/tasks/_components/tasks-table.tsx 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 deleted file mode 100644 index 763550829..000000000 --- a/apps/nextjs/src/app/[locale]/manage/tools/tasks/_components/jobs-list.tsx +++ /dev/null @@ -1,284 +0,0 @@ -"use client"; - -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 { getMantineColor, useTimeAgo } from "@homarr/common"; -import type { TaskStatus } from "@homarr/cron-job-status"; -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"]; -} - -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; -} - -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 (name: JobName) => { - if (status?.status === "running") return; - await triggerMutation.mutateAsync(name); - }, - [triggerMutation, status], - ); - - 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)); - - return ( - - {timeAgo} - - ); -}; - -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(); - }, - }, - ); - })} - > - - ({ + value, + label: label(t), + }))} + size="sm" + disabled={loadingStates.get(row.original.name)?.interval ?? false} + style={{ minWidth: 180 }} + /> + ); + }, + }, + { + accessorKey: "lastExecutionTime", + header: tTasks("field.lastExecution.label"), + size: 150, + Cell({ row }) { + const status = jobStatusMap.get(row.original.name); + if (!status?.lastExecutionTimestamp) { + return ( + + — + + ); + } + return ; + }, + }, + { + id: "actions", + header: tTasks("field.actions.label"), + size: 120, + enableSorting: false, + Cell({ row }) { + const status = jobStatusMap.get(row.original.name); + + const handleToggleEnabled = () => { + const currentStates = loadingStates.get(row.original.name) ?? { + toggle: false, + trigger: false, + interval: false, + }; + loadingStates.set(row.original.name, { + ...currentStates, + toggle: true, + }); + try { + if (row.original.isEnabled) { + disableMutation.mutate(row.original.name); + } else { + enableMutation.mutate(row.original.name); + } + } finally { + const updatedStates = loadingStates.get(row.original.name) ?? { + toggle: false, + trigger: false, + interval: false, + }; + loadingStates.set(row.original.name, { + ...updatedStates, + toggle: false, + }); + } + }; + + const handleTrigger = () => { + if (status?.status === "running") return; + + const currentStates = loadingStates.get(row.original.name) ?? { + toggle: false, + trigger: false, + interval: false, + }; + loadingStates.set(row.original.name, { + ...currentStates, + trigger: true, + }); + try { + triggerMutation.mutate(row.original.name); + } finally { + const updatedStates = loadingStates.get(row.original.name) ?? { + toggle: false, + trigger: false, + interval: false, + }; + loadingStates.set(row.original.name, { + ...updatedStates, + trigger: false, + }); + } + }; + + return ( + + {!row.original.preventManualExecution && ( + + + + )} + + {row.original.isEnabled ? : } + + + ); + }, + }, +]; + +interface TasksTableProps { + initialJobs: RouterOutputs["cronJobs"]["getJobs"]; +} + +export const TasksTable = ({ initialJobs }: TasksTableProps) => { + const t = useI18n(); + const tTasks = useScopedI18n("management.page.tool.tasks"); + + const { data: jobs } = clientApi.cronJobs.getJobs.useQuery(undefined, { + initialData: initialJobs, + refetchOnMount: false, + }); + + const jobStatusMap = useMap(initialJobs.map(({ name }) => [name, null] as const)); + + const loadingStates = useMap(); + + clientApi.cronJobs.subscribeToStatusUpdates.useSubscription(undefined, { + onData: (data) => { + jobStatusMap.set(data.name, data); + }, + }); + + const triggerMutation = clientApi.cronJobs.triggerJob.useMutation({ + onError() { + showErrorNotification({ + title: t("common.error"), + message: tTasks("trigger.error.message"), + }); + }, + onSuccess() { + showSuccessNotification({ + title: t("common.success"), + message: tTasks("trigger.success.message"), + }); + }, + }); + const updateIntervalMutation = clientApi.cronJobs.updateJobInterval.useMutation({ + onError() { + showErrorNotification({ + title: t("common.error"), + message: tTasks("interval.update.error.message"), + }); + }, + onSuccess: async () => { + await utils.cronJobs.getJobs.invalidate(); + showSuccessNotification({ + title: t("common.success"), + message: tTasks("interval.update.success.message"), + }); + }, + }); + const enableMutation = clientApi.cronJobs.enableJob.useMutation({ + onError() { + showErrorNotification({ + title: t("common.error"), + message: tTasks("toggle.error.message"), + }); + }, + onSuccess: async () => { + await utils.cronJobs.getJobs.invalidate(); + showSuccessNotification({ + title: t("common.success"), + message: tTasks("enable.success.message"), + }); + }, + }); + const disableMutation = clientApi.cronJobs.disableJob.useMutation({ + onError() { + showErrorNotification({ + title: t("common.error"), + message: tTasks("toggle.error.message"), + }); + }, + onSuccess: async () => { + await utils.cronJobs.getJobs.invalidate(); + showSuccessNotification({ + title: t("common.success"), + message: tTasks("disable.success.message"), + }); + }, + }); + + // Utils for refresh functionality + const utils = clientApi.useUtils(); + const handleRefreshAsync = async () => { + try { + await utils.cronJobs.getJobs.invalidate(); + showSuccessNotification({ + title: t("common.success"), + message: tTasks("refresh.success.message"), + }); + } catch { + showErrorNotification({ + title: t("common.error"), + message: tTasks("refresh.error.message"), + }); + } + }; + + const table = useTranslatedMantineReactTable({ + data: jobs, + enableDensityToggle: false, + enableColumnActions: false, + enableColumnFilters: false, + enablePagination: false, + enableRowSelection: false, + enableTableFooter: false, + enableBottomToolbar: false, + enableRowActions: false, + enableColumnOrdering: false, + enableSorting: false, + enableSortingRemoval: false, + positionGlobalFilter: "right", + mantineSearchTextInputProps: { + placeholder: tTasks("table.search", { count: String(jobs.length) }), + style: { minWidth: 300 }, + }, + initialState: { density: "xs", showGlobalFilter: true }, + renderTopToolbarCustomActions: () => ( + + ), + columns: createColumns( + t, + tTasks, + jobStatusMap, + triggerMutation, + updateIntervalMutation, + enableMutation, + disableMutation, + loadingStates, + ), + }); + + return ; +}; + +interface StatusBadgeProps { + isEnabled: boolean; + status: TaskStatus | null; +} + +const StatusBadge = ({ isEnabled, status }: StatusBadgeProps) => { + const tTasks = useScopedI18n("management.page.tool.tasks"); + + if (!isEnabled) { + return ( + + {tTasks("status.disabled")} + + ); + } + + if (!status) return null; + + if (status.status === "running") { + return ( + + {tTasks("status.running")} + + ); + } + + return ( + + {tTasks("status.idle")} + + ); +}; + +const TimeAgo = ({ timestamp }: { timestamp: string }) => { + const timeAgo = useTimeAgo(new Date(timestamp)); + + return ( + + {timeAgo} + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/tasks/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/tasks/page.tsx index 94def6599..d3977bb5d 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/tasks/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/tasks/page.tsx @@ -1,12 +1,13 @@ import { notFound } from "next/navigation"; -import { Box, Title } from "@mantine/core"; +import { Stack, Title } from "@mantine/core"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { createMetaTitle } from "~/metadata"; -import { JobsList } from "./_components/jobs-list"; +import { TasksTable } from "./_components/tasks-table"; export async function generateMetadata() { const session = await auth(); @@ -27,10 +28,15 @@ export default async function TasksPage() { } const jobs = await api.cronJobs.getJobs(); + const tTasks = await getScopedI18n("management.page.tool.tasks"); + return ( - - Tasks - - + <> + + + {tTasks("title")} + + + ); } diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 03590099d..e9a364d03 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1003,6 +1003,7 @@ } }, "common": { + "success": "Success", "beta": "Beta", "error": "Error", "action": { @@ -3260,14 +3261,70 @@ "minutes": "Every {interval, plural, =1 {minute} other {# minutes}}", "hours": "Every {interval, plural, =1 {hour} other {# hours}}", "midnight": "Every day at midnight", - "weeklyMonday": "Every week on monday" + "weeklyMonday": "Every week on monday", + "update": { + "success": { + "message": "Interval updated successfully" + }, + "error": { + "message": "Failed to update interval" + } + } }, "settings": { "title": "Task settings for {jobName}" }, "field": { + "name": { + "label": "Name" + }, "interval": { "label": "Schedule interval" + }, + "lastExecution": { + "label": "Last Execution" + }, + "actions": { + "label": "Actions" + } + }, + "table": { + "search": "Search {count} tasks..." + }, + "action": { + "refresh": { + "label": "Refresh" + } + }, + "refresh": { + "success": { + "message": "Tasks refreshed successfully" + }, + "error": { + "message": "Failed to refresh tasks" + } + }, + "trigger": { + "success": { + "message": "Task triggered successfully" + }, + "error": { + "message": "Failed to trigger task" + } + }, + "enable": { + "success": { + "message": "Task enabled successfully" + } + }, + "disable": { + "success": { + "message": "Task disabled successfully" + } + }, + "toggle": { + "error": { + "message": "Failed to toggle task status" } } }, @@ -3776,6 +3833,9 @@ }, "tools": { "label": "Tools", + "tasks": { + "label": "Tasks" + }, "docker": { "label": "Docker" },