Merge branch 'dev' into feat/rework-perferences
This commit is contained in:
174 files changed
+27098
-8861
No files matched your search
@@ -10,4 +10,21 @@ export default [
|
||||
...baseConfig,
|
||||
...reactConfig,
|
||||
...nextjsConfig,
|
||||
{
|
||||
files: ["public/sw.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
self: "readonly",
|
||||
clients: "readonly",
|
||||
skipWaiting: "readonly",
|
||||
importScripts: "readonly",
|
||||
caches: "readonly",
|
||||
fetch: "readonly",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -32,6 +32,8 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
preloadEntriesOnStart: false,
|
||||
webpackMemoryOptimizations: true,
|
||||
},
|
||||
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
||||
images: {
|
||||
@@ -42,9 +44,18 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
// skipcq: JS-0116
|
||||
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/sw.js",
|
||||
headers: [
|
||||
{ key: "Content-Type", value: "application/javascript; charset=utf-8" },
|
||||
{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
|
||||
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self'" },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/(.*)", // Apply CSP to all routes
|
||||
headers: [
|
||||
|
||||
@@ -44,9 +44,11 @@
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
"@homarr/spotlight": "workspace:^0.1.0",
|
||||
"@homarr/tasks": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/websocket": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "catalog:",
|
||||
"@mantine/core": "catalog:",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
@@ -8,13 +8,12 @@ import dayjs from "dayjs";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||
|
||||
export const CustomMantineProvider = ({
|
||||
children,
|
||||
defaultColorScheme,
|
||||
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
|
||||
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
|
||||
const manager = useColorSchemeManager();
|
||||
return (
|
||||
<DirectionProvider>
|
||||
@@ -35,7 +34,7 @@ export const CustomMantineProvider = ({
|
||||
export function useColorSchemeManager(): MantineColorSchemeManager {
|
||||
const { data: session } = useSession();
|
||||
|
||||
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
|
||||
const updateCookieValue = (value: MantineColorScheme) => {
|
||||
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
|
||||
};
|
||||
|
||||
@@ -60,7 +59,6 @@ export function useColorSchemeManager(): MantineColorSchemeManager {
|
||||
},
|
||||
|
||||
set: (value) => {
|
||||
if (value === "auto") return;
|
||||
try {
|
||||
if (session) {
|
||||
mutateColorScheme({ colorScheme: value });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Inter } from "next/font/google";
|
||||
import "@homarr/notifications/styles.css";
|
||||
import "@homarr/spotlight/styles.css";
|
||||
import "@homarr/ui/styles.css";
|
||||
import "~/styles/color-scheme.scss";
|
||||
import "~/styles/scroll-area.scss";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -25,6 +26,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import { DayJsLoader } from "./_client-providers/dayjs-loader";
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
@@ -129,7 +131,7 @@ export default async function Layout(props: {
|
||||
dir={direction}
|
||||
data-mantine-color-scheme={colorScheme}
|
||||
style={{
|
||||
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
|
||||
backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff",
|
||||
}}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
@@ -141,6 +143,7 @@ export default async function Layout(props: {
|
||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||
<StackedProvider>
|
||||
<Notifications />
|
||||
<ServiceWorkerRegistration />
|
||||
{props.children}
|
||||
</StackedProvider>
|
||||
</body>
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||
import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
|
||||
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
@@ -34,6 +34,7 @@ export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: Serve
|
||||
};
|
||||
|
||||
const appearanceIcons = {
|
||||
auto: IconDeviceDesktop,
|
||||
light: IconSun,
|
||||
dark: IconMoon,
|
||||
};
|
||||
|
||||
@@ -58,6 +58,18 @@ const createColumns = (
|
||||
return <ContainerStateBadge state={cell.row.original.state} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "host",
|
||||
header: t("docker.field.host.label"),
|
||||
size: 140,
|
||||
Cell({ row }) {
|
||||
return (
|
||||
<Text truncate="end" title={row.original.host}>
|
||||
{row.original.host}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "image",
|
||||
header: t("docker.field.containerImage.label"),
|
||||
|
||||
+6
@@ -26,6 +26,7 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
|
||||
form.setInitialValues({
|
||||
defaultSearchEngineId: variables.defaultSearchEngineId,
|
||||
openInNewTab: variables.openInNewTab,
|
||||
ddgBangsEnabled: variables.ddgBangsEnabled,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changeSearchPreferences.notification.success.message"),
|
||||
@@ -41,6 +42,7 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
|
||||
initialValues: {
|
||||
defaultSearchEngineId: user.defaultSearchEngineId,
|
||||
openInNewTab: user.openSearchInNewTab,
|
||||
ddgBangsEnabled: user.ddgBangs,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,6 +66,10 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
|
||||
label={t("user.field.openSearchInNewTab.label")}
|
||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.ddgBangs.label")}
|
||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core";
|
||||
import { IconCheck, IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
|
||||
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const schemeIcons: Record<ColorScheme, React.ElementType> = {
|
||||
auto: IconDeviceDesktop,
|
||||
light: IconSun,
|
||||
dark: IconMoon,
|
||||
};
|
||||
|
||||
interface ColorSchemeComboboxProps {
|
||||
value: ColorScheme;
|
||||
onChange: (value: ColorScheme) => void;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export const ColorSchemeCombobox = ({ value, onChange, width }: ColorSchemeComboboxProps) => {
|
||||
const tOptions = useScopedI18n("common.colorScheme.options");
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
|
||||
const handleOnOptionSubmit = React.useCallback(
|
||||
(val: string) => {
|
||||
if (!val) return;
|
||||
onChange(val as ColorScheme);
|
||||
combobox.closeDropdown();
|
||||
},
|
||||
[onChange, combobox],
|
||||
);
|
||||
|
||||
const handleOnClick = React.useCallback(() => {
|
||||
combobox.toggleDropdown();
|
||||
}, [combobox]);
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
rightSection={<Combobox.Chevron />}
|
||||
rightSectionPointerEvents="none"
|
||||
onClick={handleOnClick}
|
||||
variant="filled"
|
||||
w={width}
|
||||
>
|
||||
<OptionItem scheme={value} currentScheme={value} tOptions={tOptions} />
|
||||
</InputBase>
|
||||
</Combobox.Target>
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{colorSchemes.map((scheme) => (
|
||||
<Combobox.Option value={scheme} key={scheme}>
|
||||
<OptionItem scheme={scheme} currentScheme={value} tOptions={tOptions} showCheck />
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionItem = ({
|
||||
scheme,
|
||||
currentScheme,
|
||||
tOptions,
|
||||
showCheck,
|
||||
}: {
|
||||
scheme: ColorScheme;
|
||||
currentScheme: ColorScheme;
|
||||
tOptions: (key: ColorScheme) => string;
|
||||
showCheck?: boolean;
|
||||
}) => {
|
||||
const Icon = schemeIcons[scheme];
|
||||
|
||||
return (
|
||||
<Group wrap="nowrap" justify="space-between">
|
||||
<Group wrap="nowrap">
|
||||
<Icon size={16} stroke={1.5} />
|
||||
<Text>{tOptions(scheme)}</Text>
|
||||
</Group>
|
||||
{showCheck && scheme === currentScheme && <IconCheck color="currentColor" size={16} />}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||
import { useMantineColorScheme } from "@mantine/core";
|
||||
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
import { ColorSchemeCombobox } from "./color-scheme-combobox";
|
||||
|
||||
interface CurrentColorSchemeComboboxProps {
|
||||
w?: string;
|
||||
}
|
||||
|
||||
export const CurrentColorSchemeCombobox = ({ w }: CurrentColorSchemeComboboxProps) => {
|
||||
const tOptions = useScopedI18n("common.colorScheme.options");
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
|
||||
return (
|
||||
<SelectWithCustomItems
|
||||
value={colorScheme}
|
||||
onChange={(value) => setColorScheme((value as ColorScheme | null) ?? "light")}
|
||||
data={colorSchemes.map((scheme) => ({
|
||||
value: scheme,
|
||||
label: tOptions(scheme),
|
||||
}))}
|
||||
SelectOption={ColorSchemeCustomOption}
|
||||
w={w}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const appearanceIcons = {
|
||||
light: IconSun,
|
||||
dark: IconMoon,
|
||||
};
|
||||
|
||||
const ColorSchemeCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
||||
const Icon = appearanceIcons[value];
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Icon size={16} stroke={1.5} />
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
return <ColorSchemeCombobox value={colorScheme as ColorScheme} onChange={setColorScheme} width={w} />;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const ServiceWorkerRegistration = () => {
|
||||
useEffect(() => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none" }).catch((error) => {
|
||||
console.error("Service worker registration failed", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -5,16 +5,7 @@ import { useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
||||
import {
|
||||
IconCheck,
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSun,
|
||||
IconTool,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
@@ -24,6 +15,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
||||
|
||||
@@ -34,13 +26,9 @@ interface UserAvatarMenuProps {
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
|
||||
const ColorSchemeIcon = colorScheme === "dark" ? IconSun : IconMoon;
|
||||
|
||||
const colorSchemeText = colorScheme === "dark" ? t("switchToLightMode") : t("switchToDarkMode");
|
||||
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -67,14 +55,14 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
<Menu width={300} withArrow withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
||||
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
|
||||
{colorSchemeText}
|
||||
</Menu.Item>
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("@homarr/tasks");
|
||||
await import("@homarr/websocket");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Prevent flash of white background when color scheme is "auto" and OS prefers dark.
|
||||
// CSS media queries are evaluated synchronously during the browser's initial paint,
|
||||
// before any JavaScript executes, so this eliminates the white→dark flicker.
|
||||
html[data-mantine-color-scheme="auto"] {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html[data-mantine-color-scheme="auto"] {
|
||||
background-color: #242424;
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,11 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/main.ts"
|
||||
},
|
||||
"main": "./src/main.ts",
|
||||
"types": "./src/main.ts",
|
||||
"scripts": {
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:*.node --external:@opentelemetry/api --external:deasync --external:bcrypt --outfile=tasks.cjs",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
@@ -24,7 +23,6 @@
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^",
|
||||
"@homarr/cron-job-api": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs-core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
@@ -37,6 +35,7 @@
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"croner": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"fastify": "catalog:",
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { schedule, validate as validateCron } from "node-cron";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { IJobManager } from "@homarr/cron-job-api";
|
||||
import type { jobGroup as cronJobGroup, JobGroupKeys } from "@homarr/cron-jobs";
|
||||
import type { Database, InferInsertModel } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { cronJobConfigurations } from "@homarr/db/schema";
|
||||
|
||||
const logger = createLogger({ module: "jobManager" });
|
||||
|
||||
export class JobManager implements IJobManager {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private jobGroup: typeof cronJobGroup,
|
||||
) {}
|
||||
|
||||
public async startAsync(name: JobGroupKeys): Promise<void> {
|
||||
await this.jobGroup.startAsync(name);
|
||||
}
|
||||
public async triggerAsync(name: JobGroupKeys): Promise<void> {
|
||||
await this.jobGroup.runManuallyAsync(name);
|
||||
}
|
||||
public async stopAsync(name: JobGroupKeys): Promise<void> {
|
||||
await this.jobGroup.stopAsync(name);
|
||||
}
|
||||
public async updateIntervalAsync(name: JobGroupKeys, cron: string): Promise<void> {
|
||||
logger.info("Updating cron job interval", { name, expression: cron });
|
||||
const job = this.jobGroup.getJobRegistry().get(name);
|
||||
if (!job) throw new Error(`Job ${name} not found`);
|
||||
if (!validateCron(cron)) {
|
||||
throw new Error(`Invalid cron expression: ${cron}`);
|
||||
}
|
||||
if (job.preventCustomInterval && cron !== job.cronExpression) {
|
||||
throw new Error(`Custom cron expressions are not allowed for job ${name}`);
|
||||
}
|
||||
|
||||
await this.updateConfigurationAsync(name, { cronExpression: cron });
|
||||
await this.jobGroup.getTask(name)?.destroy();
|
||||
|
||||
this.jobGroup.setTask(
|
||||
name,
|
||||
schedule(cron, () => void job.executeAsync(), {
|
||||
name,
|
||||
}),
|
||||
);
|
||||
logger.info("Cron job interval updated", { name, expression: cron });
|
||||
}
|
||||
public async disableAsync(name: JobGroupKeys): Promise<void> {
|
||||
logger.info("Disabling cron job", { name });
|
||||
const job = this.jobGroup.getJobRegistry().get(name);
|
||||
if (!job) throw new Error(`Job ${name} not found`);
|
||||
|
||||
await this.updateConfigurationAsync(name, { isEnabled: false });
|
||||
await this.jobGroup.stopAsync(name);
|
||||
logger.info("Cron job disabled", { name });
|
||||
}
|
||||
public async enableAsync(name: JobGroupKeys): Promise<void> {
|
||||
logger.info("Enabling cron job", { name });
|
||||
await this.updateConfigurationAsync(name, { isEnabled: true });
|
||||
await this.jobGroup.startAsync(name);
|
||||
logger.info("Cron job enabled", { name });
|
||||
}
|
||||
|
||||
private async updateConfigurationAsync(
|
||||
name: JobGroupKeys,
|
||||
configuration: Omit<Partial<InferInsertModel<typeof cronJobConfigurations>>, "name">,
|
||||
) {
|
||||
const existingConfig = await this.db.query.cronJobConfigurations.findFirst({
|
||||
where: (table, { eq }) => eq(table.name, name),
|
||||
});
|
||||
|
||||
logger.debug("Updating cron job configuration", {
|
||||
name,
|
||||
configuration: JSON.stringify(configuration),
|
||||
exists: Boolean(existingConfig),
|
||||
});
|
||||
|
||||
if (existingConfig) {
|
||||
await this.db
|
||||
.update(cronJobConfigurations)
|
||||
// prevent updating the name, as it is the primary key
|
||||
.set({ ...configuration, name: undefined })
|
||||
.where(eq(cronJobConfigurations.name, name));
|
||||
logger.debug("Cron job configuration updated", {
|
||||
name,
|
||||
configuration: JSON.stringify(configuration),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const job = this.jobGroup.getJobRegistry().get(name);
|
||||
if (!job) throw new Error(`Job ${name} not found`);
|
||||
|
||||
await this.db.insert(cronJobConfigurations).values({
|
||||
name,
|
||||
cronExpression: configuration.cronExpression ?? job.cronExpression,
|
||||
isEnabled: configuration.isEnabled ?? true,
|
||||
});
|
||||
logger.debug("Cron job configuration updated", {
|
||||
name,
|
||||
configuration: JSON.stringify(configuration),
|
||||
});
|
||||
}
|
||||
|
||||
public async getAllAsync(): Promise<
|
||||
{
|
||||
name: JobGroupKeys;
|
||||
cron: string;
|
||||
preventManualExecution: boolean;
|
||||
preventCustomInterval: boolean;
|
||||
isEnabled: boolean;
|
||||
}[]
|
||||
> {
|
||||
const configurations = await this.db.query.cronJobConfigurations.findMany();
|
||||
|
||||
return [...this.jobGroup.getJobRegistry().entries()].map(([name, job]) => {
|
||||
const config = configurations.find((config) => config.name === name);
|
||||
return {
|
||||
name,
|
||||
cron: config?.cronExpression ?? job.cronExpression,
|
||||
preventManualExecution: job.preventManualExecution,
|
||||
preventCustomInterval: job.preventCustomInterval,
|
||||
isEnabled: config?.isEnabled ?? true,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,12 @@
|
||||
// This import has to be the first import in the file so that the agent is overridden before any other modules are imported.
|
||||
import "./overrides";
|
||||
|
||||
import type { FastifyTRPCPluginOptions } from "@trpc/server/adapters/fastify";
|
||||
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
|
||||
import fastify from "fastify";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { JobRouter } from "@homarr/cron-job-api";
|
||||
import { jobRouter } from "@homarr/cron-job-api";
|
||||
import { CRON_JOB_API_KEY_HEADER, CRON_JOB_API_PATH, CRON_JOB_API_PORT } from "@homarr/cron-job-api/constants";
|
||||
import { jobGroup } from "@homarr/cron-jobs";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { JobManager } from "./job-manager";
|
||||
import { onStartAsync } from "./on-start";
|
||||
|
||||
const logger = createLogger({ module: "tasksMain" });
|
||||
|
||||
const server = fastify({
|
||||
maxParamLength: 5000,
|
||||
});
|
||||
server.register(fastifyTRPCPlugin, {
|
||||
prefix: CRON_JOB_API_PATH,
|
||||
trpcOptions: {
|
||||
router: jobRouter,
|
||||
createContext: ({ req }) => ({
|
||||
manager: new JobManager(db, jobGroup),
|
||||
apiKey: req.headers[CRON_JOB_API_KEY_HEADER] as string | undefined,
|
||||
}),
|
||||
onError({ path, error }) {
|
||||
logger.error(new ErrorWithMetadata("Error in tasks tRPC handler", { path }, { cause: error }));
|
||||
},
|
||||
} satisfies FastifyTRPCPluginOptions<JobRouter>["trpcOptions"],
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
await onStartAsync();
|
||||
await jobGroup.initializeAsync();
|
||||
await jobGroup.startAllAsync();
|
||||
|
||||
try {
|
||||
await server.listen({ port: CRON_JOB_API_PORT });
|
||||
logger.info("Tasks web server started successfully", { port: CRON_JOB_API_PORT });
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata("Failed to start tasks web server", { port: CRON_JOB_API_PORT }, { cause: err }),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
@@ -4,10 +4,12 @@
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/main.ts"
|
||||
},
|
||||
"main": "./src/main.ts",
|
||||
"types": "./src/main.ts",
|
||||
"scripts": {
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:@opentelemetry/api --external:deasync --external:cpu-features --loader:.html=text --loader:.scss=text --loader:.node=text",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
|
||||
Reference in new issue
Block a user