diff --git a/.env.example b/.env.example index ae3d1e38b..e78c567a6 100644 --- a/.env.example +++ b/.env.example @@ -37,4 +37,7 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE' TURBO_TELEMETRY_DISABLED=1 # Enable kubernetes tool -# ENABLE_KUBERNETES=true \ No newline at end of file +# ENABLE_KUBERNETES=true + +# Enable mock integration +UNSAFE_ENABLE_MOCK_INTEGRATION=true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b35fd56f4..5a65e03d9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -31,6 +31,7 @@ body: label: Version description: What version of Homarr are you running? options: + - 1.26.0 - 1.25.0 - 1.24.0 - 1.23.0 diff --git a/Dockerfile b/Dockerfile index 2c0d2eb1e..81e3435cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22.16.0-alpine AS base +FROM node:22.17.0-alpine AS base FROM base AS builder RUN apk add --no-cache libc6-compat diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 725fbe473..a76c1a914 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -29,6 +29,7 @@ "@homarr/db": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", "@homarr/docker": "workspace:^0.1.0", + "@homarr/env": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0", "@homarr/forms-collection": "workspace:^0.1.0", "@homarr/gridstack": "^1.12.0", @@ -56,9 +57,9 @@ "@mantine/tiptap": "^8.1.2", "@million/lint": "1.0.14", "@tabler/icons-react": "^3.34.0", - "@tanstack/react-query": "^5.81.4", - "@tanstack/react-query-devtools": "^5.81.4", - "@tanstack/react-query-next-experimental": "^5.81.4", + "@tanstack/react-query": "^5.81.5", + "@tanstack/react-query-devtools": "^5.81.5", + "@tanstack/react-query-next-experimental": "^5.81.5", "@trpc/client": "^11.4.3", "@trpc/next": "^11.4.3", "@trpc/react-query": "^11.4.3", @@ -69,13 +70,13 @@ "chroma-js": "^3.1.2", "clsx": "^2.1.1", "dayjs": "^1.11.13", - "dotenv": "^16.6.0", + "dotenv": "^17.0.1", "flag-icons": "^7.5.0", "glob": "^11.0.3", "jotai": "^2.12.5", "mantine-react-table": "2.0.0-beta.9", - "next": "15.3.4", - "postcss-preset-mantine": "^1.17.0", + "next": "15.3.5", + "postcss-preset-mantine": "^1.18.0", "prismjs": "^1.30.0", "react": "19.1.0", "react-dom": "19.1.0", @@ -83,22 +84,22 @@ "react-simple-code-editor": "^0.14.1", "sass": "^1.89.2", "superjson": "2.2.2", - "swagger-ui-react": "^5.25.3", + "swagger-ui-react": "^5.26.0", "use-deep-compare-effect": "^1.8.1", - "zod": "^3.25.67" + "zod": "^3.25.74" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0", "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", "@types/chroma-js": "3.1.1", - "@types/node": "^22.15.33", + "@types/node": "^22.16.0", "@types/prismjs": "^1.26.5", "@types/react": "19.1.8", "@types/react-dom": "19.1.6", "@types/swagger-ui-react": "^5.18.0", "concurrently": "^9.2.0", - "eslint": "^9.29.0", + "eslint": "^9.30.1", "node-loader": "^2.1.0", "prettier": "^3.6.2", "typescript": "^5.8.3" diff --git a/apps/nextjs/public/images/mock/avatar.jpg b/apps/nextjs/public/images/mock/avatar.jpg new file mode 100644 index 000000000..c06c8c88c Binary files /dev/null and b/apps/nextjs/public/images/mock/avatar.jpg differ diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx index a697b9270..dd8b0a5de 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx @@ -10,15 +10,20 @@ import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; import { IntegrationAvatar } from "@homarr/ui"; -export const IntegrationCreateDropdownContent = () => { +interface IntegrationCreateDropdownContentProps { + enableMockIntegration: boolean; +} + +export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => { const t = useI18n(); const [search, setSearch] = useState(""); const filteredKinds = useMemo(() => { - return integrationKinds.filter((kind) => - getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()), - ); - }, [search]); + return integrationKinds + .filter((kind) => enableMockIntegration || kind !== "mock") + .filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim())) + .sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB))); + }, [search, enableMockIntegration]); const handleSearch = React.useCallback( (event: ChangeEvent) => setSearch(event.target.value), diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx index d2271a48c..48ead515d 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx @@ -41,6 +41,7 @@ import { CountBadge, IntegrationAvatar } from "@homarr/ui"; import { ManageContainer } from "~/components/manage/manage-container"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { NoResults } from "~/components/no-results"; +import { env } from "~/env"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { DeleteIntegrationActionButton } from "./_integration-buttons"; import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown"; @@ -114,7 +115,7 @@ const IntegrationSelectMenu = ({ children }: PropsWithChildren) => { > {children} - + ); 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(); + }, + }, + ); + })} + > + +