mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-27 00:40:58 +01:00
chore(release): automatic release v1.27.0
This commit is contained in:
@@ -37,4 +37,7 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
# Enable kubernetes tool
|
||||
# ENABLE_KUBERNETES=true
|
||||
# ENABLE_KUBERNETES=true
|
||||
|
||||
# Enable mock integration
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -31,6 +31,7 @@ body:
|
||||
label: Version
|
||||
description: What version of Homarr are you running?
|
||||
options:
|
||||
- 1.26.0
|
||||
- 1.25.0
|
||||
- 1.24.0
|
||||
- 1.23.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.16.0-alpine AS base
|
||||
FROM node:22.17.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/docker": "workspace:^0.1.0",
|
||||
"@homarr/env": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||
"@homarr/gridstack": "^1.12.0",
|
||||
@@ -56,9 +57,9 @@
|
||||
"@mantine/tiptap": "^8.1.2",
|
||||
"@million/lint": "1.0.14",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"@tanstack/react-query": "^5.81.4",
|
||||
"@tanstack/react-query-devtools": "^5.81.4",
|
||||
"@tanstack/react-query-next-experimental": "^5.81.4",
|
||||
"@tanstack/react-query": "^5.81.5",
|
||||
"@tanstack/react-query-devtools": "^5.81.5",
|
||||
"@tanstack/react-query-next-experimental": "^5.81.5",
|
||||
"@trpc/client": "^11.4.3",
|
||||
"@trpc/next": "^11.4.3",
|
||||
"@trpc/react-query": "^11.4.3",
|
||||
@@ -69,13 +70,13 @@
|
||||
"chroma-js": "^3.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.6.0",
|
||||
"dotenv": "^17.0.1",
|
||||
"flag-icons": "^7.5.0",
|
||||
"glob": "^11.0.3",
|
||||
"jotai": "^2.12.5",
|
||||
"mantine-react-table": "2.0.0-beta.9",
|
||||
"next": "15.3.4",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"next": "15.3.5",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
@@ -83,22 +84,22 @@
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.89.2",
|
||||
"superjson": "2.2.2",
|
||||
"swagger-ui-react": "^5.25.3",
|
||||
"swagger-ui-react": "^5.26.0",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"zod": "^3.25.67"
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "3.1.1",
|
||||
"@types/node": "^22.15.33",
|
||||
"@types/node": "^22.16.0",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"concurrently": "^9.2.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"node-loader": "^2.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
BIN
apps/nextjs/public/images/mock/avatar.jpg
Normal file
BIN
apps/nextjs/public/images/mock/avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 595 KiB |
@@ -10,15 +10,20 @@ import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
export const IntegrationCreateDropdownContent = () => {
|
||||
interface IntegrationCreateDropdownContentProps {
|
||||
enableMockIntegration: boolean;
|
||||
}
|
||||
|
||||
export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => {
|
||||
const t = useI18n();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredKinds = useMemo(() => {
|
||||
return integrationKinds.filter((kind) =>
|
||||
getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()),
|
||||
);
|
||||
}, [search]);
|
||||
return integrationKinds
|
||||
.filter((kind) => enableMockIntegration || kind !== "mock")
|
||||
.filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()))
|
||||
.sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB)));
|
||||
}, [search, enableMockIntegration]);
|
||||
|
||||
const handleSearch = React.useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value),
|
||||
|
||||
@@ -41,6 +41,7 @@ import { CountBadge, IntegrationAvatar } from "@homarr/ui";
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { env } from "~/env";
|
||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||
@@ -114,7 +115,7 @@ const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
|
||||
>
|
||||
{children}
|
||||
<MenuDropdown>
|
||||
<IntegrationCreateDropdownContent />
|
||||
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
|
||||
</MenuDropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -1,89 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ActionIcon, Badge, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { useListState } from "@mantine/hooks";
|
||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||
import React, { useState, useTransition } from "react";
|
||||
import { ActionIcon, Badge, Button, Card, Group, Select, Stack, Text } from "@mantine/core";
|
||||
import { useMap } from "@mantine/hooks";
|
||||
import { IconPlayerPlay, IconPower, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { getMantineColor, useTimeAgo } from "@homarr/common";
|
||||
import type { TaskStatus } from "@homarr/cron-job-status";
|
||||
import type { TranslationKeys } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { IconPowerOff } from "@homarr/ui/icons";
|
||||
|
||||
interface JobsListProps {
|
||||
initialJobs: RouterOutputs["cronJobs"]["getJobs"];
|
||||
}
|
||||
|
||||
interface JobState {
|
||||
job: JobsListProps["initialJobs"][number];
|
||||
type JobName = RouterOutputs["cronJobs"]["getJobs"][number]["name"];
|
||||
|
||||
export const JobsList = ({ initialJobs }: JobsListProps) => {
|
||||
const [jobs] = clientApi.cronJobs.getJobs.useSuspenseQuery(undefined, {
|
||||
initialData: initialJobs,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const jobStatusMap = useMap<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;
|
||||
}
|
||||
|
||||
export const JobsList = ({ initialJobs }: JobsListProps) => {
|
||||
const t = useScopedI18n("management.page.tool.tasks");
|
||||
const [jobs, handlers] = useListState<JobState>(
|
||||
initialJobs.map((job) => ({
|
||||
job,
|
||||
status: null,
|
||||
})),
|
||||
);
|
||||
clientApi.cronJobs.subscribeToStatusUpdates.useSubscription(undefined, {
|
||||
onData: (data) => {
|
||||
const jobByName = jobs.find((job) => job.job.name === data.name);
|
||||
if (!jobByName) {
|
||||
return;
|
||||
}
|
||||
handlers.applyWhere(
|
||||
(job) => job.job.name === data.name,
|
||||
(job) => ({ ...job, status: data }),
|
||||
);
|
||||
},
|
||||
});
|
||||
const { mutateAsync } = clientApi.cronJobs.triggerJob.useMutation();
|
||||
const JobCard = ({ job, status }: JobCardProps) => {
|
||||
const t = useI18n();
|
||||
const tTasks = useScopedI18n("management.page.tool.tasks");
|
||||
const triggerMutation = clientApi.cronJobs.triggerJob.useMutation();
|
||||
const handleJobTrigger = React.useCallback(
|
||||
async (job: JobState) => {
|
||||
if (job.status?.status === "running") {
|
||||
return;
|
||||
}
|
||||
await mutateAsync(job.job.name);
|
||||
async (name: JobName) => {
|
||||
if (status?.status === "running") return;
|
||||
await triggerMutation.mutateAsync(name);
|
||||
},
|
||||
[mutateAsync],
|
||||
[triggerMutation, status],
|
||||
);
|
||||
return (
|
||||
<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 && (
|
||||
<ActionIcon
|
||||
onClick={() => handleJobTrigger(job)}
|
||||
disabled={job.status?.status === "running"}
|
||||
variant={"default"}
|
||||
size={"xl"}
|
||||
radius={"xl"}
|
||||
>
|
||||
<IconPlayerPlay stroke={1.5} />
|
||||
</ActionIcon>
|
||||
const { openModal } = useModalAction(TaskConfigurationModal);
|
||||
const [isEnabled, setEnabled] = useState(job.isEnabled);
|
||||
const disableMutation = clientApi.cronJobs.disableJob.useMutation();
|
||||
const enableMutation = clientApi.cronJobs.enableJob.useMutation();
|
||||
|
||||
const [activeStatePending, startActiveTransition] = useTransition();
|
||||
const handleActiveChange = () =>
|
||||
startActiveTransition(async () => {
|
||||
if (isEnabled) {
|
||||
await disableMutation.mutateAsync(job.name, {
|
||||
onSuccess() {
|
||||
setEnabled(false);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await enableMutation.mutateAsync(job.name, {
|
||||
onSuccess() {
|
||||
setEnabled(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<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>
|
||||
</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 = useTimeAgo(new Date(timestamp));
|
||||
|
||||
@@ -93,3 +220,65 @@ const TimeAgo = ({ timestamp }: { timestamp: string }) => {
|
||||
</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
9
apps/nextjs/src/env.ts
Normal 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,
|
||||
});
|
||||
@@ -22,7 +22,7 @@
|
||||
"dependencies": {
|
||||
"@homarr/analytics": "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-core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
@@ -36,7 +36,8 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.6.0",
|
||||
"dotenv": "^17.0.1",
|
||||
"fastify": "^5.4.0",
|
||||
"superjson": "2.2.2",
|
||||
"undici": "7.11.0"
|
||||
},
|
||||
@@ -44,10 +45,10 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "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",
|
||||
"esbuild": "^0.25.5",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"prettier": "^3.6.2",
|
||||
"tsx": "4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
109
apps/tasks/src/job-manager.ts
Normal file
109
apps/tasks/src/job-manager.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
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 { 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 () => {
|
||||
registerCronJobRunner();
|
||||
await jobGroup.initializeAsync();
|
||||
await jobGroup.startAllAsync();
|
||||
|
||||
try {
|
||||
await server.listen({ port: CRON_JOB_API_PORT });
|
||||
logger.info(`Tasks web server started successfully port="${CRON_JOB_API_PORT}"`);
|
||||
} catch (err) {
|
||||
logger.error(new Error(`Failed to start tasks web server port="${CRON_JOB_API_PORT}"`, { cause: err }));
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"dotenv": "^16.6.0",
|
||||
"dotenv": "^17.0.1",
|
||||
"tsx": "4.20.3",
|
||||
"ws": "^8.18.2"
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
@@ -35,7 +35,7 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"esbuild": "^0.25.5",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"cross-env": "^7.0.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"semantic-release": "^24.2.5",
|
||||
"semantic-release": "^24.2.6",
|
||||
"testcontainers": "^11.0.3",
|
||||
"turbo": "^2.5.4",
|
||||
"typescript": "^5.8.3",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/certificates": "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-jobs": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
@@ -41,24 +41,24 @@
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.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/react-query": "^11.4.3",
|
||||
"@trpc/server": "^11.4.3",
|
||||
"@trpc/tanstack-react-query": "^11.4.3",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"next": "15.3.4",
|
||||
"next": "15.3.5",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"superjson": "2.2.2",
|
||||
"trpc-to-openapi": "^2.3.1",
|
||||
"zod": "^3.25.67"
|
||||
"trpc-to-openapi": "^2.3.2",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import z from "zod/v4";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { cronJobNames, cronJobs, jobNameSchema, triggerCronJobAsync } from "@homarr/cron-job-runner";
|
||||
import { cronExpressionSchema, jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api";
|
||||
import { cronJobApi } from "@homarr/cron-job-api/client";
|
||||
import type { TaskStatus } from "@homarr/cron-job-status";
|
||||
import { createCronJobStatusChannel } from "@homarr/cron-job-status";
|
||||
import { logger } from "@homarr/log";
|
||||
@@ -13,19 +14,51 @@ export const cronJobsRouter = createTRPCRouter({
|
||||
.requiresPermission("admin")
|
||||
.input(jobNameSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await triggerCronJobAsync(input);
|
||||
await cronJobApi.trigger.mutate(input);
|
||||
}),
|
||||
getJobs: permissionRequiredProcedure.requiresPermission("admin").query(() => {
|
||||
return objectEntries(cronJobs).map(([name, options]) => ({
|
||||
name,
|
||||
preventManualExecution: options.preventManualExecution,
|
||||
}));
|
||||
startJob: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(jobNameSchema)
|
||||
.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(() => {
|
||||
return observable<TaskStatus>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
|
||||
for (const name of cronJobNames) {
|
||||
for (const name of jobGroupKeys) {
|
||||
const channel = createCronJobStatusChannel(name);
|
||||
const unsubscribe = channel.subscribe((data) => {
|
||||
emit.next(data);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const healthMonitoringRouter = createTRPCRouter({
|
||||
getSystemHealthStatus: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
|
||||
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
@@ -26,9 +26,9 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
);
|
||||
}),
|
||||
subscribeSystemHealthStatus: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
|
||||
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "mock"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
|
||||
return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integration of ctx.integrations) {
|
||||
const innerHandler = systemInfoRequestHandler.handler(integration, {});
|
||||
@@ -49,14 +49,14 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
getClusterHealthStatus: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "proxmox"))
|
||||
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
subscribeClusterHealthStatus: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "proxmox"))
|
||||
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<ProxmoxClusterInfo>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
|
||||
@@ -34,12 +34,12 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookies": "^0.9.1",
|
||||
"ldapts": "8.0.2",
|
||||
"next": "15.3.4",
|
||||
"ldapts": "8.0.4",
|
||||
"next": "15.3.5",
|
||||
"next-auth": "5.0.0-beta.29",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "^3.25.67"
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
@@ -47,7 +47,7 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/cookies": "0.9.1",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"dotenv": "^16.6.0"
|
||||
"dotenv": "^17.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"esbuild": "^0.25.5",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,18 +30,18 @@
|
||||
"@homarr/env": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "15.3.4",
|
||||
"next": "15.3.5",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"undici": "7.11.0",
|
||||
"zod": "^3.25.67",
|
||||
"zod": "^3.25.74",
|
||||
"zod-validation-error": "^3.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { CookieSerializeOptions } from "cookie";
|
||||
import type { SerializeOptions } from "cookie";
|
||||
import { parse, serialize } from "cookie";
|
||||
|
||||
export function parseCookies(cookieString: string) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "@homarr/cron-job-runner",
|
||||
"name": "@homarr/cron-job-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./register": "./src/register.ts"
|
||||
".": "./src/index.ts",
|
||||
"./env": "./src/env.ts",
|
||||
"./constants": "./src/constants.ts",
|
||||
"./client": "./src/client.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
@@ -25,15 +27,23 @@
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs": "workspace:^0.1.0",
|
||||
"@homarr/env": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0"
|
||||
"@tanstack/react-query": "^5.81.5",
|
||||
"@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": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "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"
|
||||
}
|
||||
}
|
||||
20
packages/cron-job-api/src/client.ts
Normal file
20
packages/cron-job-api/src/client.ts
Normal 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}`;
|
||||
}
|
||||
3
packages/cron-job-api/src/constants.ts
Normal file
3
packages/cron-job-api/src/constants.ts
Normal 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";
|
||||
11
packages/cron-job-api/src/env.ts
Normal file
11
packages/cron-job-api/src/env.ts
Normal 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,
|
||||
});
|
||||
82
packages/cron-job-api/src/index.ts
Normal file
82
packages/cron-job-api/src/index.ts
Normal 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;
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./src";
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -29,7 +29,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,14 +25,15 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"node-cron": "^4.1.1"
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"node-cron": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AxiosError } from "axios";
|
||||
import type { ScheduledTask } from "node-cron";
|
||||
import { schedule, validate } from "node-cron";
|
||||
import { createTask, validate } from "node-cron";
|
||||
|
||||
import { Stopwatch } from "@homarr/common";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import type { Logger } from "./logger";
|
||||
import type { ValidateCron } from "./validation";
|
||||
@@ -18,13 +18,14 @@ export interface CreateCronJobCreatorOptions<TAllowedNames extends string> {
|
||||
|
||||
interface CreateCronJobOptions {
|
||||
runOnStart?: boolean;
|
||||
preventManualExecution?: boolean;
|
||||
expectedMaximumDurationInMillis?: number;
|
||||
beforeStart?: () => MaybePromise<void>;
|
||||
}
|
||||
|
||||
const createCallback = <TAllowedNames extends string, TName extends TAllowedNames>(
|
||||
name: TName,
|
||||
cronExpression: string,
|
||||
defaultCronExpression: string,
|
||||
options: CreateCronJobOptions,
|
||||
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 {
|
||||
name,
|
||||
cronExpression,
|
||||
scheduledTask,
|
||||
cronExpression: defaultCronExpression,
|
||||
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() {
|
||||
if (options.beforeStart) {
|
||||
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`);
|
||||
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>(
|
||||
name: TName,
|
||||
cronExpression: TExpression,
|
||||
defaultCronExpression: TExpression,
|
||||
options: CreateCronJobOptions = { runOnStart: false },
|
||||
) => {
|
||||
creatorOptions.logger.logDebug(`Validating cron expression '${cronExpression}' for job: ${name}`);
|
||||
if (cronExpression !== "never" && !validate(cronExpression)) {
|
||||
throw new Error(`Invalid cron expression '${cronExpression}' for job '${name}'`);
|
||||
creatorOptions.logger.logDebug(`Validating cron expression '${defaultCronExpression}' for job: ${name}`);
|
||||
if (defaultCronExpression !== "never" && !validate(defaultCronExpression)) {
|
||||
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 = {
|
||||
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
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ScheduledTask } from "node-cron";
|
||||
|
||||
import { objectEntries, objectKeys } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import type { JobCallback } from "./creator";
|
||||
import type { Logger } from "./logger";
|
||||
@@ -27,45 +30,78 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
});
|
||||
}
|
||||
|
||||
const tasks = new Map<string, ScheduledTask>();
|
||||
|
||||
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) => {
|
||||
const job = jobRegistry.get(name as string);
|
||||
if (!job) return;
|
||||
if (!tasks.has(job.name)) return;
|
||||
|
||||
options.logger.logInfo(`Starting schedule cron job ${job.name}.`);
|
||||
await job.onStartAsync();
|
||||
await job.scheduledTask?.start();
|
||||
await tasks.get(name as string)?.start();
|
||||
},
|
||||
startAllAsync: async () => {
|
||||
for (const job of jobRegistry.values()) {
|
||||
if (!tasks.has(job.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.logger.logInfo(`Starting schedule of cron job ${job.name}.`);
|
||||
await job.onStartAsync();
|
||||
await job.scheduledTask?.start();
|
||||
await tasks.get(job.name)?.start();
|
||||
}
|
||||
},
|
||||
runManuallyAsync: async (name: keyof TJobs) => {
|
||||
const job = jobRegistry.get(name as string);
|
||||
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.`);
|
||||
await job.scheduledTask?.execute();
|
||||
await tasks.get(name as string)?.execute();
|
||||
},
|
||||
stopAsync: async (name: keyof TJobs) => {
|
||||
const job = jobRegistry.get(name as string);
|
||||
if (!job) return;
|
||||
|
||||
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
|
||||
await job.scheduledTask?.stop();
|
||||
await tasks.get(name as string)?.stop();
|
||||
},
|
||||
stopAllAsync: async () => {
|
||||
for (const job of jobRegistry.values()) {
|
||||
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
|
||||
await job.scheduledTask?.stop();
|
||||
await tasks.get(job.name)?.stop();
|
||||
}
|
||||
},
|
||||
getJobRegistry() {
|
||||
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() {
|
||||
return objectKeys(jobs);
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createCronJob } from "../lib";
|
||||
|
||||
export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
|
||||
runOnStart: true,
|
||||
preventManualExecution: true,
|
||||
}).withCallback(async () => {
|
||||
const analyticSetting = await getServerSettingByKeyAsync(db, "analytics");
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||
|
||||
const newIconRepositories: InferInsertModel<typeof iconRepositories>[] = [];
|
||||
const newIcons: InferInsertModel<typeof icons>[] = [];
|
||||
const allDbIcons = databaseIconRepositories.flatMap((group) => group.icons);
|
||||
|
||||
for (const repositoryIconGroup of repositoryIconGroups) {
|
||||
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) {
|
||||
if (
|
||||
databaseIconRepositories
|
||||
.flatMap((repository) => repository.icons)
|
||||
.some((dbIcon) => dbIcon.checksum === icon.checksum && dbIcon.iconRepositoryId === iconRepositoryId)
|
||||
) {
|
||||
if (dbIconsInRepository.some((dbIcon) => dbIcon.checksum === icon.checksum)) {
|
||||
skippedChecksums.push(`${iconRepositoryId}.${icon.checksum}`);
|
||||
continue;
|
||||
}
|
||||
@@ -76,9 +75,9 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||
}
|
||||
}
|
||||
|
||||
const deadIcons = databaseIconRepositories
|
||||
.flatMap((repository) => repository.icons)
|
||||
.filter((icon) => !skippedChecksums.includes(`${icon.iconRepositoryId}.${icon.checksum}`));
|
||||
const deadIcons = allDbIcons.filter(
|
||||
(icon) => !skippedChecksums.includes(`${icon.iconRepositoryId}.${icon.checksum}`),
|
||||
);
|
||||
|
||||
const deadIconRepositories = databaseIconRepositories.filter(
|
||||
(iconRepository) => !repositoryIconGroups.some((group) => group.slug === iconRepository.slug),
|
||||
|
||||
@@ -8,7 +8,8 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC
|
||||
createRequestIntegrationJobHandler(
|
||||
(integration, itemOptions: Record<string, never>) => {
|
||||
const { kind } = integration;
|
||||
if (kind !== "proxmox") {
|
||||
|
||||
if (kind !== "proxmox" && kind !== "mock") {
|
||||
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
|
||||
}
|
||||
return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
|
||||
|
||||
@@ -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`)
|
||||
);
|
||||
2093
packages/db/migrations/mysql/meta/0033_snapshot.json
Normal file
2093
packages/db/migrations/mysql/meta/0033_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -232,6 +232,13 @@
|
||||
"when": 1746821770071,
|
||||
"tag": "0032_add_trusted_certificate_hostnames",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 33,
|
||||
"version": "5",
|
||||
"when": 1750013953833,
|
||||
"tag": "0033_add_cron_job_configuration",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
2008
packages/db/migrations/sqlite/meta/0033_snapshot.json
Normal file
2008
packages/db/migrations/sqlite/meta/0033_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -232,6 +232,13 @@
|
||||
"when": 1746821779051,
|
||||
"tag": "0032_add_trusted_certificate_hostnames",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 33,
|
||||
"version": "6",
|
||||
"when": 1750014001941,
|
||||
"tag": "0033_add_cron_job_configuration",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
"@mantine/core": "^8.1.2",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@testcontainers/mysql": "^11.0.3",
|
||||
"better-sqlite3": "^12.1.1",
|
||||
"dotenv": "^16.6.0",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"dotenv": "^17.0.1",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
@@ -61,7 +61,7 @@
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"esbuild": "^0.25.5",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"prettier": "^3.6.2",
|
||||
"tsx": "4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
@@ -40,6 +40,7 @@ export const {
|
||||
itemLayouts,
|
||||
sectionLayouts,
|
||||
trustedCertificateHostnames,
|
||||
cronJobConfigurations,
|
||||
} = schema;
|
||||
|
||||
export type User = InferSelectModel<typeof schema.users>;
|
||||
|
||||
@@ -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 }) => ({
|
||||
user: one(users, {
|
||||
fields: [accounts.userId],
|
||||
|
||||
@@ -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 }) => ({
|
||||
user: one(users, {
|
||||
fields: [accounts.userId],
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"zod": "^3.25.67"
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"tsx": "4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
@@ -176,6 +176,25 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg",
|
||||
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>;
|
||||
|
||||
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/dockerode": "^3.3.41",
|
||||
"eslint": "^9.29.0",
|
||||
"@types/dockerode": "^3.3.42",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
4
packages/env/package.json
vendored
4
packages/env/package.json
vendored
@@ -24,13 +24,13 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"zod": "^3.25.67"
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/form": "^8.1.2",
|
||||
"zod": "^3.25.67"
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,13 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^8.1.2",
|
||||
"react": "19.1.0",
|
||||
"zod": "^3.25.67"
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"tsdav": "^2.1.5",
|
||||
"undici": "7.11.0",
|
||||
"xml2js": "^0.6.2",
|
||||
"zod": "^3.25.67"
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
@@ -52,7 +52,7 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-unifi": "^2.5.1",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint": "^9.30.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"
|
||||
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
|
||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
|
||||
import { MockIntegration } from "../mock/mock-integration";
|
||||
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
|
||||
import { NTFYIntegration } from "../ntfy/ntfy-integration";
|
||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||
@@ -94,6 +95,7 @@ export const integrationCreators = {
|
||||
nextcloud: NextcloudIntegration,
|
||||
unifiController: UnifiControllerIntegration,
|
||||
ntfy: NTFYIntegration,
|
||||
mock: MockIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -12,9 +12,10 @@ import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
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> {
|
||||
const response = await input.fetchAsync(this.url("/info"));
|
||||
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 cpuLoad = await this.getCurrentCpuLoadAsync();
|
||||
const memoryLoad = await this.getCurrentMemoryLoadAsync();
|
||||
|
||||
@@ -4,14 +4,15 @@ import type { fetch as undiciFetch } from "undici";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
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 { 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> {
|
||||
const client = this.getClient();
|
||||
const keys: (keyof Aria2Download)[] = [
|
||||
|
||||
@@ -6,16 +6,17 @@ import { createCertificateAgentAsync } from "@homarr/certificates/server";
|
||||
|
||||
import { HandleIntegrationErrors } from "../../base/errors/decorator";
|
||||
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
|
||||
|
||||
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
|
||||
export class DelugeIntegration extends DownloadClientIntegration {
|
||||
export class DelugeIntegration extends Integration implements IDownloadClientIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const client = await this.getClientAsync(input.dispatcher);
|
||||
const isSuccess = await client.login();
|
||||
|
||||
@@ -4,15 +4,16 @@ import type { fetch as undiciFetch } from "undici";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
|
||||
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> {
|
||||
await this.nzbGetApiCallWithCustomFetchAsync(input.fetchAsync, "version");
|
||||
return {
|
||||
|
||||
@@ -6,16 +6,17 @@ import { createCertificateAgentAsync } from "@homarr/certificates/server";
|
||||
|
||||
import { HandleIntegrationErrors } from "../../base/errors/decorator";
|
||||
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
|
||||
|
||||
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
|
||||
export class QBitTorrentIntegration extends DownloadClientIntegration {
|
||||
export class QBitTorrentIntegration extends Integration implements IDownloadClientIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const client = await this.getClientAsync(input.dispatcher);
|
||||
const isSuccess = await client.login();
|
||||
|
||||
@@ -5,17 +5,18 @@ import type { fetch as undiciFetch } from "undici";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
|
||||
import { historySchema, queueSchema } from "./sabnzbd-schema";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
export class SabnzbdIntegration extends DownloadClientIntegration {
|
||||
export class SabnzbdIntegration extends Integration implements IDownloadClientIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
//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" });
|
||||
|
||||
@@ -6,15 +6,16 @@ import { createCertificateAgentAsync } from "@homarr/certificates/server";
|
||||
|
||||
import { HandleIntegrationErrors } from "../../base/errors/decorator";
|
||||
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
|
||||
|
||||
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
|
||||
export class TransmissionIntegration extends DownloadClientIntegration {
|
||||
export class TransmissionIntegration extends Integration implements IDownloadClientIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const client = await this.getClientAsync(input.dispatcher);
|
||||
await client.getSession();
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
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";
|
||||
|
||||
const sessionSchema = z.object({
|
||||
@@ -30,7 +31,7 @@ const sessionSchema = z.object({
|
||||
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 deviceId = "homarr-emby-integration";
|
||||
private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`;
|
||||
|
||||
@@ -5,9 +5,10 @@ import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
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";
|
||||
|
||||
export class HomeAssistantIntegration extends Integration {
|
||||
export class HomeAssistantIntegration extends Integration implements ISmartHomeIntegration {
|
||||
public async getEntityStateAsync(entityId: string) {
|
||||
try {
|
||||
const response = await this.getAsync(`/api/states/${entityId}`);
|
||||
@@ -15,6 +16,7 @@ export class HomeAssistantIntegration extends Integration {
|
||||
if (!response.ok) {
|
||||
logger.warn(`Response did not indicate success`);
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Response did not indicate success",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorren
|
||||
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
|
||||
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
|
||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
|
||||
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-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 { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
|
||||
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
|
||||
export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-monitoring";
|
||||
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
|
||||
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request";
|
||||
export type { StreamSession } from "./interfaces/media-server/session";
|
||||
export type { TdarrQueue } from "./interfaces/media-transcoding/queue";
|
||||
export type { TdarrPieSegment, TdarrStatistics } from "./interfaces/media-transcoding/statistics";
|
||||
export type { TdarrWorker } from "./interfaces/media-transcoding/workers";
|
||||
export type { Notification } from "./interfaces/notifications/notification";
|
||||
export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types";
|
||||
export { MediaRequestStatus } from "./interfaces/media-requests/media-request-types";
|
||||
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types";
|
||||
export type { StreamSession } from "./interfaces/media-server/media-server-types";
|
||||
export type {
|
||||
TdarrQueue,
|
||||
TdarrPieSegment,
|
||||
TdarrStatistics,
|
||||
TdarrWorker,
|
||||
} from "./interfaces/media-transcoding/media-transcoding-types";
|
||||
export type { Notification } from "./interfaces/notifications/notification-types";
|
||||
|
||||
// Schemas
|
||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { CalendarEvent } from "./calendar-types";
|
||||
|
||||
export interface ICalendarIntegration {
|
||||
getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored: boolean): Promise<CalendarEvent[]>;
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { DownloadClientJobsAndStatus } from "./download-client-data";
|
||||
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 */
|
||||
public abstract getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus>;
|
||||
getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus>;
|
||||
/** 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 */
|
||||
public abstract pauseItemAsync(item: DownloadClientItem): Promise<void>;
|
||||
pauseItemAsync(item: DownloadClientItem): Promise<void>;
|
||||
/** 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 */
|
||||
public abstract resumeItemAsync(item: DownloadClientItem): Promise<void>;
|
||||
resumeItemAsync(item: DownloadClientItem): Promise<void>;
|
||||
/** 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>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ClusterHealthMonitoring, SystemHealthMonitoring } from "./health-monitoring-types";
|
||||
|
||||
export interface ISystemHealthMonitoringIntegration {
|
||||
getSystemInfoAsync(): Promise<SystemHealthMonitoring>;
|
||||
}
|
||||
|
||||
export interface IClusterHealthMonitoringIntegration {
|
||||
getClusterInfoAsync(): Promise<ClusterHealthMonitoring>;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface HealthMonitoring {
|
||||
import type { LxcResource, NodeResource, QemuResource, StorageResource } from "../../types";
|
||||
|
||||
export interface SystemHealthMonitoring {
|
||||
version: string;
|
||||
cpuModelName: string;
|
||||
cpuUtilization: number;
|
||||
@@ -25,3 +27,11 @@ export interface HealthMonitoring {
|
||||
overallStatus: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// TODO: in the future decouple this from the Proxmox integration
|
||||
export interface ClusterHealthMonitoring {
|
||||
nodes: NodeResource[];
|
||||
lxcs: LxcResource[];
|
||||
vms: QemuResource[];
|
||||
storages: StorageResource[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Indexer } from "./indexer-manager-types";
|
||||
|
||||
export interface IIndexerManagerIntegration {
|
||||
getIndexersAsync(): Promise<Indexer[]>;
|
||||
testAllAsync(): Promise<void>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { CurrentSessionsInput, StreamSession } from "./media-server-types";
|
||||
|
||||
export interface IMediaServerIntegration {
|
||||
getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 {
|
||||
name: string;
|
||||
value: number;
|
||||
@@ -21,3 +38,17 @@ export interface TdarrStatistics {
|
||||
audioCodecs: 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { Notification } from "./notification";
|
||||
import type { Notification } from "./notification-types";
|
||||
|
||||
export abstract class NotificationsIntegration extends Integration {
|
||||
public abstract getNotificationsAsync(): Promise<Notification[]>;
|
||||
export interface INotificationsIntegration {
|
||||
getNotificationsAsync(): Promise<Notification[]>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -11,10 +11,11 @@ import { integrationAxiosHttpErrorHandler } from "../base/errors/http";
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
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])
|
||||
export class JellyfinIntegration extends Integration {
|
||||
export class JellyfinIntegration extends Integration implements IMediaServerIntegration {
|
||||
private readonly jellyfin: Jellyfin = new Jellyfin({
|
||||
clientInfo: {
|
||||
name: "Homarr",
|
||||
|
||||
@@ -3,13 +3,15 @@ import { z } from "zod";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { CalendarEvent } from "../../calendar-types";
|
||||
import { MediaOrganizerIntegration } from "../media-organizer-integration";
|
||||
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-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> {
|
||||
const response = await input.fetchAsync(this.url("/api"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
@@ -103,7 +105,8 @@ export class LidarrIntegration extends MediaOrganizerIntegration {
|
||||
const flatImages = [...event.images];
|
||||
|
||||
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(",")}]`);
|
||||
return sortedImages[0];
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
17
packages/integrations/src/media-organizer/media-organizer.ts
Normal file
17
packages/integrations/src/media-organizer/media-organizer.ts
Normal 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
|
||||
];
|
||||
@@ -4,14 +4,16 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import type { AtLeastOneOf } from "@homarr/common/types";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { CalendarEvent } from "../../calendar-types";
|
||||
import { radarrReleaseTypes } from "../../calendar-types";
|
||||
import { MediaOrganizerIntegration } from "../media-organizer-integration";
|
||||
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
|
||||
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.
|
||||
* @param start The start date
|
||||
@@ -82,7 +84,8 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
|
||||
const flatImages = [...event.images];
|
||||
|
||||
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(",")}]`);
|
||||
return sortedImages[0];
|
||||
|
||||
@@ -3,13 +3,15 @@ import { z } from "zod";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { CalendarEvent } from "../../calendar-types";
|
||||
import { MediaOrganizerIntegration } from "../media-organizer-integration";
|
||||
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-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> {
|
||||
const response = await input.fetchAsync(this.url("/api"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
@@ -81,7 +83,8 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
|
||||
const flatImages = [...event.images];
|
||||
|
||||
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(",")}]`);
|
||||
return sortedImages[0];
|
||||
|
||||
@@ -3,13 +3,15 @@ import { z } from "zod";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { CalendarEvent } from "../../calendar-types";
|
||||
import { MediaOrganizerIntegration } from "../media-organizer-integration";
|
||||
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-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.
|
||||
* @param start The start date
|
||||
@@ -81,7 +83,8 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
|
||||
const flatImages = [...event.images, ...event.series.images];
|
||||
|
||||
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(",")}]`);
|
||||
return sortedImages[0];
|
||||
|
||||
@@ -4,12 +4,11 @@ import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { TdarrQueue } from "../interfaces/media-transcoding/queue";
|
||||
import type { TdarrStatistics } from "../interfaces/media-transcoding/statistics";
|
||||
import type { TdarrWorker } from "../interfaces/media-transcoding/workers";
|
||||
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
|
||||
import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "../interfaces/media-transcoding/media-transcoding-types";
|
||||
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> {
|
||||
const response = await input.fetchAsync(this.url("/api/v2/is-server-alive"), {
|
||||
method: "POST",
|
||||
|
||||
74
packages/integrations/src/mock/data/calendar.ts
Normal file
74
packages/integrations/src/mock/data/calendar.ts
Normal 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()));
|
||||
}
|
||||
100
packages/integrations/src/mock/data/cluster-health-monitoring.ts
Normal file
100
packages/integrations/src/mock/data/cluster-health-monitoring.ts
Normal 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
Reference in New Issue
Block a user