chore(release): automatic release v1.27.0

This commit is contained in:
homarr-releases[bot]
2025-07-04 19:15:22 +00:00
committed by GitHub
178 changed files with 7735 additions and 1293 deletions

View File

@@ -37,4 +37,7 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
TURBO_TELEMETRY_DISABLED=1 TURBO_TELEMETRY_DISABLED=1
# Enable kubernetes tool # Enable kubernetes tool
# ENABLE_KUBERNETES=true # ENABLE_KUBERNETES=true
# Enable mock integration
UNSAFE_ENABLE_MOCK_INTEGRATION=true

View File

@@ -31,6 +31,7 @@ body:
label: Version label: Version
description: What version of Homarr are you running? description: What version of Homarr are you running?
options: options:
- 1.26.0
- 1.25.0 - 1.25.0
- 1.24.0 - 1.24.0
- 1.23.0 - 1.23.0

View File

@@ -1,4 +1,4 @@
FROM node:22.16.0-alpine AS base FROM node:22.17.0-alpine AS base
FROM base AS builder FROM base AS builder
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat

View File

@@ -29,6 +29,7 @@
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0", "@homarr/docker": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0", "@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/gridstack": "^1.12.0", "@homarr/gridstack": "^1.12.0",
@@ -56,9 +57,9 @@
"@mantine/tiptap": "^8.1.2", "@mantine/tiptap": "^8.1.2",
"@million/lint": "1.0.14", "@million/lint": "1.0.14",
"@tabler/icons-react": "^3.34.0", "@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.81.4", "@tanstack/react-query": "^5.81.5",
"@tanstack/react-query-devtools": "^5.81.4", "@tanstack/react-query-devtools": "^5.81.5",
"@tanstack/react-query-next-experimental": "^5.81.4", "@tanstack/react-query-next-experimental": "^5.81.5",
"@trpc/client": "^11.4.3", "@trpc/client": "^11.4.3",
"@trpc/next": "^11.4.3", "@trpc/next": "^11.4.3",
"@trpc/react-query": "^11.4.3", "@trpc/react-query": "^11.4.3",
@@ -69,13 +70,13 @@
"chroma-js": "^3.1.2", "chroma-js": "^3.1.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.6.0", "dotenv": "^17.0.1",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"glob": "^11.0.3", "glob": "^11.0.3",
"jotai": "^2.12.5", "jotai": "^2.12.5",
"mantine-react-table": "2.0.0-beta.9", "mantine-react-table": "2.0.0-beta.9",
"next": "15.3.4", "next": "15.3.5",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.18.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
@@ -83,22 +84,22 @@
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"sass": "^1.89.2", "sass": "^1.89.2",
"superjson": "2.2.2", "superjson": "2.2.2",
"swagger-ui-react": "^5.25.3", "swagger-ui-react": "^5.26.0",
"use-deep-compare-effect": "^1.8.1", "use-deep-compare-effect": "^1.8.1",
"zod": "^3.25.67" "zod": "^3.25.74"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.1", "@types/chroma-js": "3.1.1",
"@types/node": "^22.15.33", "@types/node": "^22.16.0",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"concurrently": "^9.2.0", "concurrently": "^9.2.0",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"node-loader": "^2.1.0", "node-loader": "^2.1.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"typescript": "^5.8.3" "typescript": "^5.8.3"

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

View File

@@ -10,15 +10,20 @@ import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
export const IntegrationCreateDropdownContent = () => { interface IntegrationCreateDropdownContentProps {
enableMockIntegration: boolean;
}
export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => {
const t = useI18n(); const t = useI18n();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => { const filteredKinds = useMemo(() => {
return integrationKinds.filter((kind) => return integrationKinds
getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()), .filter((kind) => enableMockIntegration || kind !== "mock")
); .filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()))
}, [search]); .sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB)));
}, [search, enableMockIntegration]);
const handleSearch = React.useCallback( const handleSearch = React.useCallback(
(event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value), (event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value),

View File

@@ -41,6 +41,7 @@ import { CountBadge, IntegrationAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { env } from "~/env";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons"; import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown"; import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
@@ -114,7 +115,7 @@ const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
> >
{children} {children}
<MenuDropdown> <MenuDropdown>
<IntegrationCreateDropdownContent /> <IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
</MenuDropdown> </MenuDropdown>
</Menu> </Menu>
); );

View File

@@ -1,89 +1,216 @@
"use client"; "use client";
import React from "react"; import React, { useState, useTransition } from "react";
import { ActionIcon, Badge, Card, Group, Stack, Text } from "@mantine/core"; import { ActionIcon, Badge, Button, Card, Group, Select, Stack, Text } from "@mantine/core";
import { useListState } from "@mantine/hooks"; import { useMap } from "@mantine/hooks";
import { IconPlayerPlay } from "@tabler/icons-react"; import { IconPlayerPlay, IconPower, IconSettings } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; 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 { TaskStatus } from "@homarr/cron-job-status";
import type { TranslationKeys } from "@homarr/translation"; import { useForm } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client"; 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 { interface JobsListProps {
initialJobs: RouterOutputs["cronJobs"]["getJobs"]; initialJobs: RouterOutputs["cronJobs"]["getJobs"];
} }
interface JobState { type JobName = RouterOutputs["cronJobs"]["getJobs"][number]["name"];
job: JobsListProps["initialJobs"][number];
export const JobsList = ({ initialJobs }: JobsListProps) => {
const [jobs] = clientApi.cronJobs.getJobs.useSuspenseQuery(undefined, {
initialData: initialJobs,
refetchOnMount: false,
});
const jobStatusMap = useMap<string, TaskStatus | null>(initialJobs.map(({ name }) => [name, null] as const));
clientApi.cronJobs.subscribeToStatusUpdates.useSubscription(undefined, {
onData: (data) => {
jobStatusMap.set(data.name, data);
},
});
return (
<Stack>
{jobs.map((job) => {
const status = jobStatusMap.get(job.name);
return <JobCard key={job.name} job={job} status={status ?? null} />;
})}
</Stack>
);
};
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; status: TaskStatus | null;
} }
export const JobsList = ({ initialJobs }: JobsListProps) => { const JobCard = ({ job, status }: JobCardProps) => {
const t = useScopedI18n("management.page.tool.tasks"); const t = useI18n();
const [jobs, handlers] = useListState<JobState>( const tTasks = useScopedI18n("management.page.tool.tasks");
initialJobs.map((job) => ({ const triggerMutation = clientApi.cronJobs.triggerJob.useMutation();
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 handleJobTrigger = React.useCallback( const handleJobTrigger = React.useCallback(
async (job: JobState) => { async (name: JobName) => {
if (job.status?.status === "running") { if (status?.status === "running") return;
return; await triggerMutation.mutateAsync(name);
}
await mutateAsync(job.job.name);
}, },
[mutateAsync], [triggerMutation, status],
); );
return (
<Stack>
{jobs.map((job) => (
<Card key={job.job.name} withBorder>
<Group justify={"space-between"} gap={"md"}>
<Stack gap={0}>
<Group>
<Text>{t(`job.${job.job.name}.label` as TranslationKeys)}</Text>
{job.status?.status === "idle" && <Badge variant="default">{t("status.idle")}</Badge>}
{job.status?.status === "running" && <Badge color="green">{t("status.running")}</Badge>}
{job.status?.lastExecutionStatus === "error" && <Badge color="red">{t("status.error")}</Badge>}
</Group>
{job.status && <TimeAgo timestamp={job.status.lastExecutionTimestamp} />}
</Stack>
{!job.job.preventManualExecution && ( const { openModal } = useModalAction(TaskConfigurationModal);
<ActionIcon const [isEnabled, setEnabled] = useState(job.isEnabled);
onClick={() => handleJobTrigger(job)} const disableMutation = clientApi.cronJobs.disableJob.useMutation();
disabled={job.status?.status === "running"} const enableMutation = clientApi.cronJobs.enableJob.useMutation();
variant={"default"}
size={"xl"} const [activeStatePending, startActiveTransition] = useTransition();
radius={"xl"} const handleActiveChange = () =>
> startActiveTransition(async () => {
<IconPlayerPlay stroke={1.5} /> if (isEnabled) {
</ActionIcon> await disableMutation.mutateAsync(job.name, {
onSuccess() {
setEnabled(false);
},
});
} else {
await enableMutation.mutateAsync(job.name, {
onSuccess() {
setEnabled(true);
},
});
}
});
return (
<Card key={job.name} withBorder>
<Group justify={"space-between"} gap={"md"}>
<Stack gap={0}>
<Group>
<Text>{tTasks(`job.${job.name}.label`)}</Text>
<StatusBadge isEnabled={isEnabled} status={status} />
{status?.lastExecutionStatus === "error" && <Badge color="red">{tTasks("status.error")}</Badge>}
</Group>
<Group gap="xs">
{status && (
<>
<TimeAgo timestamp={status.lastExecutionTimestamp} />
<Text size="sm" c="dimmed">
</Text>
<Text size="sm" c="dimmed">
{cronExpressions.find((expression) => expression.value === job.cron)?.label(t) ?? job.cron}
</Text>
</>
)} )}
</Group> </Group>
</Card> </Stack>
))}
</Stack> <Group>
{!job.preventManualExecution && (
<ActionIcon
onClick={() => handleJobTrigger(job.name)}
disabled={status?.status === "running"}
loading={triggerMutation.isPending}
variant="default"
size="xl"
radius="xl"
>
<IconPlayerPlay color={getMantineColor("green", 6)} stroke={1.5} />
</ActionIcon>
)}
<ActionIcon onClick={handleActiveChange} loading={activeStatePending} variant="default" size="xl" radius="xl">
{isEnabled ? (
<IconPower color={getMantineColor("green", 6)} stroke={1.5} />
) : (
<IconPowerOff color={getMantineColor("gray", 6)} stroke={1.5} />
)}
</ActionIcon>
<ActionIcon
onClick={() =>
openModal(
{ job },
{
title: tTasks("settings.title", {
jobName: tTasks(`job.${job.name}.label`),
}),
},
)
}
variant={"default"}
size={"xl"}
radius={"xl"}
>
<IconSettings stroke={1.5} />
</ActionIcon>
</Group>
</Group>
</Card>
); );
}; };
interface StatusBadgeProps {
isEnabled: boolean;
status: TaskStatus | null;
}
const StatusBadge = ({ isEnabled, status }: StatusBadgeProps) => {
const t = useScopedI18n("management.page.tool.tasks");
if (!isEnabled) return <Badge color="yellow">{t("status.disabled")}</Badge>;
if (!status) return null;
if (status.status === "running") return <Badge color="green">{t("status.running")}</Badge>;
return <Badge variant="default">{t("status.idle")}</Badge>;
};
const TimeAgo = ({ timestamp }: { timestamp: string }) => { const TimeAgo = ({ timestamp }: { timestamp: string }) => {
const timeAgo = useTimeAgo(new Date(timestamp)); const timeAgo = useTimeAgo(new Date(timestamp));
@@ -93,3 +220,65 @@ const TimeAgo = ({ timestamp }: { timestamp: string }) => {
</Text> </Text>
); );
}; };
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 (
<form
onSubmit={form.onSubmit(async (values) => {
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();
},
},
);
})}
>
<Stack gap="sm">
<Select
label={t("management.page.tool.tasks.field.interval.label")}
{...form.getInputProps("cron")}
data={cronExpressions.map(({ value, label }) => ({ value, label: label(t) }))}
/>
<Group justify="end">
<Button variant="subtle" color="gray" disabled={isPending} onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: "",
});

9
apps/nextjs/src/env.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createEnv } from "@homarr/env";
import { createBooleanSchema } from "@homarr/env/schemas";
export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
},
experimental__runtimeEnv: process.env,
});

View File

@@ -22,7 +22,7 @@
"dependencies": { "dependencies": {
"@homarr/analytics": "workspace:^0.1.0", "@homarr/analytics": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-runner": "workspace:^0.1.0", "@homarr/cron-job-api": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0", "@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/cron-jobs-core": "workspace:^0.1.0", "@homarr/cron-jobs-core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
@@ -36,7 +36,8 @@
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.6.0", "dotenv": "^17.0.1",
"fastify": "^5.4.0",
"superjson": "2.2.2", "superjson": "2.2.2",
"undici": "7.11.0" "undici": "7.11.0"
}, },
@@ -44,10 +45,10 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.15.33", "@types/node": "^22.16.0",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"esbuild": "^0.25.5", "esbuild": "^0.25.5",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tsx": "4.20.3", "tsx": "4.20.3",
"typescript": "^5.8.3" "typescript": "^5.8.3"

View File

@@ -0,0 +1,109 @@
import { schedule, validate as validateCron } from "node-cron";
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";
import { logger } from "@homarr/log";
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="${name}" expression="${cron}"`);
const job = this.jobGroup.getJobRegistry().get(name);
if (!job) throw new Error(`Job ${name} not found`);
if (job.cronExpression === "never") throw new Error(`Job ${name} cannot be updated as it is set to "never"`);
if (!validateCron(cron)) {
throw new Error(`Invalid cron expression: ${cron}`);
}
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="${name}" expression="${cron}"`);
}
public async disableAsync(name: JobGroupKeys): Promise<void> {
logger.info(`Disabling cron job name="${name}"`);
const job = this.jobGroup.getJobRegistry().get(name);
if (!job) throw new Error(`Job ${name} not found`);
if (job.cronExpression === "never") throw new Error(`Job ${name} cannot be disabled as it is set to "never"`);
await this.updateConfigurationAsync(name, { isEnabled: false });
await this.jobGroup.stopAsync(name);
logger.info(`Cron job disabled name="${name}"`);
}
public async enableAsync(name: JobGroupKeys): Promise<void> {
logger.info(`Enabling cron job name="${name}"`);
await this.updateConfigurationAsync(name, { isEnabled: true });
await this.jobGroup.startAsync(name);
logger.info(`Cron job enabled name="${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="${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="${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="${name}" configuration="${JSON.stringify(configuration)}"`);
}
public async getAllAsync(): Promise<
{ name: JobGroupKeys; cron: string; preventManualExecution: 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,
isEnabled: config?.isEnabled ?? true,
};
});
}
}

View File

@@ -1,10 +1,45 @@
// This import has to be the first import in the file so that the agent is overridden before any other modules are imported. // This import has to be the first import in the file so that the agent is overridden before any other modules are imported.
import "./undici-log-agent-override"; import "./undici-log-agent-override";
import { registerCronJobRunner } from "@homarr/cron-job-runner/register"; import type { FastifyTRPCPluginOptions } from "@trpc/server/adapters/fastify";
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
import fastify from "fastify";
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 { jobGroup } from "@homarr/cron-jobs";
import { db } from "@homarr/db";
import { logger } from "@homarr/log";
import { JobManager } from "./job-manager";
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 Error(`Error in tasks tRPC handler path="${path}"`, { cause: error }));
},
} satisfies FastifyTRPCPluginOptions<JobRouter>["trpcOptions"],
});
void (async () => { void (async () => {
registerCronJobRunner(); await jobGroup.initializeAsync();
await jobGroup.startAllAsync(); 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 Error(`Failed to start tasks web server port="${CRON_JOB_API_PORT}"`, { cause: err }));
process.exit(1);
}
})(); })();

View File

@@ -25,9 +25,9 @@
"@homarr/log": "workspace:^", "@homarr/log": "workspace:^",
"@homarr/redis": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"dotenv": "^16.6.0", "dotenv": "^17.0.1",
"tsx": "4.20.3", "tsx": "4.20.3",
"ws": "^8.18.2" "ws": "^8.18.3"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
@@ -35,7 +35,7 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"esbuild": "^0.25.5", "esbuild": "^0.25.5",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }

View File

@@ -46,7 +46,7 @@
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"semantic-release": "^24.2.5", "semantic-release": "^24.2.6",
"testcontainers": "^11.0.3", "testcontainers": "^11.0.3",
"turbo": "^2.5.4", "turbo": "^2.5.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",

View File

@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -24,7 +24,7 @@
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0", "@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-runner": "workspace:^0.1.0", "@homarr/cron-job-api": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0", "@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0", "@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
@@ -41,24 +41,24 @@
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "^1.3.0", "@kubernetes/client-node": "^1.3.0",
"@tanstack/react-query": "^5.81.4", "@tanstack/react-query": "^5.81.5",
"@trpc/client": "^11.4.3", "@trpc/client": "^11.4.3",
"@trpc/react-query": "^11.4.3", "@trpc/react-query": "^11.4.3",
"@trpc/server": "^11.4.3", "@trpc/server": "^11.4.3",
"@trpc/tanstack-react-query": "^11.4.3", "@trpc/tanstack-react-query": "^11.4.3",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"next": "15.3.4", "next": "15.3.5",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"superjson": "2.2.2", "superjson": "2.2.2",
"trpc-to-openapi": "^2.3.1", "trpc-to-openapi": "^2.3.2",
"zod": "^3.25.67" "zod": "^3.25.74"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }

View File

@@ -1,7 +1,8 @@
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import z from "zod/v4";
import { objectEntries } from "@homarr/common"; import { cronExpressionSchema, jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api";
import { cronJobNames, cronJobs, jobNameSchema, triggerCronJobAsync } from "@homarr/cron-job-runner"; import { cronJobApi } from "@homarr/cron-job-api/client";
import type { TaskStatus } from "@homarr/cron-job-status"; import type { TaskStatus } from "@homarr/cron-job-status";
import { createCronJobStatusChannel } from "@homarr/cron-job-status"; import { createCronJobStatusChannel } from "@homarr/cron-job-status";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
@@ -13,19 +14,51 @@ export const cronJobsRouter = createTRPCRouter({
.requiresPermission("admin") .requiresPermission("admin")
.input(jobNameSchema) .input(jobNameSchema)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
await triggerCronJobAsync(input); await cronJobApi.trigger.mutate(input);
}), }),
getJobs: permissionRequiredProcedure.requiresPermission("admin").query(() => { startJob: permissionRequiredProcedure
return objectEntries(cronJobs).map(([name, options]) => ({ .requiresPermission("admin")
name, .input(jobNameSchema)
preventManualExecution: options.preventManualExecution, .mutation(async ({ input }) => {
})); await cronJobApi.start.mutate(input);
}),
stopJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.stop.mutate(input);
}),
updateJobInterval: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
name: jobNameSchema,
cron: cronExpressionSchema,
}),
)
.mutation(async ({ input }) => {
await cronJobApi.updateInterval.mutate(input);
}),
disableJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.disable.mutate(input);
}),
enableJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.enable.mutate(input);
}),
getJobs: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
return await cronJobApi.getAll.query();
}), }),
subscribeToStatusUpdates: permissionRequiredProcedure.requiresPermission("admin").subscription(() => { subscribeToStatusUpdates: permissionRequiredProcedure.requiresPermission("admin").subscription(() => {
return observable<TaskStatus>((emit) => { return observable<TaskStatus>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];
for (const name of cronJobNames) { for (const name of jobGroupKeys) {
const channel = createCronJobStatusChannel(name); const channel = createCronJobStatusChannel(name);
const unsubscribe = channel.subscribe((data) => { const unsubscribe = channel.subscribe((data) => {
emit.next(data); emit.next(data);

View File

@@ -1,6 +1,6 @@
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import type { HealthMonitoring } from "@homarr/integrations"; import type { SystemHealthMonitoring } from "@homarr/integrations";
import type { ProxmoxClusterInfo } from "@homarr/integrations/types"; import type { ProxmoxClusterInfo } from "@homarr/integrations/types";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
@@ -9,7 +9,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({ export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure getSystemHealthStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot")) .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "mock"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await Promise.all( return await Promise.all(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
@@ -26,9 +26,9 @@ export const healthMonitoringRouter = createTRPCRouter({
); );
}), }),
subscribeSystemHealthStatus: publicProcedure subscribeSystemHealthStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot")) .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "mock"))
.subscription(({ ctx }) => { .subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => { return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) { for (const integration of ctx.integrations) {
const innerHandler = systemInfoRequestHandler.handler(integration, {}); const innerHandler = systemInfoRequestHandler.handler(integration, {});
@@ -49,14 +49,14 @@ export const healthMonitoringRouter = createTRPCRouter({
}); });
}), }),
getClusterHealthStatus: publicProcedure getClusterHealthStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "proxmox")) .concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {}); const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data; return data;
}), }),
subscribeClusterHealthStatus: publicProcedure subscribeClusterHealthStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "proxmox")) .concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.subscription(({ ctx }) => { .subscription(({ ctx }) => {
return observable<ProxmoxClusterInfo>((emit) => { return observable<ProxmoxClusterInfo>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];

View File

@@ -34,12 +34,12 @@
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cookies": "^0.9.1", "cookies": "^0.9.1",
"ldapts": "8.0.2", "ldapts": "8.0.4",
"next": "15.3.4", "next": "15.3.5",
"next-auth": "5.0.0-beta.29", "next-auth": "5.0.0-beta.29",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"zod": "^3.25.67" "zod": "^3.25.74"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
@@ -47,7 +47,7 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2", "@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.1", "@types/cookies": "0.9.1",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }

View File

@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -28,14 +28,14 @@
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"dotenv": "^16.6.0" "dotenv": "^17.0.1"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"esbuild": "^0.25.5", "esbuild": "^0.25.5",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -30,18 +30,18 @@
"@homarr/env": "workspace:^0.1.0", "@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "15.3.4", "next": "15.3.5",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"undici": "7.11.0", "undici": "7.11.0",
"zod": "^3.25.67", "zod": "^3.25.74",
"zod-validation-error": "^3.5.2" "zod-validation-error": "^3.5.2"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -1,10 +1,10 @@
import type { CookieSerializeOptions } from "cookie"; import type { SerializeOptions } from "cookie";
import { parse, serialize } from "cookie"; import { parse, serialize } from "cookie";
export function parseCookies(cookieString: string) { export function parseCookies(cookieString: string) {
return parse(cookieString); return parse(cookieString);
} }
export function setClientCookie(name: string, value: string, options: CookieSerializeOptions = {}) { export function setClientCookie(name: string, value: string, options: SerializeOptions = {}) {
document.cookie = serialize(name, value, options); document.cookie = serialize(name, value, options);
} }

View File

@@ -1,12 +1,14 @@
{ {
"name": "@homarr/cron-job-runner", "name": "@homarr/cron-job-api",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./index.ts", ".": "./src/index.ts",
"./register": "./src/register.ts" "./env": "./src/env.ts",
"./constants": "./src/constants.ts",
"./client": "./src/client.ts"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {
@@ -25,15 +27,23 @@
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0", "@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0", "@tanstack/react-query": "^5.81.5",
"@homarr/validation": "workspace:^0.1.0" "@trpc/client": "^11.4.3",
"@trpc/server": "^11.4.3",
"@trpc/tanstack-react-query": "^11.4.3",
"node-cron": "^4.2.0",
"react": "19.1.0",
"zod": "^3.25.74"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.29.0", "@types/node-cron": "^3.0.11",
"@types/react": "19.1.8",
"eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -0,0 +1,20 @@
import { createTRPCClient, httpLink } from "@trpc/client";
import type { JobRouter } from ".";
import { CRON_JOB_API_KEY_HEADER, CRON_JOB_API_PATH, CRON_JOB_API_PORT } from "./constants";
import { env } from "./env";
export const cronJobApi = createTRPCClient<JobRouter>({
links: [
httpLink({
url: `${getBaseUrl()}${CRON_JOB_API_PATH}`,
headers: {
[CRON_JOB_API_KEY_HEADER]: env.CRON_JOB_API_KEY,
},
}),
],
});
function getBaseUrl() {
return `http://localhost:${CRON_JOB_API_PORT}`;
}

View File

@@ -0,0 +1,3 @@
export const CRON_JOB_API_PORT = 3002;
export const CRON_JOB_API_PATH = "/trpc";
export const CRON_JOB_API_KEY_HEADER = "cron-job-api-key";

View File

@@ -0,0 +1,11 @@
import { z } from "zod/v4";
import { env as commonEnv } from "@homarr/common/env";
import { createEnv } from "@homarr/env";
export const env = createEnv({
server: {
CRON_JOB_API_KEY: commonEnv.NODE_ENV === "development" ? z.string().default("test") : z.string(),
},
experimental__runtimeEnv: process.env,
});

View File

@@ -0,0 +1,82 @@
import { initTRPC, TRPCError } from "@trpc/server";
import { validate } from "node-cron";
import { z } from "zod/v4";
import type { JobGroupKeys } from "@homarr/cron-jobs";
import { jobGroup } from "@homarr/cron-jobs";
import { env } from "./env";
export const jobGroupKeys = jobGroup.getKeys();
export const jobNameSchema = z.enum(jobGroup.getKeys());
export interface IJobManager {
startAsync(name: JobGroupKeys): Promise<void>;
triggerAsync(name: JobGroupKeys): Promise<void>;
stopAsync(name: JobGroupKeys): Promise<void>;
updateIntervalAsync(name: JobGroupKeys, cron: string): Promise<void>;
disableAsync(name: JobGroupKeys): Promise<void>;
enableAsync(name: JobGroupKeys): Promise<void>;
getAllAsync(): Promise<{ name: JobGroupKeys; cron: string; preventManualExecution: boolean; isEnabled: boolean }[]>;
}
const t = initTRPC
.context<{
manager: IJobManager;
apiKey?: string;
}>()
.create();
const createTrpcRouter = t.router;
const apiKeyProcedure = t.procedure.use(({ ctx, next }) => {
if (ctx.apiKey !== env.CRON_JOB_API_KEY) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Missing or invalid API key",
});
}
return next({
ctx: {
...ctx,
apiKey: undefined, // Clear the API key after checking
},
});
});
export const cronExpressionSchema = z.string().refine((expression) => validate(expression), {
error: "Invalid cron expression",
});
export const jobRouter = createTrpcRouter({
start: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.startAsync(input);
}),
trigger: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.triggerAsync(input);
}),
stop: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.stopAsync(input);
}),
updateInterval: apiKeyProcedure
.input(
z.object({
name: jobNameSchema,
cron: cronExpressionSchema,
}),
)
.mutation(async ({ input, ctx }) => {
await ctx.manager.updateIntervalAsync(input.name, input.cron);
}),
disable: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.disableAsync(input);
}),
enable: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.enableAsync(input);
}),
getAll: apiKeyProcedure.query(({ ctx }) => {
return ctx.manager.getAllAsync();
}),
});
export type JobRouter = typeof jobRouter;

View File

@@ -1 +0,0 @@
export * from "./src";

View File

@@ -1,45 +0,0 @@
import { objectKeys } from "@homarr/common";
import type { JobGroupKeys } from "@homarr/cron-jobs";
import { createSubPubChannel } from "@homarr/redis";
import { zodEnumFromArray } from "@homarr/validation/enums";
export const cronJobRunnerChannel = createSubPubChannel<JobGroupKeys>("cron-job-runner", { persist: false });
export const cronJobs = {
analytics: { preventManualExecution: true },
iconsUpdater: { preventManualExecution: false },
ping: { preventManualExecution: false },
smartHomeEntityState: { preventManualExecution: false },
mediaServer: { preventManualExecution: false },
mediaOrganizer: { preventManualExecution: false },
downloads: { preventManualExecution: false },
dnsHole: { preventManualExecution: false },
mediaRequestStats: { preventManualExecution: false },
mediaRequestList: { preventManualExecution: false },
rssFeeds: { preventManualExecution: false },
indexerManager: { preventManualExecution: false },
healthMonitoring: { preventManualExecution: false },
sessionCleanup: { preventManualExecution: false },
updateChecker: { preventManualExecution: false },
mediaTranscoding: { preventManualExecution: false },
minecraftServerStatus: { preventManualExecution: false },
networkController: { preventManualExecution: false },
dockerContainers: { preventManualExecution: false },
refreshNotifications: { preventManualExecution: false },
} satisfies Record<JobGroupKeys, { preventManualExecution?: boolean }>;
/**
* Triggers a cron job to run immediately.
* This works over the Redis PubSub channel.
* @param jobName name of the job to be triggered
*/
export const triggerCronJobAsync = async (jobName: JobGroupKeys) => {
if (cronJobs[jobName].preventManualExecution) {
throw new Error(`The job "${jobName}" can not be executed manually`);
}
await cronJobRunnerChannel.publishAsync(jobName);
};
export const cronJobNames = objectKeys(cronJobs);
export const jobNameSchema = zodEnumFromArray(cronJobNames);

View File

@@ -1,12 +0,0 @@
import { jobGroup } from "@homarr/cron-jobs";
import { cronJobRunnerChannel } from ".";
/**
* Registers the cron job runner to listen to the Redis PubSub channel.
*/
export const registerCronJobRunner = () => {
cronJobRunnerChannel.subscribe((jobName) => {
void jobGroup.runManuallyAsync(jobName);
});
};

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -25,14 +25,15 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"node-cron": "^4.1.1" "@homarr/db": "workspace:^0.1.0",
"node-cron": "^4.2.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -1,9 +1,9 @@
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import type { ScheduledTask } from "node-cron"; import { createTask, validate } from "node-cron";
import { schedule, validate } from "node-cron";
import { Stopwatch } from "@homarr/common"; import { Stopwatch } from "@homarr/common";
import type { MaybePromise } from "@homarr/common/types"; import type { MaybePromise } from "@homarr/common/types";
import { db } from "@homarr/db";
import type { Logger } from "./logger"; import type { Logger } from "./logger";
import type { ValidateCron } from "./validation"; import type { ValidateCron } from "./validation";
@@ -18,13 +18,14 @@ export interface CreateCronJobCreatorOptions<TAllowedNames extends string> {
interface CreateCronJobOptions { interface CreateCronJobOptions {
runOnStart?: boolean; runOnStart?: boolean;
preventManualExecution?: boolean;
expectedMaximumDurationInMillis?: number; expectedMaximumDurationInMillis?: number;
beforeStart?: () => MaybePromise<void>; beforeStart?: () => MaybePromise<void>;
} }
const createCallback = <TAllowedNames extends string, TName extends TAllowedNames>( const createCallback = <TAllowedNames extends string, TName extends TAllowedNames>(
name: TName, name: TName,
cronExpression: string, defaultCronExpression: string,
options: CreateCronJobOptions, options: CreateCronJobOptions,
creatorOptions: CreateCronJobCreatorOptions<TAllowedNames>, creatorOptions: CreateCronJobCreatorOptions<TAllowedNames>,
) => { ) => {
@@ -63,25 +64,30 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
} }
}; };
/**
* We are not using the runOnInit method as we want to run the job only once we start the cron job schedule manually.
* This allows us to always run it once we start it. Additionally, it will not run the callback if only the cron job file is imported.
*/
let scheduledTask: ScheduledTask | null = null;
if (cronExpression !== "never") {
scheduledTask = schedule(cronExpression, () => void catchingCallbackAsync(), {
name,
timezone: creatorOptions.timezone,
});
creatorOptions.logger.logDebug(
`The cron job '${name}' was created with expression ${cronExpression} in timezone ${creatorOptions.timezone} and runOnStart ${options.runOnStart}`,
);
}
return { return {
name, name,
cronExpression, cronExpression: defaultCronExpression,
scheduledTask, async createTaskAsync() {
const configuration = await db.query.cronJobConfigurations.findFirst({
where: (cronJobConfigurations, { eq }) => eq(cronJobConfigurations.name, name),
});
if (defaultCronExpression === "never") return null;
const scheduledTask = createTask(
configuration?.cronExpression ?? defaultCronExpression,
() => void catchingCallbackAsync(),
{
name,
timezone: creatorOptions.timezone,
},
);
creatorOptions.logger.logDebug(
`The cron job '${name}' was created with expression ${defaultCronExpression} in timezone ${creatorOptions.timezone} and runOnStart ${options.runOnStart}`,
);
return scheduledTask;
},
async onStartAsync() { async onStartAsync() {
if (options.beforeStart) { if (options.beforeStart) {
creatorOptions.logger.logDebug(`Running beforeStart for job: ${name}`); creatorOptions.logger.logDebug(`Running beforeStart for job: ${name}`);
@@ -93,6 +99,10 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
creatorOptions.logger.logDebug(`The cron job '${name}' is running because runOnStart is set to true`); creatorOptions.logger.logDebug(`The cron job '${name}' is running because runOnStart is set to true`);
await catchingCallbackAsync(); await catchingCallbackAsync();
}, },
async executeAsync() {
await catchingCallbackAsync();
},
preventManualExecution: options.preventManualExecution ?? false,
}; };
}; };
}; };
@@ -106,17 +116,17 @@ export const createCronJobCreator = <TAllowedNames extends string = string>(
) => { ) => {
return <TName extends TAllowedNames, TExpression extends string>( return <TName extends TAllowedNames, TExpression extends string>(
name: TName, name: TName,
cronExpression: TExpression, defaultCronExpression: TExpression,
options: CreateCronJobOptions = { runOnStart: false }, options: CreateCronJobOptions = { runOnStart: false },
) => { ) => {
creatorOptions.logger.logDebug(`Validating cron expression '${cronExpression}' for job: ${name}`); creatorOptions.logger.logDebug(`Validating cron expression '${defaultCronExpression}' for job: ${name}`);
if (cronExpression !== "never" && !validate(cronExpression)) { if (defaultCronExpression !== "never" && !validate(defaultCronExpression)) {
throw new Error(`Invalid cron expression '${cronExpression}' for job '${name}'`); throw new Error(`Invalid cron expression '${defaultCronExpression}' for job '${name}'`);
} }
creatorOptions.logger.logDebug(`Cron job expression '${cronExpression}' for job ${name} is valid`); creatorOptions.logger.logDebug(`Cron job expression '${defaultCronExpression}' for job ${name} is valid`);
const returnValue = { const returnValue = {
withCallback: createCallback<TAllowedNames, TName>(name, cronExpression, options, creatorOptions), withCallback: createCallback<TAllowedNames, TName>(name, defaultCronExpression, options, creatorOptions),
}; };
// This is a type guard to check if the cron expression is valid and give the user a type hint // This is a type guard to check if the cron expression is valid and give the user a type hint

View File

@@ -1,4 +1,7 @@
import type { ScheduledTask } from "node-cron";
import { objectEntries, objectKeys } from "@homarr/common"; import { objectEntries, objectKeys } from "@homarr/common";
import { db } from "@homarr/db";
import type { JobCallback } from "./creator"; import type { JobCallback } from "./creator";
import type { Logger } from "./logger"; import type { Logger } from "./logger";
@@ -27,45 +30,78 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
}); });
} }
const tasks = new Map<string, ScheduledTask>();
return { return {
initializeAsync: async () => {
const configurations = await db.query.cronJobConfigurations.findMany();
for (const job of jobRegistry.values()) {
const configuration = configurations.find(({ name }) => name === job.name);
if (configuration?.isEnabled === false) {
continue;
}
if (tasks.has(job.name)) {
continue;
}
const scheduledTask = await job.createTaskAsync();
if (!scheduledTask) continue;
tasks.set(job.name, scheduledTask);
}
},
startAsync: async (name: keyof TJobs) => { startAsync: async (name: keyof TJobs) => {
const job = jobRegistry.get(name as string); const job = jobRegistry.get(name as string);
if (!job) return; if (!job) return;
if (!tasks.has(job.name)) return;
options.logger.logInfo(`Starting schedule cron job ${job.name}.`); options.logger.logInfo(`Starting schedule cron job ${job.name}.`);
await job.onStartAsync(); await job.onStartAsync();
await job.scheduledTask?.start(); await tasks.get(name as string)?.start();
}, },
startAllAsync: async () => { startAllAsync: async () => {
for (const job of jobRegistry.values()) { for (const job of jobRegistry.values()) {
if (!tasks.has(job.name)) {
continue;
}
options.logger.logInfo(`Starting schedule of cron job ${job.name}.`); options.logger.logInfo(`Starting schedule of cron job ${job.name}.`);
await job.onStartAsync(); await job.onStartAsync();
await job.scheduledTask?.start(); await tasks.get(job.name)?.start();
} }
}, },
runManuallyAsync: async (name: keyof TJobs) => { runManuallyAsync: async (name: keyof TJobs) => {
const job = jobRegistry.get(name as string); const job = jobRegistry.get(name as string);
if (!job) return; if (!job) return;
if (job.preventManualExecution) {
throw new Error(`The job "${job.name}" can not be executed manually.`);
}
options.logger.logInfo(`Running schedule cron job ${job.name} manually.`); options.logger.logInfo(`Running schedule cron job ${job.name} manually.`);
await job.scheduledTask?.execute(); await tasks.get(name as string)?.execute();
}, },
stopAsync: async (name: keyof TJobs) => { stopAsync: async (name: keyof TJobs) => {
const job = jobRegistry.get(name as string); const job = jobRegistry.get(name as string);
if (!job) return; if (!job) return;
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`); options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
await job.scheduledTask?.stop(); await tasks.get(name as string)?.stop();
}, },
stopAllAsync: async () => { stopAllAsync: async () => {
for (const job of jobRegistry.values()) { for (const job of jobRegistry.values()) {
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`); options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
await job.scheduledTask?.stop(); await tasks.get(job.name)?.stop();
} }
}, },
getJobRegistry() { getJobRegistry() {
return jobRegistry as Map<TAllowedNames, ReturnType<JobCallback<TAllowedNames, TAllowedNames>>>; return jobRegistry as Map<TAllowedNames, ReturnType<JobCallback<TAllowedNames, TAllowedNames>>>;
}, },
getTask(name: keyof TJobs) {
return tasks.get(name as string) ?? null;
},
setTask(name: keyof TJobs, task: ScheduledTask) {
tasks.set(name as string, task);
},
getKeys() { getKeys() {
return objectKeys(jobs); return objectKeys(jobs);
}, },

View File

@@ -44,7 +44,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -7,6 +7,7 @@ import { createCronJob } from "../lib";
export const analyticsJob = createCronJob("analytics", EVERY_WEEK, { export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
runOnStart: true, runOnStart: true,
preventManualExecution: true,
}).withCallback(async () => { }).withCallback(async () => {
const analyticSetting = await getServerSettingByKeyAsync(db, "analytics"); const analyticSetting = await getServerSettingByKeyAsync(db, "analytics");

View File

@@ -38,6 +38,7 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
const newIconRepositories: InferInsertModel<typeof iconRepositories>[] = []; const newIconRepositories: InferInsertModel<typeof iconRepositories>[] = [];
const newIcons: InferInsertModel<typeof icons>[] = []; const newIcons: InferInsertModel<typeof icons>[] = [];
const allDbIcons = databaseIconRepositories.flatMap((group) => group.icons);
for (const repositoryIconGroup of repositoryIconGroups) { for (const repositoryIconGroup of repositoryIconGroups) {
if (!repositoryIconGroup.success) { if (!repositoryIconGroup.success) {
@@ -55,12 +56,10 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
}); });
} }
const dbIconsInRepository = allDbIcons.filter((icon) => icon.iconRepositoryId === iconRepositoryId);
for (const icon of repositoryIconGroup.icons) { for (const icon of repositoryIconGroup.icons) {
if ( if (dbIconsInRepository.some((dbIcon) => dbIcon.checksum === icon.checksum)) {
databaseIconRepositories
.flatMap((repository) => repository.icons)
.some((dbIcon) => dbIcon.checksum === icon.checksum && dbIcon.iconRepositoryId === iconRepositoryId)
) {
skippedChecksums.push(`${iconRepositoryId}.${icon.checksum}`); skippedChecksums.push(`${iconRepositoryId}.${icon.checksum}`);
continue; continue;
} }
@@ -76,9 +75,9 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
} }
} }
const deadIcons = databaseIconRepositories const deadIcons = allDbIcons.filter(
.flatMap((repository) => repository.icons) (icon) => !skippedChecksums.includes(`${icon.iconRepositoryId}.${icon.checksum}`),
.filter((icon) => !skippedChecksums.includes(`${icon.iconRepositoryId}.${icon.checksum}`)); );
const deadIconRepositories = databaseIconRepositories.filter( const deadIconRepositories = databaseIconRepositories.filter(
(iconRepository) => !repositoryIconGroups.some((group) => group.slug === iconRepository.slug), (iconRepository) => !repositoryIconGroups.some((group) => group.slug === iconRepository.slug),

View File

@@ -8,7 +8,8 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC
createRequestIntegrationJobHandler( createRequestIntegrationJobHandler(
(integration, itemOptions: Record<string, never>) => { (integration, itemOptions: Record<string, never>) => {
const { kind } = integration; const { kind } = integration;
if (kind !== "proxmox") {
if (kind !== "proxmox" && kind !== "mock") {
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions); return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
} }
return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions); return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions);

View File

@@ -0,0 +1,6 @@
CREATE TABLE `cron_job_configuration` (
`name` varchar(256) NOT NULL,
`cron_expression` varchar(32) NOT NULL,
`is_enabled` boolean NOT NULL DEFAULT true,
CONSTRAINT `cron_job_configuration_name` PRIMARY KEY(`name`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -232,6 +232,13 @@
"when": 1746821770071, "when": 1746821770071,
"tag": "0032_add_trusted_certificate_hostnames", "tag": "0032_add_trusted_certificate_hostnames",
"breakpoints": true "breakpoints": true
},
{
"idx": 33,
"version": "5",
"when": 1750013953833,
"tag": "0033_add_cron_job_configuration",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1,5 @@
CREATE TABLE `cron_job_configuration` (
`name` text PRIMARY KEY NOT NULL,
`cron_expression` text NOT NULL,
`is_enabled` integer DEFAULT true NOT NULL
);

File diff suppressed because it is too large Load Diff

View File

@@ -232,6 +232,13 @@
"when": 1746821779051, "when": 1746821779051,
"tag": "0032_add_trusted_certificate_hostnames", "tag": "0032_add_trusted_certificate_hostnames",
"breakpoints": true "breakpoints": true
},
{
"idx": 33,
"version": "6",
"when": 1750014001941,
"tag": "0033_add_cron_job_configuration",
"breakpoints": true
} }
] ]
} }

View File

@@ -47,8 +47,8 @@
"@mantine/core": "^8.1.2", "@mantine/core": "^8.1.2",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^11.0.3", "@testcontainers/mysql": "^11.0.3",
"better-sqlite3": "^12.1.1", "better-sqlite3": "^12.2.0",
"dotenv": "^16.6.0", "dotenv": "^17.0.1",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.2", "drizzle-orm": "^0.44.2",
"drizzle-zod": "^0.7.1", "drizzle-zod": "^0.7.1",
@@ -61,7 +61,7 @@
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"esbuild": "^0.25.5", "esbuild": "^0.25.5",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tsx": "4.20.3", "tsx": "4.20.3",
"typescript": "^5.8.3" "typescript": "^5.8.3"

View File

@@ -40,6 +40,7 @@ export const {
itemLayouts, itemLayouts,
sectionLayouts, sectionLayouts,
trustedCertificateHostnames, trustedCertificateHostnames,
cronJobConfigurations,
} = schema; } = schema;
export type User = InferSelectModel<typeof schema.users>; export type User = InferSelectModel<typeof schema.users>;

View File

@@ -508,6 +508,12 @@ export const trustedCertificateHostnames = mysqlTable(
}), }),
); );
export const cronJobConfigurations = mysqlTable("cron_job_configuration", {
name: varchar({ length: 256 }).notNull().primaryKey(),
cronExpression: varchar({ length: 32 }).notNull(),
isEnabled: boolean().default(true).notNull(),
});
export const accountRelations = relations(accounts, ({ one }) => ({ export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, { user: one(users, {
fields: [accounts.userId], fields: [accounts.userId],

View File

@@ -493,6 +493,12 @@ export const trustedCertificateHostnames = sqliteTable(
}), }),
); );
export const cronJobConfigurations = sqliteTable("cron_job_configuration", {
name: text().notNull().primaryKey(),
cronExpression: text().notNull(),
isEnabled: int({ mode: "boolean" }).default(true).notNull(),
});
export const accountRelations = relations(accounts, ({ one }) => ({ export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, { user: one(users, {
fields: [accounts.userId], fields: [accounts.userId],

View File

@@ -25,13 +25,13 @@
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"fast-xml-parser": "^5.2.5", "fast-xml-parser": "^5.2.5",
"zod": "^3.25.67" "zod": "^3.25.74"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"tsx": "4.20.3", "tsx": "4.20.3",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }

View File

@@ -176,6 +176,25 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg",
category: ["notifications"], category: ["notifications"],
}, },
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/vitest.svg",
category: [
"calendar",
"dnsHole",
"downloadClient",
"healthMonitoring",
"indexerManager",
"mediaRequest",
"mediaService",
"mediaTranscoding",
"networkController",
"notifications",
"smartHomeServer",
],
},
} as const satisfies Record<string, integrationDefinition>; } as const satisfies Record<string, integrationDefinition>;
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>; export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;

View File

@@ -32,8 +32,8 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.41", "@types/dockerode": "^3.3.42",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -24,13 +24,13 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8",
"zod": "^3.25.67" "zod": "^3.25.74"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -27,13 +27,13 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^8.1.2", "@mantine/form": "^8.1.2",
"zod": "^3.25.67" "zod": "^3.25.74"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -31,13 +31,13 @@
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.1.2", "@mantine/core": "^8.1.2",
"react": "19.1.0", "react": "19.1.0",
"zod": "^3.25.67" "zod": "^3.25.74"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -31,7 +31,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -44,7 +44,7 @@
"tsdav": "^2.1.5", "tsdav": "^2.1.5",
"undici": "7.11.0", "undici": "7.11.0",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"zod": "^3.25.67" "zod": "^3.25.74"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
@@ -52,7 +52,7 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-unifi": "^2.5.1", "@types/node-unifi": "^2.5.1",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
"eslint": "^9.29.0", "eslint": "^9.30.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }

View File

@@ -20,6 +20,7 @@ import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration"; import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { TdarrIntegration } from "../media-transcoding/tdarr-integration"; import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { MockIntegration } from "../mock/mock-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration"; import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration"; import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
@@ -94,6 +95,7 @@ export const integrationCreators = {
nextcloud: NextcloudIntegration, nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration, unifiController: UnifiControllerIntegration,
ntfy: NTFYIntegration, ntfy: NTFYIntegration,
mock: MockIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>; } satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = { type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {

View File

@@ -12,9 +12,10 @@ import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration"; import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error"; import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { HealthMonitoring } from "../types"; import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
export class DashDotIntegration extends Integration { export class DashDotIntegration extends Integration implements ISystemHealthMonitoringIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> { protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/info")); const response = await input.fetchAsync(this.url("/info"));
if (!response.ok) return TestConnectionError.StatusResult(response); if (!response.ok) return TestConnectionError.StatusResult(response);
@@ -26,7 +27,7 @@ export class DashDotIntegration extends Integration {
}; };
} }
public async getSystemInfoAsync(): Promise<HealthMonitoring> { public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const info = await this.getInfoAsync(); const info = await this.getInfoAsync();
const cpuLoad = await this.getCurrentCpuLoadAsync(); const cpuLoad = await this.getCurrentCpuLoadAsync();
const memoryLoad = await this.getCurrentMemoryLoadAsync(); const memoryLoad = await this.getCurrentMemoryLoadAsync();

View File

@@ -4,14 +4,15 @@ import type { fetch as undiciFetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server"; import { ResponseError } from "@homarr/common/server";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { Aria2Download, Aria2GetClient } from "./aria2-types"; import type { Aria2Download, Aria2GetClient } from "./aria2-types";
export class Aria2Integration extends DownloadClientIntegration { export class Aria2Integration extends Integration implements IDownloadClientIntegration {
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const client = this.getClient(); const client = this.getClient();
const keys: (keyof Aria2Download)[] = [ const keys: (keyof Aria2Download)[] = [

View File

@@ -6,16 +6,17 @@ import { createCertificateAgentAsync } from "@homarr/certificates/server";
import { HandleIntegrationErrors } from "../../base/errors/decorator"; import { HandleIntegrationErrors } from "../../base/errors/decorator";
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http"; import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error"; import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler]) @HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
export class DelugeIntegration extends DownloadClientIntegration { export class DelugeIntegration extends Integration implements IDownloadClientIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> { protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher); const client = await this.getClientAsync(input.dispatcher);
const isSuccess = await client.login(); const isSuccess = await client.login();

View File

@@ -4,15 +4,16 @@ import type { fetch as undiciFetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server"; import { ResponseError } from "@homarr/common/server";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
import type { NzbGetClient } from "./nzbget-types"; import type { NzbGetClient } from "./nzbget-types";
export class NzbGetIntegration extends DownloadClientIntegration { export class NzbGetIntegration extends Integration implements IDownloadClientIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> { protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await this.nzbGetApiCallWithCustomFetchAsync(input.fetchAsync, "version"); await this.nzbGetApiCallWithCustomFetchAsync(input.fetchAsync, "version");
return { return {

View File

@@ -6,16 +6,17 @@ import { createCertificateAgentAsync } from "@homarr/certificates/server";
import { HandleIntegrationErrors } from "../../base/errors/decorator"; import { HandleIntegrationErrors } from "../../base/errors/decorator";
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http"; import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error"; import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler]) @HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
export class QBitTorrentIntegration extends DownloadClientIntegration { export class QBitTorrentIntegration extends Integration implements IDownloadClientIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> { protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher); const client = await this.getClientAsync(input.dispatcher);
const isSuccess = await client.login(); const isSuccess = await client.login();

View File

@@ -5,17 +5,18 @@ import type { fetch as undiciFetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server"; import { ResponseError } from "@homarr/common/server";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
import { historySchema, queueSchema } from "./sabnzbd-schema"; import { historySchema, queueSchema } from "./sabnzbd-schema";
dayjs.extend(duration); dayjs.extend(duration);
export class SabnzbdIntegration extends DownloadClientIntegration { export class SabnzbdIntegration extends Integration implements IDownloadClientIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> { protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
//This is the one call that uses the least amount of data while requiring the api key //This is the one call that uses the least amount of data while requiring the api key
await this.sabNzbApiCallWithCustomFetchAsync(input.fetchAsync, "translate", { value: "ping" }); await this.sabNzbApiCallWithCustomFetchAsync(input.fetchAsync, "translate", { value: "ping" });

View File

@@ -6,15 +6,16 @@ import { createCertificateAgentAsync } from "@homarr/certificates/server";
import { HandleIntegrationErrors } from "../../base/errors/decorator"; import { HandleIntegrationErrors } from "../../base/errors/decorator";
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http"; import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler]) @HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
export class TransmissionIntegration extends DownloadClientIntegration { export class TransmissionIntegration extends Integration implements IDownloadClientIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> { protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher); const client = await this.getClientAsync(input.dispatcher);
await client.getSession(); await client.getSession();

View File

@@ -7,7 +7,8 @@ import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration"; import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error"; import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session"; import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import { convertJellyfinType } from "../jellyfin/jellyfin-integration"; import { convertJellyfinType } from "../jellyfin/jellyfin-integration";
const sessionSchema = z.object({ const sessionSchema = z.object({
@@ -30,7 +31,7 @@ const sessionSchema = z.object({
UserName: z.string().nullish(), UserName: z.string().nullish(),
}); });
export class EmbyIntegration extends Integration { export class EmbyIntegration extends Integration implements IMediaServerIntegration {
private static readonly apiKeyHeader = "X-Emby-Token"; private static readonly apiKeyHeader = "X-Emby-Token";
private static readonly deviceId = "homarr-emby-integration"; private static readonly deviceId = "homarr-emby-integration";
private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`; private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`;

View File

@@ -5,9 +5,10 @@ import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration"; import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error"; import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ISmartHomeIntegration } from "../interfaces/smart-home/smart-home-integration";
import { entityStateSchema } from "./homeassistant-types"; import { entityStateSchema } from "./homeassistant-types";
export class HomeAssistantIntegration extends Integration { export class HomeAssistantIntegration extends Integration implements ISmartHomeIntegration {
public async getEntityStateAsync(entityId: string) { public async getEntityStateAsync(entityId: string) {
try { try {
const response = await this.getAsync(`/api/states/${entityId}`); const response = await this.getAsync(`/api/states/${entityId}`);
@@ -15,6 +16,7 @@ export class HomeAssistantIntegration extends Integration {
if (!response.ok) { if (!response.ok) {
logger.warn(`Response did not indicate success`); logger.warn(`Response did not indicate success`);
return { return {
success: false as const,
error: "Response did not indicate success", error: "Response did not indicate success",
}; };
} }

View File

@@ -7,7 +7,6 @@ export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorren
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration"; export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration"; export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration"; export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration"; export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration"; export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration"; export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
@@ -28,14 +27,17 @@ export type { IntegrationInput } from "./base/integration";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data"; export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items"; export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status"; export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-monitoring"; export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request"; export { MediaRequestStatus } from "./interfaces/media-requests/media-request-types";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request"; export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types";
export type { StreamSession } from "./interfaces/media-server/session"; export type { StreamSession } from "./interfaces/media-server/media-server-types";
export type { TdarrQueue } from "./interfaces/media-transcoding/queue"; export type {
export type { TdarrPieSegment, TdarrStatistics } from "./interfaces/media-transcoding/statistics"; TdarrQueue,
export type { TdarrWorker } from "./interfaces/media-transcoding/workers"; TdarrPieSegment,
export type { Notification } from "./interfaces/notifications/notification"; TdarrStatistics,
TdarrWorker,
} from "./interfaces/media-transcoding/media-transcoding-types";
export type { Notification } from "./interfaces/notifications/notification-types";
// Schemas // Schemas
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items"; export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";

View File

@@ -0,0 +1,5 @@
import type { CalendarEvent } from "./calendar-types";
export interface ICalendarIntegration {
getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored: boolean): Promise<CalendarEvent[]>;
}

View File

@@ -1,18 +1,17 @@
import { Integration } from "../../base/integration";
import type { DownloadClientJobsAndStatus } from "./download-client-data"; import type { DownloadClientJobsAndStatus } from "./download-client-data";
import type { DownloadClientItem } from "./download-client-items"; import type { DownloadClientItem } from "./download-client-items";
export abstract class DownloadClientIntegration extends Integration { export interface IDownloadClientIntegration {
/** Get download client's status and list of all of it's items */ /** Get download client's status and list of all of it's items */
public abstract getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus>; getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus>;
/** Pauses the client or all of it's items */ /** Pauses the client or all of it's items */
public abstract pauseQueueAsync(): Promise<void>; pauseQueueAsync(): Promise<void>;
/** Pause a single item using it's ID */ /** Pause a single item using it's ID */
public abstract pauseItemAsync(item: DownloadClientItem): Promise<void>; pauseItemAsync(item: DownloadClientItem): Promise<void>;
/** Resumes the client or all of it's items */ /** Resumes the client or all of it's items */
public abstract resumeQueueAsync(): Promise<void>; resumeQueueAsync(): Promise<void>;
/** Resume a single item using it's ID */ /** Resume a single item using it's ID */
public abstract resumeItemAsync(item: DownloadClientItem): Promise<void>; resumeItemAsync(item: DownloadClientItem): Promise<void>;
/** Delete an entry on the client or a file from disk */ /** Delete an entry on the client or a file from disk */
public abstract deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise<void>; deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise<void>;
} }

View File

@@ -0,0 +1,9 @@
import type { ClusterHealthMonitoring, SystemHealthMonitoring } from "./health-monitoring-types";
export interface ISystemHealthMonitoringIntegration {
getSystemInfoAsync(): Promise<SystemHealthMonitoring>;
}
export interface IClusterHealthMonitoringIntegration {
getClusterInfoAsync(): Promise<ClusterHealthMonitoring>;
}

View File

@@ -1,4 +1,6 @@
export interface HealthMonitoring { import type { LxcResource, NodeResource, QemuResource, StorageResource } from "../../types";
export interface SystemHealthMonitoring {
version: string; version: string;
cpuModelName: string; cpuModelName: string;
cpuUtilization: number; cpuUtilization: number;
@@ -25,3 +27,11 @@ export interface HealthMonitoring {
overallStatus: string; overallStatus: string;
}[]; }[];
} }
// TODO: in the future decouple this from the Proxmox integration
export interface ClusterHealthMonitoring {
nodes: NodeResource[];
lxcs: LxcResource[];
vms: QemuResource[];
storages: StorageResource[];
}

View File

@@ -0,0 +1,6 @@
import type { Indexer } from "./indexer-manager-types";
export interface IIndexerManagerIntegration {
getIndexersAsync(): Promise<Indexer[]>;
testAllAsync(): Promise<void>;
}

View File

@@ -0,0 +1,11 @@
import type { MediaInformation, MediaRequest, RequestStats, RequestUser } from "./media-request-types";
export interface IMediaRequestIntegration {
getSeriesInformationAsync(mediaType: "movie" | "tv", id: number): Promise<MediaInformation>;
requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise<void>;
getRequestsAsync(): Promise<MediaRequest[]>;
getStatsAsync(): Promise<RequestStats>;
getUsersAsync(): Promise<RequestUser[]>;
approveRequestAsync(requestId: number): Promise<void>;
declineRequestAsync(requestId: number): Promise<void>;
}

View File

@@ -1,3 +1,25 @@
interface SerieSeason {
id: number;
seasonNumber: number;
name: string;
episodeCount: number;
}
interface SeriesInformation {
id: number;
overview: string;
seasons: SerieSeason[];
posterPath: string;
}
interface MovieInformation {
id: number;
overview: string;
posterPath: string;
}
export type MediaInformation = SeriesInformation | MovieInformation;
export interface MediaRequest { export interface MediaRequest {
id: number; id: number;
name: string; name: string;

View File

@@ -0,0 +1,5 @@
import type { CurrentSessionsInput, StreamSession } from "./media-server-types";
export interface IMediaServerIntegration {
getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]>;
}

View File

@@ -0,0 +1,7 @@
import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "./media-transcoding-types";
export interface IMediaTranscodingIntegration {
getStatisticsAsync(): Promise<TdarrStatistics>;
getWorkersAsync(): Promise<TdarrWorker[]>;
getQueueAsync(firstItemIndex: number, pageSize: number): Promise<TdarrQueue>;
}

View File

@@ -1,3 +1,20 @@
export interface TdarrQueue {
array: {
id: string;
healthCheck: string;
transcode: string;
filePath: string;
fileSize: number;
container: string;
codec: string;
resolution: string;
type: "transcode" | "health-check";
}[];
totalCount: number;
startIndex: number;
endIndex: number;
}
export interface TdarrPieSegment { export interface TdarrPieSegment {
name: string; name: string;
value: number; value: number;
@@ -21,3 +38,17 @@ export interface TdarrStatistics {
audioCodecs: TdarrPieSegment[]; audioCodecs: TdarrPieSegment[];
audioContainers: TdarrPieSegment[]; audioContainers: TdarrPieSegment[];
} }
export interface TdarrWorker {
id: string;
filePath: string;
fps: number;
percentage: number;
ETA: string;
jobType: string;
status: string;
step: string;
originalSize: number;
estimatedSize: number | null;
outputSize: number | null;
}

View File

@@ -1,16 +0,0 @@
export interface TdarrQueue {
array: {
id: string;
healthCheck: string;
transcode: string;
filePath: string;
fileSize: number;
container: string;
codec: string;
resolution: string;
type: "transcode" | "health-check";
}[];
totalCount: number;
startIndex: number;
endIndex: number;
}

View File

@@ -1,13 +0,0 @@
export interface TdarrWorker {
id: string;
filePath: string;
fps: number;
percentage: number;
ETA: string;
jobType: string;
status: string;
step: string;
originalSize: number;
estimatedSize: number | null;
outputSize: number | null;
}

View File

@@ -1,6 +1,5 @@
import { Integration } from "../../base/integration"; import type { Notification } from "./notification-types";
import type { Notification } from "./notification";
export abstract class NotificationsIntegration extends Integration { export interface INotificationsIntegration {
public abstract getNotificationsAsync(): Promise<Notification[]>; getNotificationsAsync(): Promise<Notification[]>;
} }

View File

@@ -0,0 +1,7 @@
import type { EntityStateResult } from "./smart-home-types";
export interface ISmartHomeIntegration {
getEntityStateAsync(entityId: string): Promise<EntityStateResult>;
triggerAutomationAsync(entityId: string): Promise<boolean>;
triggerToggleAsync(entityId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,17 @@
interface EntityState {
attributes: Record<string, string | number | boolean | null | (string | number)[]>;
entity_id: string;
last_changed: Date;
last_updated: Date;
state: string;
}
export type EntityStateResult =
| {
success: true;
data: EntityState;
}
| {
success: false;
error: unknown;
};

View File

@@ -11,10 +11,11 @@ import { integrationAxiosHttpErrorHandler } from "../base/errors/http";
import type { IntegrationTestingInput } from "../base/integration"; import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration"; import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session"; import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler]) @HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration { export class JellyfinIntegration extends Integration implements IMediaServerIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({ private readonly jellyfin: Jellyfin = new Jellyfin({
clientInfo: { clientInfo: {
name: "Homarr", name: "Homarr",

View File

@@ -3,13 +3,15 @@ import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error"; import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { CalendarEvent } from "../../calendar-types"; import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import { MediaOrganizerIntegration } from "../media-organizer-integration"; import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer";
export class LidarrIntegration extends MediaOrganizerIntegration { export class LidarrIntegration extends Integration implements ICalendarIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> { protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), { const response = await input.fetchAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") }, headers: { "X-Api-Key": super.getSecretValue("apiKey") },
@@ -103,7 +105,8 @@ export class LidarrIntegration extends MediaOrganizerIntegration {
const flatImages = [...event.images]; const flatImages = [...event.images];
const sortedImages = flatImages.sort( const sortedImages = flatImages.sort(
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType), (imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
); );
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`); logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
return sortedImages[0]; return sortedImages[0];

View File

@@ -1,21 +0,0 @@
import { Integration } from "../base/integration";
export abstract class MediaOrganizerIntegration extends Integration {
/**
* Priority list that determines the quality of images using their order.
* Types at the start of the list are better than those at the end.
* We do this to attempt to find the best quality image for the show.
*/
protected readonly priorities: string[] = [
"cover", // Official, perfect aspect ratio, best for music
"poster", // Official, perfect aspect ratio
"banner", // Official, bad aspect ratio
"disc", // Official, second best for music / books
"logo", // Official, possibly unrelated
"fanart", // Unofficial, possibly bad quality
"screenshot", // Bad aspect ratio, possibly bad quality
"clearlogo", // Without background, bad aspect ratio,
"headshot", // Unrelated
"unknown", // Not known, possibly good or bad, better not to choose
];
}

View File

@@ -0,0 +1,17 @@
/**
* Priority list that determines the quality of images using their order.
* Types at the start of the list are better than those at the end.
* We do this to attempt to find the best quality image for the show.
*/
export const mediaOrganizerPriorities = [
"cover", // Official, perfect aspect ratio, best for music
"poster", // Official, perfect aspect ratio
"banner", // Official, bad aspect ratio
"disc", // Official, second best for music / books
"logo", // Official, possibly unrelated
"fanart", // Unofficial, possibly bad quality
"screenshot", // Bad aspect ratio, possibly bad quality
"clearlogo", // Without background, bad aspect ratio,
"headshot", // Unrelated
"unknown", // Not known, possibly good or bad, better not to choose
];

View File

@@ -4,14 +4,16 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { AtLeastOneOf } from "@homarr/common/types"; import type { AtLeastOneOf } from "@homarr/common/types";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error"; import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { CalendarEvent } from "../../calendar-types"; import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import { radarrReleaseTypes } from "../../calendar-types"; import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
import { MediaOrganizerIntegration } from "../media-organizer-integration"; import { radarrReleaseTypes } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer";
export class RadarrIntegration extends MediaOrganizerIntegration { export class RadarrIntegration extends Integration implements ICalendarIntegration {
/** /**
* Gets the events in the Radarr calendar between two dates. * Gets the events in the Radarr calendar between two dates.
* @param start The start date * @param start The start date
@@ -82,7 +84,8 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
const flatImages = [...event.images]; const flatImages = [...event.images];
const sortedImages = flatImages.sort( const sortedImages = flatImages.sort(
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType), (imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
); );
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`); logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
return sortedImages[0]; return sortedImages[0];

View File

@@ -3,13 +3,15 @@ import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error"; import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { CalendarEvent } from "../../calendar-types"; import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import { MediaOrganizerIntegration } from "../media-organizer-integration"; import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer";
export class ReadarrIntegration extends MediaOrganizerIntegration { export class ReadarrIntegration extends Integration implements ICalendarIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> { protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), { const response = await input.fetchAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") }, headers: { "X-Api-Key": super.getSecretValue("apiKey") },
@@ -81,7 +83,8 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
const flatImages = [...event.images]; const flatImages = [...event.images];
const sortedImages = flatImages.sort( const sortedImages = flatImages.sort(
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType), (imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
); );
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`); logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
return sortedImages[0]; return sortedImages[0];

View File

@@ -3,13 +3,15 @@ import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error"; import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { CalendarEvent } from "../../calendar-types"; import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import { MediaOrganizerIntegration } from "../media-organizer-integration"; import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer";
export class SonarrIntegration extends MediaOrganizerIntegration { export class SonarrIntegration extends Integration implements ICalendarIntegration {
/** /**
* Gets the events in the Sonarr calendar between two dates. * Gets the events in the Sonarr calendar between two dates.
* @param start The start date * @param start The start date
@@ -81,7 +83,8 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
const flatImages = [...event.images, ...event.series.images]; const flatImages = [...event.images, ...event.series.images];
const sortedImages = flatImages.sort( const sortedImages = flatImages.sort(
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType), (imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
); );
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`); logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
return sortedImages[0]; return sortedImages[0];

View File

@@ -4,12 +4,11 @@ import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration"; import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error"; import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { TdarrQueue } from "../interfaces/media-transcoding/queue"; import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
import type { TdarrStatistics } from "../interfaces/media-transcoding/statistics"; import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "../interfaces/media-transcoding/media-transcoding-types";
import type { TdarrWorker } from "../interfaces/media-transcoding/workers";
import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } from "./tdarr-validation-schemas"; import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } from "./tdarr-validation-schemas";
export class TdarrIntegration extends Integration { export class TdarrIntegration extends Integration implements IMediaTranscodingIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> { protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v2/is-server-alive"), { const response = await input.fetchAsync(this.url("/api/v2/is-server-alive"), {
method: "POST", method: "POST",

View File

@@ -0,0 +1,74 @@
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
export class CalendarMockService implements ICalendarIntegration {
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
const result = [homarrMeetup(start, end), titanicRelease(start, end), seriesRelease(start, end)];
return await Promise.resolve(result);
}
}
const homarrMeetup = (start: Date, end: Date): CalendarEvent => ({
name: "Homarr Meetup",
subName: "",
description: "Yearly meetup of the Homarr community",
date: randomDateBetween(start, end),
links: [
{
href: "https://homarr.dev",
name: "Homarr",
logo: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
color: "#000000",
notificationColor: "#fa5252",
isDark: true,
},
],
});
const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
name: "Titanic",
subName: "A classic movie",
description: "A tragic love story set on the ill-fated RMS Titanic.",
date: randomDateBetween(start, end),
thumbnail: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
mediaInformation: {
type: "movie",
},
links: [
{
href: "https://www.imdb.com/title/tt0120338/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
notificationColor: "cyan",
},
],
});
const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
name: "The Mandalorian",
subName: "A Star Wars Series",
description: "A lone bounty hunter in the outer reaches of the galaxy.",
date: randomDateBetween(start, end),
thumbnail: "https://image.tmdb.org/t/p/original/ztvm7C7hiUpS3CZRXFmJxljICzK.jpg",
mediaInformation: {
type: "tv",
seasonNumber: 1,
episodeNumber: 1,
},
links: [
{
href: "https://www.imdb.com/title/tt8111088/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
notificationColor: "blue",
},
],
});
function randomDateBetween(start: Date, end: Date): Date {
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
}

View File

@@ -0,0 +1,100 @@
import type { IClusterHealthMonitoringIntegration } from "../../interfaces/health-monitoring/health-monitoring-integration";
import type { ClusterHealthMonitoring } from "../../types";
export class ClusterHealthMonitoringMockService implements IClusterHealthMonitoringIntegration {
public async getClusterInfoAsync(): Promise<ClusterHealthMonitoring> {
return Promise.resolve({
nodes: Array.from({ length: 5 }, (_, index) => ClusterHealthMonitoringMockService.createNode(index)),
lxcs: Array.from({ length: 3 }, (_, index) => ClusterHealthMonitoringMockService.createLxc(index)),
vms: Array.from({ length: 7 }, (_, index) => ClusterHealthMonitoringMockService.createVm(index)),
storages: Array.from({ length: 9 }, (_, index) => ClusterHealthMonitoringMockService.createStorage(index)),
});
}
private static createNode(index: number): ClusterHealthMonitoring["nodes"][number] {
return {
id: index.toString(),
name: `Node ${index}`,
isRunning: Math.random() > 0.1, // 90% chance of being running
node: `Node ${index}`,
status: Math.random() > 0.5 ? "online" : "offline",
type: "node",
uptime: Math.floor(Math.random() * 1000000), // Randomly generate uptime in seconds
haState: null,
...this.createResourceUsage(),
};
}
private static createResourceUsage() {
const totalMemory = Math.pow(2, Math.floor(Math.random() * 6) + 1) * 1024 * 1024 * 1024; // Randomly generate between 2GB and 64GB
const totalStorage = Math.pow(2, Math.floor(Math.random() * 6) + 1) * 1024 * 1024 * 1024; // Randomly generate between 2GB and 64GB
return {
cpu: {
cores: Math.pow(2, Math.floor(Math.random() * 5) + 1), // Randomly generate between 2 and 32 cores,
utilization: Math.random(),
},
memory: {
total: totalMemory,
used: Math.floor(Math.random() * totalMemory), // Randomly generate used memory
},
network: {
in: Math.floor(Math.random() * 1000), // Randomly generate network in
out: Math.floor(Math.random() * 1000), // Randomly generate network out
},
storage: {
total: totalStorage,
used: Math.floor(Math.random() * totalStorage), // Randomly generate used storage
read: Math.floor(Math.random() * 1000), // Randomly generate read
write: Math.floor(Math.random() * 1000), // Randomly generate write
},
};
}
private static createVm(index: number): ClusterHealthMonitoring["vms"][number] {
return {
id: index.toString(),
name: `VM ${index}`,
vmId: index + 1000, // VM IDs start from 1000
...this.createResourceUsage(),
haState: null,
isRunning: Math.random() > 0.1, // 90% chance of being running
node: `Node ${Math.floor(index / 2)}`, // Assign to a node
status: Math.random() > 0.5 ? "online" : "offline",
type: "qemu",
uptime: Math.floor(Math.random() * 1000000), // Randomly generate uptime in seconds
};
}
private static createLxc(index: number): ClusterHealthMonitoring["lxcs"][number] {
return {
id: index.toString(),
name: `LXC ${index}`,
vmId: index + 2000, // LXC IDs start from 2000
...this.createResourceUsage(),
haState: null,
isRunning: Math.random() > 0.1, // 90% chance of being running
node: `Node ${Math.floor(index / 2)}`, // Assign to a node
status: Math.random() > 0.5 ? "online" : "offline",
type: "lxc",
uptime: Math.floor(Math.random() * 1000000), // Randomly generate uptime in seconds
};
}
private static createStorage(index: number): ClusterHealthMonitoring["storages"][number] {
const total = Math.pow(2, Math.floor(Math.random() * 6) + 1) * 1024 * 1024 * 1024; // Randomly generate between 2GB and 64GB
return {
id: index.toString(),
name: `Storage ${index}`,
isRunning: Math.random() > 0.1, // 90% chance of being running
node: `Node ${Math.floor(index / 2)}`, // Assign to a node
status: Math.random() > 0.5 ? "online" : "offline",
isShared: Math.random() > 0.5, // 50% chance of being shared
storagePlugin: `Plugin ${index}`,
total,
used: Math.floor(Math.random() * total), // Randomly generate used storage
type: "storage",
};
}
}

Some files were not shown because too many files have changed in this diff Show More