chore(release): automatic release v1.9.0

This commit is contained in:
homarr-releases[bot]
2025-03-05 19:00:29 +00:00
committed by GitHub
148 changed files with 6210 additions and 924 deletions

View File

@@ -48,17 +48,17 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.17.0",
"@mantine/core": "^7.17.0",
"@mantine/dropzone": "^7.17.0",
"@mantine/hooks": "^7.17.0",
"@mantine/modals": "^7.17.0",
"@mantine/tiptap": "^7.17.0",
"@mantine/colors-generator": "^7.17.1",
"@mantine/core": "^7.17.1",
"@mantine/dropzone": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"@mantine/modals": "^7.17.1",
"@mantine/tiptap": "^7.17.1",
"@million/lint": "1.0.14",
"@tabler/icons-react": "^3.30.0",
"@tanstack/react-query": "^5.66.11",
"@tanstack/react-query-devtools": "^5.66.11",
"@tanstack/react-query-next-experimental": "^5.66.11",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-query-devtools": "^5.67.1",
"@tanstack/react-query-next-experimental": "^5.67.1",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -92,7 +92,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.1",
"@types/node": "^22.13.5",
"@types/node": "^22.13.9",
"@types/prismjs": "^1.26.5",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
@@ -100,7 +100,7 @@
"concurrently": "^9.1.2",
"eslint": "^9.21.0",
"node-loader": "^2.1.0",
"prettier": "^3.5.2",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
}
}

View File

@@ -61,7 +61,8 @@ export default async function AppsPage(props: AppsPageProps) {
</Stack>
)}
<Group justify="end">
{/* Added margin to not hide pagination behind affix-button */}
<Group justify="end" mb={48}>
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>

View File

@@ -92,7 +92,8 @@ export default async function GroupsListPage(props: MediaListPageProps) {
</TableTbody>
</Table>
<Group justify="end">
{/* Added margin to not hide pagination behind affix-button */}
<Group justify="end" mb={48}>
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>

View File

@@ -61,7 +61,8 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
</Stack>
)}
<Group justify="end">
{/* Added margin to not hide pagination behind affix-button */}
<Group justify="end" mb={48}>
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>

View File

@@ -35,7 +35,13 @@ const createColumns = (
Cell({ renderedCellValue, row }) {
return (
<Group gap="xs">
<Avatar variant="outline" radius="lg" size="md" src={row.original.iconUrl}>
<Avatar
variant="outline"
radius="lg"
size="md"
styles={{ image: { objectFit: "contain" } }}
src={row.original.iconUrl}
>
{row.original.name.at(0)?.toUpperCase()}
</Avatar>
<Text>{renderedCellValue}</Text>

View File

@@ -9,6 +9,9 @@ export class DynamicSectionMockBuilder {
this.section = {
id: createId(),
kind: "dynamic",
options: {
borderColor: "",
},
layouts: [],
...section,
} satisfies DynamicSection;

View File

@@ -14,6 +14,8 @@ interface Props {
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const options = section.options;
return (
<Box className="grid-stack-item-content">
<Card
@@ -25,6 +27,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
root: {
"--opacity": board.opacity / 100,
overflow: "hidden",
"--border-color": options.borderColor !== "" ? options.borderColor : undefined,
},
}}
radius={board.itemRadius}

View File

@@ -16,6 +16,9 @@ export const addDynamicSectionCallback = () => (board: Board) => {
const newSection = {
id: createId(),
kind: "dynamic",
options: {
borderColor: "",
},
layouts: createDynamicSectionLayouts(board, firstSection),
} satisfies DynamicSection;

View File

@@ -1,11 +1,18 @@
import { useCallback } from "react";
import type { z } from "zod";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { dynamicSectionOptionsSchema } from "@homarr/validation";
import { addDynamicSectionCallback } from "./actions/add-dynamic-section";
import type { RemoveDynamicSectionInput } from "./actions/remove-dynamic-section";
import { removeDynamicSectionCallback } from "./actions/remove-dynamic-section";
interface UpdateDynamicOptions {
itemId: string;
newOptions: z.infer<typeof dynamicSectionOptionsSchema>;
}
export const useDynamicSectionActions = () => {
const { updateBoard } = useUpdateBoard();
@@ -13,6 +20,16 @@ export const useDynamicSectionActions = () => {
updateBoard(addDynamicSectionCallback());
}, [updateBoard]);
const updateDynamicSection = useCallback(
({ itemId, newOptions }: UpdateDynamicOptions) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
}));
},
[updateBoard],
);
const removeDynamicSection = useCallback(
(input: RemoveDynamicSectionInput) => {
updateBoard(removeDynamicSectionCallback(input));
@@ -22,6 +39,7 @@ export const useDynamicSectionActions = () => {
return {
addDynamicSection,
updateDynamicSection,
removeDynamicSection,
};
};

View File

@@ -0,0 +1,63 @@
"use client";
import { Button, CloseButton, ColorInput, Group, Stack, useMantineTheme } from "@mantine/core";
import type { z } from "zod";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { dynamicSectionOptionsSchema } from "@homarr/validation";
interface ModalProps {
value: z.infer<typeof dynamicSectionOptionsSchema>;
onSuccessfulEdit: (value: z.infer<typeof dynamicSectionOptionsSchema>) => void;
}
export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const theme = useMantineTheme();
const form = useZodForm(dynamicSectionOptionsSchema, {
mode: "controlled",
initialValues: { ...innerProps.value },
});
return (
<form
onSubmit={form.onSubmit((values) => {
innerProps.onSuccessfulEdit(values);
actions.closeModal();
})}
>
<Stack>
<ColorInput
label={t("section.dynamic.option.borderColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
rightSection={
<CloseButton
onClick={() => form.setFieldValue("borderColor", "")}
style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }}
/>
}
{...form.getInputProps("borderColor")}
/>
<Group justify="end">
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("item.edit.title");
},
size: "lg",
});

View File

@@ -1,22 +1,37 @@
import { ActionIcon, Menu } from "@mantine/core";
import { IconDotsVertical, IconTrash } from "@tabler/icons-react";
import { IconDotsVertical, IconPencil, IconTrash } from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal } from "@homarr/modals";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import { useDynamicSectionActions } from "./dynamic-actions";
import { DynamicSectionEditModal } from "./dynamic-edit-modal";
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionItem }) => {
const t = useI18n();
const tDynamic = useScopedI18n("section.dynamic");
const { removeDynamicSection } = useDynamicSectionActions();
const tItem = useScopedI18n("item");
const { openModal } = useModalAction(DynamicSectionEditModal);
const { updateDynamicSection, removeDynamicSection } = useDynamicSectionActions();
const { openConfirmModal } = useConfirmModal();
const [isEditMode] = useEditMode();
if (!isEditMode) return null;
const openEditModal = () => {
openModal({
value: section.options,
onSuccessfulEdit: (options) => {
updateDynamicSection({
itemId: section.id,
newOptions: options,
});
},
});
};
const openRemoveModal = () => {
openConfirmModal({
title: tDynamic("remove.title"),
@@ -35,6 +50,11 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
{tItem("action.edit")}
</Menu.Item>
<Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
{tDynamic("action.remove")}

View File

@@ -2,7 +2,7 @@
"name": "@homarr/tasks",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./src/index.ts"
@@ -44,10 +44,10 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.13.5",
"@types/node": "^22.13.9",
"dotenv-cli": "^8.0.0",
"eslint": "^9.21.0",
"prettier": "^3.5.2",
"prettier": "^3.5.3",
"tsx": "4.19.3",
"typescript": "^5.8.2"
}

View File

@@ -2,7 +2,7 @@
"name": "@homarr/websocket",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"main": "./src/main.ts",
"types": "./src/main.ts",
@@ -33,9 +33,9 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.5.14",
"@types/ws": "^8.18.0",
"eslint": "^9.21.0",
"prettier": "^3.5.2",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
}
}

View File

@@ -1 +1,2 @@
docker run -p 7575:7575 homarr:latest
:: Please do not run this command in production. It is only for local testing.
docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 homarr:latest

View File

@@ -45,7 +45,7 @@
"conventional-changelog-conventionalcommits": "^8.0.0",
"cross-env": "^7.0.3",
"jsdom": "^26.0.0",
"prettier": "^3.5.2",
"prettier": "^3.5.3",
"semantic-release": "^24.2.3",
"testcontainers": "^10.18.0",
"turbo": "^2.4.4",

View File

@@ -2,7 +2,7 @@
"name": "@homarr/analytics",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"

View File

@@ -36,7 +36,7 @@ const sendWidgetDataAsync = async (umamiInstance: Umami, analyticsSettings: type
if (!analyticsSettings.enableWidgetData) {
return;
}
const widgetCount = (await db.select({ count: count(items.id) }).from(items))[0]?.count ?? 0;
const widgetCount = await db.$count(items);
const response = await umamiInstance.track("server-widget-data", {
countWidgets: widgetCount,
@@ -52,7 +52,7 @@ const sendUserDataAsync = async (umamiInstance: Umami, analyticsSettings: typeof
if (!analyticsSettings.enableUserData) {
return;
}
const userCount = (await db.select({ count: count(users.id) }).from(users))[0]?.count ?? 0;
const userCount = await db.$count(users);
const response = await umamiInstance.track("server-user-data", {
countUsers: userCount,

View File

@@ -2,7 +2,7 @@
"name": "@homarr/api",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./src/index.ts",
@@ -57,7 +57,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.21.0",
"prettier": "^3.5.2",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
}
}

View File

@@ -5,7 +5,7 @@ import { z } from "zod";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { DeviceType } from "@homarr/common/server";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, asc, createId, eq, handleTransactionsAsync, inArray, isNull, like, not, or } from "@homarr/db";
import { and, asc, createId, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
@@ -27,7 +27,13 @@ import {
users,
} from "@homarr/db/schema";
import type { WidgetKind } from "@homarr/definitions";
import { everyoneGroup, getPermissionsWithChildren, getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
import {
emptySuperJSON,
everyoneGroup,
getPermissionsWithChildren,
getPermissionsWithParents,
widgetKinds,
} from "@homarr/definitions";
import { importOldmarrAsync } from "@homarr/old-import";
import { importJsonFileSchema } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema";
@@ -554,7 +560,7 @@ export const boardRouter = createTRPCRouter({
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
getBoardByName: publicProcedure.input(validation.board.byName).query(async ({ input, ctx }) => {
const boardWhere = eq(boards.name, input.name);
const boardWhere = eq(sql`UPPER(${boards.name})`, input.name.toUpperCase());
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
@@ -736,6 +742,7 @@ export const boardRouter = createTRPCRouter({
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : 0,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
@@ -861,6 +868,7 @@ export const boardRouter = createTRPCRouter({
.set({
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : null,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(schema.sections.id, section.id));
@@ -934,6 +942,7 @@ export const boardRouter = createTRPCRouter({
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : 0,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
@@ -1069,6 +1078,7 @@ export const boardRouter = createTRPCRouter({
.set({
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : null,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id))
@@ -1561,6 +1571,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
...section,
xOffset: section.xOffset,
yOffset: section.yOffset,
options: superjson.parse(section.options ?? emptySuperJSON),
layouts: section.layouts.map((layout) => ({
xOffset: layout.xOffset,
yOffset: layout.yOffset,

View File

@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import type { Database } from "@homarr/db";
import { and, createId, eq, handleTransactionsAsync, like, not, sql } from "@homarr/db";
import { and, createId, eq, handleTransactionsAsync, like, not } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
import { everyoneGroup } from "@homarr/definitions";
@@ -42,12 +42,7 @@ export const groupRouter = createTRPCRouter({
.input(validation.common.paginated)
.query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
const groupCount = await ctx.db
.select({
count: sql<number>`count(*)`,
})
.from(groups)
.where(whereQuery);
const groupCount = await ctx.db.$count(groups, whereQuery);
const dbGroups = await ctx.db.query.groups.findMany({
with: {
@@ -74,7 +69,7 @@ export const groupRouter = createTRPCRouter({
...group,
members: group.members.map((member) => member.user),
})),
totalCount: groupCount[0]?.count ?? 0,
totalCount: groupCount,
};
}),
getById: permissionRequiredProcedure

View File

@@ -2,7 +2,6 @@ import type { AnySQLiteTable } from "drizzle-orm/sqlite-core";
import { isProviderEnabled } from "@homarr/auth/server";
import type { Database } from "@homarr/db";
import { count } from "@homarr/db";
import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema";
import { createTRPCRouter, publicProcedure } from "../trpc";
@@ -28,5 +27,5 @@ const getCountForTableAsync = async (db: Database, table: AnySQLiteTable, canVie
return 0;
}
return (await db.select({ count: count() }).from(table))[0]?.count ?? 0;
return await db.$count(table);
};

View File

@@ -1,4 +1,4 @@
import { and, count, like } from "@homarr/db";
import { and, like } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import { validation } from "@homarr/validation";
@@ -24,7 +24,7 @@ export const iconsRouter = createTRPCRouter({
},
},
}),
countIcons: (await ctx.db.select({ count: count() }).from(icons))[0]?.count ?? 0,
countIcons: await ctx.db.$count(icons),
};
}),
});

View File

@@ -23,7 +23,7 @@ import {
integrationKinds,
integrationSecretKindObject,
} from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { validation } from "@homarr/validation";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -465,7 +465,7 @@ export const integrationRouter = createTRPCRouter({
.unstable_concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("search")))
.input(z.object({ integrationId: z.string(), query: z.string() }))
.query(async ({ ctx, input }) => {
const integrationInstance = integrationCreator(ctx.integration);
const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query));
}),
});

View File

@@ -4,7 +4,7 @@ import { decryptSecret } from "@homarr/common/server";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions";
import { integrationCreator, IntegrationTestConnectionError } from "@homarr/integrations";
import { createIntegrationAsync, IntegrationTestConnectionError } from "@homarr/integrations";
import { logger } from "@homarr/log";
type FormIntegration = Integration & {
@@ -66,7 +66,7 @@ export const testConnectionAsync = async (
const { secrets: _, ...baseIntegration } = integration;
const integrationInstance = integrationCreator({
const integrationInstance = await createIntegrationAsync({
...baseIntegration,
decryptedSecrets,
});

View File

@@ -1,10 +1,10 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { asc, createId, eq, like, sql } from "@homarr/db";
import { asc, createId, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { validation } from "@homarr/validation";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -13,12 +13,7 @@ import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publ
export const searchEngineRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(searchEngines.name, `%${input.search.trim()}%`) : undefined;
const searchEngineCount = await ctx.db
.select({
count: sql<number>`count(*)`,
})
.from(searchEngines)
.where(whereQuery);
const searchEngineCount = await ctx.db.$count(searchEngines, whereQuery);
const dbSearachEngines = await ctx.db.query.searchEngines.findMany({
limit: input.pageSize,
@@ -28,7 +23,7 @@ export const searchEngineRouter = createTRPCRouter({
return {
items: dbSearachEngines,
totalCount: searchEngineCount[0]?.count ?? 0,
totalCount: searchEngineCount,
};
}),
getSelectable: protectedProcedure
@@ -139,14 +134,14 @@ export const searchEngineRouter = createTRPCRouter({
.unstable_concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
.input(validation.common.mediaRequestOptions)
.query(async ({ ctx, input }) => {
const integration = integrationCreator(ctx.integration);
const integration = await createIntegrationAsync(ctx.integration);
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.unstable_concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
.input(validation.common.requestMedia)
.mutation(async ({ ctx, input }) => {
const integration = integrationCreator(ctx.integration);
const integration = await createIntegrationAsync(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
create: permissionRequiredProcedure

View File

@@ -18,11 +18,13 @@ vi.mock("@homarr/common/server", async (importActual) => {
describe("testConnectionAsync should run test connection of integration", () => {
test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
@@ -58,11 +60,13 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
@@ -105,11 +109,13 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
@@ -152,11 +158,13 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
const integration = {
@@ -203,11 +211,13 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
const integration = {

View File

@@ -1,12 +1,12 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { controlsInputSchema } from "@homarr/integrations/types";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -65,7 +65,7 @@ export const dnsHoleRouter = createTRPCRouter({
enable: protectedProcedure
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.mutation(async ({ ctx: { integration } }) => {
const client = integrationCreator(integration);
const client = await createIntegrationAsync(integration);
await client.enableAsync();
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
@@ -76,10 +76,14 @@ export const dnsHoleRouter = createTRPCRouter({
}),
disable: protectedProcedure
.input(controlsInputSchema)
.input(
z.object({
duration: z.number().optional(),
}),
)
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.mutation(async ({ ctx: { integration }, input }) => {
const client = integrationCreator(integration);
const client = await createIntegrationAsync(integration);
await client.disableAsync(input.duration);
const innerHandler = dnsHoleRequestHandler.handler(integration, {});

View File

@@ -6,7 +6,7 @@ import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { downloadClientItemSchema, integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import type { IntegrationAction } from "../../middlewares/integration";
@@ -69,7 +69,7 @@ export const downloadsRouter = createTRPCRouter({
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseQueueAsync();
}),
);
@@ -80,7 +80,7 @@ export const downloadsRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseItemAsync(input.item);
}),
);
@@ -90,7 +90,7 @@ export const downloadsRouter = createTRPCRouter({
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeQueueAsync();
}),
);
@@ -101,7 +101,7 @@ export const downloadsRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeItemAsync(input.item);
}),
);
@@ -112,7 +112,7 @@ export const downloadsRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
}),
);

View File

@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import type { Indexer } from "@homarr/integrations/types";
import { logger } from "@homarr/log";
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
@@ -59,7 +59,7 @@ export const indexerManagerRouter = createTRPCRouter({
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const client = integrationCreator(integration);
const client = await createIntegrationAsync(integration);
await client.testAllAsync().catch((err) => {
logger.error("indexer-manager router - ", err);
throw new TRPCError({

View File

@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { integrationCreator, MediaRequestStatus } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequest } from "@homarr/integrations/types";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
@@ -30,14 +30,12 @@ export const mediaRequestsRouter = createTRPCRouter({
);
return results
.flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id })))
.sort(({ status: statusA }, { status: statusB }) => {
if (statusA === MediaRequestStatus.PendingApproval) {
return -1;
.sort((dataA, dataB) => {
if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
}
if (statusB === MediaRequestStatus.PendingApproval) {
return 1;
}
return 0;
return dataA.status - dataB.status;
});
}),
subscribeToLatestRequests: publicProcedure
@@ -96,7 +94,7 @@ export const mediaRequestsRouter = createTRPCRouter({
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
.input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
.mutation(async ({ ctx: { integration }, input }) => {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
if (input.answer === "approve") {

View File

@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
import type { IntegrationAction } from "../../middlewares/integration";
@@ -45,7 +45,7 @@ export const smartHomeRouter = createTRPCRouter({
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = integrationCreator(integration);
const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId);
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
@@ -57,7 +57,7 @@ export const smartHomeRouter = createTRPCRouter({
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = integrationCreator(integration);
const client = await createIntegrationAsync(integration);
await client.triggerAutomationAsync(input.automationId);
}),
});

View File

@@ -2,7 +2,7 @@
"name": "@homarr/auth",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",
@@ -25,6 +25,7 @@
"dependencies": {
"@auth/core": "^0.38.0",
"@auth/drizzle-adapter": "^1.8.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
@@ -48,7 +49,7 @@
"@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0",
"eslint": "^9.21.0",
"prettier": "^3.5.2",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
}
}

View File

@@ -1,6 +1,9 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import type { OIDCConfig } from "@auth/core/providers";
import type { Profile } from "@auth/core/types";
import { customFetch } from "next-auth";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { env } from "../../env";
import { createRedirectUri } from "../../redirect";
@@ -35,6 +38,10 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
provider: "oidc",
};
},
// The type for fetch is not identical, but for what we need it it's okay to not be an 1:1 match
// See documentation https://authjs.dev/guides/corporate-proxy?framework=next-js
// @ts-expect-error `undici` has a `duplex` option
[customFetch]: fetchWithTrustedCertificatesAsync,
});
export const extractProfileName = (profile: Profile) => {

View File

@@ -11,6 +11,7 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { createSignInEventHandler } from "../events";
vi.mock("next-auth", () => ({}));
vi.mock("../env", () => {
return {
env: {

View File

@@ -2,7 +2,7 @@
"name": "@homarr/boards",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
"./context": "./src/context.tsx",

View File

@@ -2,7 +2,7 @@
"name": "@homarr/certificates",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
"./server": "./src/server.ts"

View File

@@ -2,7 +2,7 @@
"name": "@homarr/cli",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"

View File

@@ -2,7 +2,7 @@
"name": "@homarr/common",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",

View File

@@ -2,7 +2,7 @@
"name": "@homarr/cron-job-runner",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"

View File

@@ -2,7 +2,7 @@
"name": "@homarr/cron-job-status",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",

View File

@@ -2,7 +2,7 @@
"name": "@homarr/cron-jobs-core",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",

View File

@@ -1,3 +1,4 @@
import { AxiosError } from "axios";
import cron from "node-cron";
import { Stopwatch } from "@homarr/common";
@@ -48,8 +49,15 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
}
await creatorOptions.onCallbackSuccess?.(name);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
creatorOptions.logger.logError(`Failed to run job '${name}': ${error}`);
// Log AxiosError in a less detailed way to prevent very long output
if (error instanceof AxiosError) {
creatorOptions.logger.logError(
`Failed to run job '${name}': [AxiosError] ${error.message} ${error.response?.status} ${error.response?.config.url}\n${error.stack}`,
);
} else {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
creatorOptions.logger.logError(`Failed to run job '${name}': ${error}`);
}
await creatorOptions.onCallbackError?.(name, error);
}
};

View File

@@ -2,7 +2,7 @@
"name": "@homarr/cron-jobs",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"

View File

@@ -0,0 +1 @@
ALTER TABLE `section` ADD `options` text DEFAULT ('{"json": {}}');

File diff suppressed because it is too large Load Diff

View File

@@ -218,6 +218,13 @@
"when": 1740256006328,
"tag": "0030_migrate_item_and_section_for_layouts",
"breakpoints": true
},
{
"idx": 31,
"version": "5",
"when": 1740784837957,
"tag": "0031_add_dynamic_section_options",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1 @@
ALTER TABLE `section` ADD `options` text DEFAULT '{"json": {}}';

File diff suppressed because it is too large Load Diff

View File

@@ -218,6 +218,13 @@
"when": 1740255968549,
"tag": "0030_migrate_item_and_section_for_layouts",
"breakpoints": true
},
{
"idx": 31,
"version": "6",
"when": 1740784849045,
"tag": "0031_add_dynamic_section_options",
"breakpoints": true
}
]
}

View File

@@ -2,7 +2,7 @@
"name": "@homarr/db",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",
@@ -44,7 +44,7 @@
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@mantine/core": "^7.17.0",
"@mantine/core": "^7.17.1",
"@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^10.18.0",
"better-sqlite3": "^11.8.1",
@@ -61,7 +61,7 @@
"@types/better-sqlite3": "7.6.12",
"dotenv-cli": "^8.0.0",
"eslint": "^9.21.0",
"prettier": "^3.5.2",
"prettier": "^3.5.3",
"tsx": "4.19.3",
"typescript": "^5.8.2"
}

View File

@@ -17,6 +17,12 @@ import {
varchar,
} from "drizzle-orm/mysql-core";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
emptySuperJSON,
} from "@homarr/definitions";
import type {
BackgroundImageAttachment,
BackgroundImageRepeat,
@@ -33,7 +39,6 @@ import type {
SupportedAuthProvider,
WidgetKind,
} from "@homarr/definitions";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
const customBlob = customType<{ data: Buffer }>({
dataType() {
@@ -388,6 +393,7 @@ export const sections = mysqlTable("section", {
xOffset: int(),
yOffset: int(),
name: text(),
options: text().default(emptySuperJSON),
});
export const sectionCollapseStates = mysqlTable(
@@ -414,8 +420,8 @@ export const items = mysqlTable("item", {
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
kind: text().$type<WidgetKind>().notNull(),
options: text().default('{"json": {}}').notNull(), // empty superjson object
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
options: text().default(emptySuperJSON).notNull(),
advancedOptions: text().default(emptySuperJSON).notNull(),
});
export const apps = mysqlTable("app", {
@@ -461,7 +467,7 @@ export const iconRepositories = mysqlTable("iconRepository", {
export const serverSettings = mysqlTable("serverSetting", {
settingKey: varchar({ length: 64 }).notNull().unique().primaryKey(),
value: text().default('{"json": {}}').notNull(), // empty superjson object
value: text().default(emptySuperJSON).notNull(),
});
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({

View File

@@ -5,7 +5,12 @@ import { relations, sql } from "drizzle-orm";
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
import { blob, index, int, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
emptySuperJSON,
} from "@homarr/definitions";
import type {
BackgroundImageAttachment,
BackgroundImageRepeat,
@@ -373,6 +378,7 @@ export const sections = sqliteTable("section", {
xOffset: int(),
yOffset: int(),
name: text(),
options: text().default(emptySuperJSON),
});
export const sectionCollapseStates = sqliteTable(
@@ -399,8 +405,8 @@ export const items = sqliteTable("item", {
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
kind: text().$type<WidgetKind>().notNull(),
options: text().default('{"json": {}}').notNull(), // empty superjson object
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
options: text().default(emptySuperJSON).notNull(),
advancedOptions: text().default(emptySuperJSON).notNull(),
});
export const apps = sqliteTable("app", {
@@ -446,7 +452,7 @@ export const iconRepositories = sqliteTable("iconRepository", {
export const serverSettings = sqliteTable("serverSetting", {
settingKey: text().notNull().unique().primaryKey(),
value: text().default('{"json": {}}').notNull(), // empty superjson object
value: text().default(emptySuperJSON).notNull(),
});
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({

View File

@@ -2,7 +2,7 @@
"name": "@homarr/definitions",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"

View File

@@ -88,6 +88,7 @@ export type HomarrDocumentationPath =
| "/docs/tags/integrations"
| "/docs/tags/interface"
| "/docs/tags/jellyserr"
| "/docs/tags/layout"
| "/docs/tags/ldap"
| "/docs/tags/links"
| "/docs/tags/lists"
@@ -110,6 +111,7 @@ export type HomarrDocumentationPath =
| "/docs/tags/proxmox"
| "/docs/tags/proxy"
| "/docs/tags/puid"
| "/docs/tags/responsive"
| "/docs/tags/roles"
| "/docs/tags/rss"
| "/docs/tags/search"

View File

@@ -0,0 +1 @@
export const emptySuperJSON = '{"json": {}}';

View File

@@ -11,3 +11,4 @@ export * from "./docs";
export * from "./cookie";
export * from "./search-engine";
export * from "./onboarding";
export * from "./emptysuperjson";

View File

@@ -85,6 +85,12 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
category: ["mediaService"],
},
emby: {
name: "Emby",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
category: ["mediaService"],
},
plex: {
name: "Plex",
secretKinds: [["apiKey"]],

View File

@@ -2,7 +2,7 @@
"name": "@homarr/docker",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",

View File

@@ -2,7 +2,7 @@
"name": "@homarr/env",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",

View File

@@ -2,7 +2,7 @@
"name": "@homarr/form",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",
@@ -26,7 +26,7 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.17.0",
"@mantine/form": "^7.17.1",
"zod": "^3.24.2"
},
"devDependencies": {

View File

@@ -2,7 +2,7 @@
"name": "@homarr/forms-collection",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"
@@ -29,7 +29,7 @@
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.17.0",
"@mantine/core": "^7.17.1",
"react": "19.0.0",
"zod": "^3.24.2"
},

View File

@@ -2,7 +2,7 @@
"name": "@homarr/icons",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",

View File

@@ -2,7 +2,7 @@
"name": "@homarr/integrations",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",

View File

@@ -10,6 +10,7 @@ import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration"
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import { EmbyIntegration } from "../emby/emby-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
@@ -20,13 +21,13 @@ import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import type { Integration, IntegrationInput } from "./integration";
export const integrationCreator = <TKind extends keyof typeof integrationCreators>(
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
integration: IntegrationInput & { kind: TKind },
) => {
if (!(integration.kind in integrationCreators)) {
@@ -35,15 +36,22 @@ export const integrationCreator = <TKind extends keyof typeof integrationCreator
);
}
return new integrationCreators[integration.kind](integration) as InstanceType<(typeof integrationCreators)[TKind]>;
const creator = integrationCreators[integration.kind];
// factories are an array, to differentiate in js between class constructors and functions
if (Array.isArray(creator)) {
return (await creator[0](integration)) as IntegrationInstanceOfKind<TKind>;
}
return new creator(integration) as IntegrationInstanceOfKind<TKind>;
};
export const integrationCreatorFromSecrets = <TKind extends keyof typeof integrationCreators>(
export const createIntegrationAsyncFromSecrets = <TKind extends keyof typeof integrationCreators>(
integration: Modify<DbIntegration, { kind: TKind }> & {
secrets: { kind: IntegrationSecretKind; value: `${string}.${string}` }[];
},
) => {
return integrationCreator({
return createIntegrationAsync({
...integration,
decryptedSecrets: integration.secrets.map((secret) => ({
...secret,
@@ -52,8 +60,11 @@ export const integrationCreatorFromSecrets = <TKind extends keyof typeof integra
});
};
type IntegrationInstance = new (integration: IntegrationInput) => Integration;
// factories are an array, to differentiate in js between class constructors and functions
export const integrationCreators = {
piHole: PiHoleIntegration,
piHole: [createPiHoleIntegrationAsync],
adGuardHome: AdGuardHomeIntegration,
homeAssistant: HomeAssistantIntegration,
jellyfin: JellyfinIntegration,
@@ -74,4 +85,13 @@ export const integrationCreators = {
dashDot: DashDotIntegration,
tdarr: TdarrIntegration,
proxmox: ProxmoxIntegration,
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;
emby: EmbyIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
[kind in TKind]: (typeof integrationCreators)[kind] extends [(input: IntegrationInput) => Promise<Integration>]
? Awaited<ReturnType<(typeof integrationCreators)[kind][0]>>
: (typeof integrationCreators)[kind] extends IntegrationInstance
? InstanceType<(typeof integrationCreators)[kind]>
: never;
}[TKind];

View File

@@ -0,0 +1,47 @@
import type { Response as UndiciResponse } from "undici";
import type { z } from "zod";
import type { IntegrationInput } from "./integration";
export class ParseError extends Error {
public readonly zodError: z.ZodError;
public readonly input: unknown;
constructor(dataName: string, zodError: z.ZodError, input?: unknown) {
super(`Failed to parse ${dataName}`);
this.zodError = zodError;
this.input = input;
}
}
export class ResponseError extends Error {
public readonly statusCode: number;
public readonly url: string;
public readonly content?: string;
constructor(response: Response | UndiciResponse, content: unknown) {
super("Response failed");
this.statusCode = response.status;
this.url = response.url;
try {
this.content = JSON.stringify(content);
} catch {
this.content = content as string;
}
}
}
export class IntegrationResponseError extends ResponseError {
public readonly integration: Pick<IntegrationInput, "id" | "name" | "url">;
constructor(integration: IntegrationInput, response: Response | UndiciResponse, content: unknown) {
super(response, content);
this.integration = {
id: integration.id,
name: integration.name,
url: integration.url,
};
}
}

View File

@@ -0,0 +1,40 @@
import superjson from "superjson";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { logger } from "@homarr/log";
import { createGetSetChannel } from "@homarr/redis";
const localLogger = logger.child({ module: "SessionStore" });
export const createSessionStore = <TValue>(integration: { id: string }) => {
const channelName = `session-store:${integration.id}`;
const channel = createGetSetChannel<`${string}.${string}`>(channelName);
return {
async getAsync() {
localLogger.debug("Getting session from store", { store: channelName });
const value = await channel.getAsync();
if (!value) return null;
try {
return superjson.parse<TValue>(decryptSecret(value));
} catch (error) {
localLogger.warn("Failed to load session", { store: channelName, error });
return null;
}
},
async setAsync(value: TValue) {
localLogger.debug("Updating session in store", { store: channelName });
try {
await channel.setAsync(encryptSecret(superjson.stringify(value)));
} catch (error) {
localLogger.error("Failed to save session", { store: channelName, error });
}
},
async clearAsync() {
localLogger.debug("Cleared session in store", { store: channelName });
await channel.removeAsync();
},
};
};
export type SessionStore<TValue> = ReturnType<typeof createSessionStore<TValue>>;

View File

@@ -0,0 +1,98 @@
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { Integration } from "../base/integration";
import type { StreamSession } from "../interfaces/media-server/session";
import { convertJellyfinType } from "../jellyfin/jellyfin-integration";
const sessionSchema = z.object({
NowPlayingItem: z
.object({
Type: z.nativeEnum(BaseItemKind).optional(),
SeriesName: z.string().nullish(),
Name: z.string().nullish(),
SeasonName: z.string().nullish(),
EpisodeTitle: z.string().nullish(),
Album: z.string().nullish(),
EpisodeCount: z.number().nullish(),
})
.optional(),
Id: z.string(),
Client: z.string().nullish(),
DeviceId: z.string().nullish(),
DeviceName: z.string().nullish(),
UserId: z.string().optional(),
UserName: z.string().nullish(),
});
export class EmbyIntegration extends Integration {
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"`;
public async testConnectionAsync(): Promise<void> {
const apiKey = super.getSecretValue("apiKey");
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetchWithTrustedCertificatesAsync(super.url("/emby/System/Ping"), {
headers: {
[EmbyIntegration.apiKeyHeader]: apiKey,
Authorization: EmbyIntegration.authorizationHeaderValue,
},
});
},
});
}
public async getCurrentSessionsAsync(): Promise<StreamSession[]> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(super.url("/emby/Sessions"), {
headers: {
[EmbyIntegration.apiKeyHeader]: apiKey,
Authorization: EmbyIntegration.authorizationHeaderValue,
},
});
if (!response.ok) {
throw new Error(`Emby server ${this.integration.id} returned a non successful status code: ${response.status}`);
}
const result = z.array(sessionSchema).safeParse(await response.json());
if (!result.success) {
throw new Error(`Emby server ${this.integration.id} returned an unexpected response: ${result.error.message}`);
}
return result.data
.filter((sessionInfo) => sessionInfo.UserId !== undefined)
.filter((sessionInfo) => sessionInfo.DeviceId !== EmbyIntegration.deviceId)
.map((sessionInfo): StreamSession => {
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) {
currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
};
}
return {
sessionId: `${sessionInfo.Id}`,
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
user: {
profilePictureUrl: super.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
userId: sessionInfo.UserId ?? "",
username: sessionInfo.UserName ?? "",
},
currentlyPlaying,
};
});
}
}

View File

@@ -13,7 +13,8 @@ export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5";
export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
@@ -36,5 +37,5 @@ export type { TdarrWorker } from "./interfaces/media-transcoding/workers";
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
// Helpers
export { integrationCreator, integrationCreatorFromSecrets } from "./base/creator";
export { createIntegrationAsync, createIntegrationAsyncFromSecrets } from "./base/creator";
export { IntegrationTestConnectionError } from "./base/test-connection-error";

View File

@@ -2,4 +2,6 @@ import type { DnsHoleSummary } from "./dns-hole-summary-types";
export interface DnsHoleSummaryIntegration {
getSummaryAsync(): Promise<DnsHoleSummary>;
enableAsync(): Promise<void>;
disableAsync(duration?: number): Promise<void>;
}

View File

@@ -1,4 +1,5 @@
import { Jellyfin } from "@jellyfin/sdk";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
@@ -34,31 +35,34 @@ export class JellyfinIntegration extends Integration {
throw new Error(`Jellyfin server ${this.url("/")} returned a non successful status code: ${sessions.status}`);
}
return sessions.data.map((sessionInfo): StreamSession => {
let nowPlaying: StreamSession["currentlyPlaying"] | null = null;
return sessions.data
.filter((sessionInfo) => sessionInfo.UserId !== undefined)
.filter((sessionInfo) => sessionInfo.DeviceId !== "homarr")
.map((sessionInfo): StreamSession => {
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) {
nowPlaying = {
type: "tv",
name: sessionInfo.NowPlayingItem.Name ?? "",
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
if (sessionInfo.NowPlayingItem) {
currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
};
}
return {
sessionId: `${sessionInfo.Id}`,
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
user: {
profilePictureUrl: this.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
userId: sessionInfo.UserId ?? "",
username: sessionInfo.UserName ?? "",
},
currentlyPlaying,
};
}
return {
sessionId: `${sessionInfo.Id}`,
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
user: {
profilePictureUrl: this.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
userId: sessionInfo.UserId ?? "",
username: sessionInfo.UserName ?? "",
},
currentlyPlaying: nowPlaying,
};
});
});
}
/**
@@ -81,3 +85,24 @@ export class JellyfinIntegration extends Integration {
return apiClient;
}
}
export const convertJellyfinType = (
kind: BaseItemKind | undefined,
): Exclude<StreamSession["currentlyPlaying"], null>["type"] => {
switch (kind) {
case BaseItemKind.Audio:
case BaseItemKind.MusicVideo:
return "audio";
case BaseItemKind.Episode:
case BaseItemKind.Video:
return "video";
case BaseItemKind.Movie:
return "movie";
case BaseItemKind.TvChannel:
case BaseItemKind.TvProgram:
case BaseItemKind.LiveTvChannel:
case BaseItemKind.LiveTvProgram:
default:
return "tv";
}
};

View File

@@ -1,53 +1,40 @@
import type { Response } from "undici";
import type { Headers, HeadersInit, Response as UndiciResponse } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { ResponseError } from "../base/error";
import type { IntegrationInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { SessionStore } from "../base/session-store";
import { createSessionStore } from "../base/session-store";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { HealthMonitoring } from "../types";
import { cpuTempSchema, fileSystemSchema, smartSchema, systemInformationSchema } from "./openmediavault-types";
const localLogger = logger.child({ module: "OpenMediaVaultIntegration" });
type SessionStoreValue =
| { type: "header"; sessionId: string }
| { type: "cookie"; loginToken: string; sessionId: string };
export class OpenMediaVaultIntegration extends Integration {
static extractSessionIdFromCookies(headers: Headers): string {
const cookies = headers.get("set-cookie") ?? "";
const sessionId = cookies
.split(";")
.find((cookie) => cookie.includes("X-OPENMEDIAVAULT-SESSIONID") || cookie.includes("OPENMEDIAVAULT-SESSIONID"));
private readonly sessionStore: SessionStore<SessionStoreValue>;
if (sessionId) {
return sessionId;
} else {
throw new Error("Session ID not found in cookies");
}
}
static extractLoginTokenFromCookies(headers: Headers): string {
const cookies = headers.get("set-cookie") ?? "";
const loginToken = cookies
.split(";")
.find((cookie) => cookie.includes("X-OPENMEDIAVAULT-LOGIN") || cookie.includes("OPENMEDIAVAULT-LOGIN"));
if (loginToken) {
return loginToken;
} else {
throw new Error("Login token not found in cookies");
}
constructor(integration: IntegrationInput) {
super(integration);
this.sessionStore = createSessionStore(integration);
}
public async getSystemInfoAsync(): Promise<HealthMonitoring> {
if (!this.headers) {
await this.authenticateAndConstructSessionInHeaderAsync();
}
const systemResponses = await this.makeOpenMediaVaultRPCCallAsync("system", "getInformation", {}, this.headers);
const fileSystemResponse = await this.makeOpenMediaVaultRPCCallAsync(
const systemResponses = await this.makeAuthenticatedRpcCallAsync("system", "getInformation");
const fileSystemResponse = await this.makeAuthenticatedRpcCallAsync(
"filesystemmgmt",
"enumerateMountedFilesystems",
{ includeroot: true },
this.headers,
);
const smartResponse = await this.makeOpenMediaVaultRPCCallAsync("smart", "enumerateDevices", {}, this.headers);
const cpuTempResponse = await this.makeOpenMediaVaultRPCCallAsync("cputemp", "get", {}, this.headers);
const smartResponse = await this.makeAuthenticatedRpcCallAsync("smart", "enumerateDevices");
const cpuTempResponse = await this.makeAuthenticatedRpcCallAsync("cputemp", "get");
const systemResult = systemInformationSchema.safeParse(await systemResponses.json());
const fileSystemResult = fileSystemSchema.safeParse(await fileSystemResponse.json());
@@ -98,30 +85,43 @@ export class OpenMediaVaultIntegration extends Integration {
}
public async testConnectionAsync(): Promise<void> {
const response = await this.makeOpenMediaVaultRPCCallAsync("session", "login", {
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
await this.getSessionAsync().catch((error) => {
if (error instanceof ResponseError) {
throw new IntegrationTestConnectionError("invalidCredentials");
}
});
if (!response.ok) {
throw new IntegrationTestConnectionError("invalidCredentials");
}
const result = await response.json();
if (typeof result !== "object" || result === null || !("response" in result)) {
throw new IntegrationTestConnectionError("invalidJson");
}
}
private async makeOpenMediaVaultRPCCallAsync(
private async makeAuthenticatedRpcCallAsync(
serviceName: string,
method: string,
params: Record<string, unknown>,
headers: Record<string, string> = {},
): Promise<Response> {
params: Record<string, unknown> = {},
): Promise<UndiciResponse> {
return await this.withAuthAsync(async (session) => {
const headers: HeadersInit =
session.type === "cookie"
? {
Cookie: `${session.loginToken};${session.sessionId}`,
}
: {
"X-OPENMEDIAVAULT-SESSIONID": session.sessionId,
};
return await this.makeRpcCallAsync(serviceName, method, params, headers);
});
}
private async makeRpcCallAsync(
serviceName: string,
method: string,
params: Record<string, unknown> = {},
headers: HeadersInit = {},
): Promise<UndiciResponse> {
return await fetchWithTrustedCertificatesAsync(this.url("/rpc.php"), {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "Homarr",
...headers,
},
body: JSON.stringify({
@@ -132,25 +132,79 @@ export class OpenMediaVaultIntegration extends Integration {
});
}
private headers: Record<string, string> | undefined = undefined;
/**
* Run the callback with the current session id
* @param callback
* @returns
*/
private async withAuthAsync(callback: (session: SessionStoreValue) => Promise<UndiciResponse>) {
const storedSession = await this.sessionStore.getAsync();
private async authenticateAndConstructSessionInHeaderAsync() {
const authResponse = await this.makeOpenMediaVaultRPCCallAsync("session", "login", {
if (storedSession) {
localLogger.debug("Using stored session for request", { integrationId: this.integration.id });
const response = await callback(storedSession);
if (response.status !== 401) {
return response;
}
localLogger.info("Session expired, getting new session", { integrationId: this.integration.id });
}
const session = await this.getSessionAsync();
await this.sessionStore.setAsync(session);
return await callback(session);
}
/**
* Get a session id from the openmediavault server
* @returns The session details
*/
private async getSessionAsync(): Promise<SessionStoreValue> {
const response = await this.makeRpcCallAsync("session", "login", {
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
});
const authResult = (await authResponse.json()) as Response;
const response = (authResult as { response?: { sessionid?: string } }).response;
let sessionId;
const headers: Record<string, string> = {};
if (response?.sessionid) {
sessionId = response.sessionid;
headers["X-OPENMEDIAVAULT-SESSIONID"] = sessionId;
const data = (await response.json()) as { response?: { sessionid?: string } };
if (data.response?.sessionid) {
return {
type: "header",
sessionId: data.response.sessionid,
};
} else {
sessionId = OpenMediaVaultIntegration.extractSessionIdFromCookies(authResponse.headers);
const loginToken = OpenMediaVaultIntegration.extractLoginTokenFromCookies(authResponse.headers);
headers.Cookie = `${loginToken};${sessionId}`;
const sessionId = OpenMediaVaultIntegration.extractSessionIdFromCookies(response.headers);
const loginToken = OpenMediaVaultIntegration.extractLoginTokenFromCookies(response.headers);
if (!sessionId || !loginToken) {
throw new ResponseError(
response,
`${JSON.stringify(data)} - sessionId=${"*".repeat(sessionId?.length ?? 0)} loginToken=${"*".repeat(loginToken?.length ?? 0)}`,
);
}
return {
type: "cookie",
loginToken,
sessionId,
};
}
this.headers = headers;
}
private static extractSessionIdFromCookies(headers: Headers): string | null {
const cookies = headers.getSetCookie();
const sessionId = cookies.find(
(cookie) => cookie.includes("X-OPENMEDIAVAULT-SESSIONID") || cookie.includes("OPENMEDIAVAULT-SESSIONID"),
);
return sessionId ?? null;
}
private static extractLoginTokenFromCookies(headers: Headers): string | null {
const cookies = headers.getSetCookie();
const loginToken = cookies.find(
(cookie) => cookie.includes("X-OPENMEDIAVAULT-LOGIN") || cookie.includes("OPENMEDIAVAULT-LOGIN"),
);
return loginToken ?? null;
}
}

View File

@@ -0,0 +1,22 @@
import { removeTrailingSlash } from "@homarr/common";
import type { IntegrationInput } from "../base/integration";
import { PiHoleIntegrationV5 } from "./v5/pi-hole-integration-v5";
import { PiHoleIntegrationV6 } from "./v6/pi-hole-integration-v6";
export const createPiHoleIntegrationAsync = async (input: IntegrationInput) => {
const baseUrl = removeTrailingSlash(input.url);
const url = new URL(`${baseUrl}/api/info/version`);
const response = await fetch(url);
/**
* In pi-hole 5 the api was at /admin/api.php, in pi-hole 6 it was moved to /api
* For the /api/info/version endpoint, the response is 404 in pi-hole 5
* and 401 in pi-hole 6
*/
if (response.status === 404) {
return new PiHoleIntegrationV5(input);
}
return new PiHoleIntegrationV6(input);
};

View File

@@ -1,12 +1,12 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types";
import { summaryResponseSchema } from "./pi-hole-types";
import { Integration } from "../../base/integration";
import { IntegrationTestConnectionError } from "../../base/test-connection-error";
import type { DnsHoleSummaryIntegration } from "../../interfaces/dns-hole-summary/dns-hole-summary-integration";
import type { DnsHoleSummary } from "../../interfaces/dns-hole-summary/dns-hole-summary-types";
import { summaryResponseSchema } from "./pi-hole-schemas-v5";
export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration {
export class PiHoleIntegrationV5 extends Integration implements DnsHoleSummaryIntegration {
public async getSummaryAsync(): Promise<DnsHoleSummary> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?summaryRaw", { auth: apiKey }));

View File

@@ -7,7 +7,3 @@ export const summaryResponseSchema = z.object({
dns_queries_today: z.number(),
ads_percentage_today: z.number(),
});
export const controlsInputSchema = z.object({
duration: z.number().optional(),
});

View File

@@ -0,0 +1,204 @@
import type { Response as UndiciResponse } from "undici";
import type { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { extractErrorMessage } from "@homarr/common";
import { logger } from "@homarr/log";
import { IntegrationResponseError, ParseError, ResponseError } from "../../base/error";
import type { IntegrationInput } from "../../base/integration";
import { Integration } from "../../base/integration";
import type { SessionStore } from "../../base/session-store";
import { createSessionStore } from "../../base/session-store";
import { IntegrationTestConnectionError } from "../../base/test-connection-error";
import type { DnsHoleSummaryIntegration } from "../../interfaces/dns-hole-summary/dns-hole-summary-integration";
import type { DnsHoleSummary } from "../../types";
import { dnsBlockingGetSchema, sessionResponseSchema, statsSummaryGetSchema } from "./pi-hole-schemas-v6";
const localLogger = logger.child({ module: "PiHoleIntegrationV6" });
export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIntegration {
private readonly sessionStore: SessionStore<string>;
constructor(integration: IntegrationInput) {
super(integration);
this.sessionStore = createSessionStore(integration);
}
public async getDnsBlockingStatusAsync(): Promise<z.infer<typeof dnsBlockingGetSchema>> {
const response = await this.withAuthAsync(async (sessionId) => {
return await fetchWithTrustedCertificatesAsync(this.url("/api/dns/blocking"), {
headers: {
sid: sessionId,
},
});
});
if (!response.ok) {
throw new IntegrationResponseError(this.integration, response, await response.json());
}
const result = dnsBlockingGetSchema.safeParse(await response.json());
if (!result.success) {
throw new ParseError("DNS blocking status", result.error, await response.json());
}
return result.data;
}
private async getStatsSummaryAsync(): Promise<z.infer<typeof statsSummaryGetSchema>> {
const response = await this.withAuthAsync(async (sessionId) => {
return await fetchWithTrustedCertificatesAsync(this.url("/api/stats/summary"), {
headers: {
sid: sessionId,
},
});
});
if (!response.ok) {
throw new IntegrationResponseError(this.integration, response, await response.json());
}
const data = await response.json();
const result = statsSummaryGetSchema.safeParse(data);
if (!result.success) {
throw new ParseError("stats summary", result.error, data);
}
return result.data;
}
public async getSummaryAsync(): Promise<DnsHoleSummary> {
const dnsStatsSummary = await this.getStatsSummaryAsync();
const dnsBlockingStatus = await this.getDnsBlockingStatusAsync();
return {
status: dnsBlockingStatus.blocking,
adsBlockedToday: dnsStatsSummary.queries.blocked,
adsBlockedTodayPercentage: dnsStatsSummary.queries.percent_blocked,
domainsBeingBlocked: dnsStatsSummary.gravity.domains_being_blocked,
dnsQueriesToday: dnsStatsSummary.queries.total,
};
}
public async testConnectionAsync(): Promise<void> {
try {
const sessionId = await this.getSessionAsync();
await this.clearSessionAsync(sessionId);
} catch (error: unknown) {
if (error instanceof ParseError) {
throw new IntegrationTestConnectionError("invalidJson");
}
if (error instanceof ResponseError && error.statusCode === 401) {
throw new IntegrationTestConnectionError("invalidCredentials");
}
throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error));
}
}
public async enableAsync(): Promise<void> {
const response = await this.withAuthAsync(async (sessionId) => {
return await fetchWithTrustedCertificatesAsync(this.url("/api/dns/blocking"), {
headers: {
sid: sessionId,
},
body: JSON.stringify({ blocking: true }),
method: "POST",
});
});
if (!response.ok) {
throw new IntegrationResponseError(this.integration, response, await response.json());
}
}
public async disableAsync(duration?: number): Promise<void> {
const response = await this.withAuthAsync(async (sessionId) => {
return await fetchWithTrustedCertificatesAsync(this.url("/api/dns/blocking"), {
headers: {
sid: sessionId,
},
body: JSON.stringify({ blocking: false, timer: duration }),
method: "POST",
});
});
if (!response.ok) {
throw new IntegrationResponseError(this.integration, response, await response.json());
}
}
/**
* Run the callback with the current session id
* @param callback
* @returns
*/
private async withAuthAsync(callback: (sessionId: string) => Promise<UndiciResponse>) {
const storedSession = await this.sessionStore.getAsync();
if (storedSession) {
localLogger.debug("Using stored session for request", { integrationId: this.integration.id });
const response = await callback(storedSession);
if (response.status !== 401) {
return response;
}
localLogger.info("Session expired, getting new session", { integrationId: this.integration.id });
}
const sessionId = await this.getSessionAsync();
await this.sessionStore.setAsync(sessionId);
const response = await callback(sessionId);
return response;
}
/**
* Get a session id from the Pi-hole server
* @returns The session id
*/
private async getSessionAsync(): Promise<string> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/auth"), {
method: "POST",
body: JSON.stringify({ password: apiKey }),
headers: {
"User-Agent": "Homarr",
},
});
const data = await response.json();
const result = sessionResponseSchema.safeParse(data);
if (!result.success) {
throw new ParseError("session response", result.error, data);
}
if (!result.data.session.sid) {
throw new IntegrationResponseError(this.integration, response, data);
}
localLogger.info("Received session id successfully", { integrationId: this.integration.id });
return result.data.session.sid;
}
/**
* Remove the session from the Pi-hole server
* @param sessionId The session id to remove
*/
private async clearSessionAsync(sessionId: string) {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/auth"), {
method: "DELETE",
headers: {
sid: sessionId,
},
});
if (!response.ok) {
localLogger.warn("Failed to clear session", { statusCode: response.status, content: await response.text() });
}
logger.debug("Cleared session successfully");
}
}

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
export const sessionResponseSchema = z.object({
session: z.object({
sid: z.string().nullable(),
message: z.string().nullable(),
}),
});
export const dnsBlockingGetSchema = z.object({
blocking: z.enum(["enabled", "disabled", "failed", "unknown"]).transform((value) => {
if (value === "failed") return undefined;
if (value === "unknown") return undefined;
return value;
}),
timer: z.number().nullable(),
});
export const statsSummaryGetSchema = z.object({
queries: z.object({
total: z.number(),
blocked: z.number(),
percent_blocked: z.number(),
}),
gravity: z.object({
domains_being_blocked: z.number(),
}),
});

View File

@@ -3,7 +3,6 @@ export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./interfaces/health-monitoring/healt-monitoring";
export * from "./interfaces/indexer-manager/indexer";
export * from "./interfaces/media-requests/media-request";
export * from "./pi-hole/pi-hole-types";
export * from "./base/searchable-integration";
export * from "./homeassistant/homeassistant-types";
export * from "./proxmox/proxmox-types";

View File

@@ -1,17 +1,18 @@
import type { StartedTestContainer } from "testcontainers";
import { GenericContainer, Wait } from "testcontainers";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { PiHoleIntegration } from "../src";
import { PiHoleIntegrationV5, PiHoleIntegrationV6 } from "../src";
import type { SessionStore } from "../src/base/session-store";
const DEFAULT_PASSWORD = "12341234";
const DEFAULT_API_KEY = "3b1434980677dcf53fa8c4a611db3b1f0f88478790097515c0abb539102778b9"; // Some hash generated from password
describe("Pi-hole integration", () => {
describe("Pi-hole v5 integration", () => {
test("getSummaryAsync should return summary from pi-hole", async () => {
// Arrange
const piholeContainer = await createPiHoleContainer(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegration(piholeContainer, DEFAULT_API_KEY);
const piholeContainer = await createPiHoleV5Container(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegrationV5(piholeContainer, DEFAULT_API_KEY);
// Act
const result = await piHoleIntegration.getSummaryAsync();
@@ -28,8 +29,8 @@ describe("Pi-hole integration", () => {
test("testConnectionAsync should not throw", async () => {
// Arrange
const piholeContainer = await createPiHoleContainer(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegration(piholeContainer, DEFAULT_API_KEY);
const piholeContainer = await createPiHoleV5Container(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegrationV5(piholeContainer, DEFAULT_API_KEY);
// Act
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
@@ -43,8 +44,8 @@ describe("Pi-hole integration", () => {
test("testConnectionAsync should throw with wrong credentials", async () => {
// Arrange
const piholeContainer = await createPiHoleContainer(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegration(piholeContainer, "wrong-api-key");
const piholeContainer = await createPiHoleV5Container(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegrationV5(piholeContainer, "wrong-api-key");
// Act
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
@@ -57,7 +58,118 @@ describe("Pi-hole integration", () => {
}, 20_000); // Timeout of 20 seconds
});
const createPiHoleContainer = (password: string) => {
vi.mock("../src/base/session-store", () => ({
createSessionStore: () =>
({
async getAsync() {
return await Promise.resolve(null);
},
async setAsync() {
return await Promise.resolve();
},
async clearAsync() {
return await Promise.resolve();
},
}) satisfies SessionStore<string>,
}));
describe("Pi-hole v6 integration", () => {
test("getSummaryAsync should return summary from pi-hole", async () => {
// Arrange
const piholeContainer = await createPiHoleV6Container(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegrationV6(piholeContainer, DEFAULT_PASSWORD);
// Act
const result = await piHoleIntegration.getSummaryAsync();
// Assert
expect(result.status).toBe("enabled");
expect(result.adsBlockedToday).toBe(0);
expect(result.adsBlockedTodayPercentage).toBe(0);
expect(result.dnsQueriesToday).toBe(0);
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
// Cleanup
await piholeContainer.stop();
}, 20_000); // Timeout of 20 seconds
test("enableAsync should enable pi-hole", async () => {
// Arrange
const piholeContainer = await createPiHoleV6Container(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegrationV6(piholeContainer, DEFAULT_PASSWORD);
// Disable pi-hole
await piholeContainer.exec(["pihole", "disable"]);
// Act
await piHoleIntegration.enableAsync();
// Assert
const status = await piHoleIntegration.getDnsBlockingStatusAsync();
expect(status.blocking).toContain("enabled");
}, 20_000); // Timeout of 20 seconds
test("disableAsync should disable pi-hole", async () => {
// Arrange
const piholeContainer = await createPiHoleV6Container(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegrationV6(piholeContainer, DEFAULT_PASSWORD);
// Act
await piHoleIntegration.disableAsync();
// Assert
const status = await piHoleIntegration.getDnsBlockingStatusAsync();
expect(status.blocking).toBe("disabled");
expect(status.timer).toBe(null);
}, 20_000); // Timeout of 20 seconds
test("disableAsync should disable pi-hole with timer", async () => {
// Arrange
const timer = 10 * 60; // 10 minutes
const piholeContainer = await createPiHoleV6Container(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegrationV6(piholeContainer, DEFAULT_PASSWORD);
// Act
await piHoleIntegration.disableAsync(timer);
// Assert
const status = await piHoleIntegration.getDnsBlockingStatusAsync();
expect(status.blocking).toBe("disabled");
expect(status.timer).toBeGreaterThan(timer - 10);
}, 20_000); // Timeout of 20 seconds
test("testConnectionAsync should not throw", async () => {
// Arrange
const piholeContainer = await createPiHoleV6Container(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegrationV6(piholeContainer, DEFAULT_PASSWORD);
// Act
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
// Assert
await expect(actAsync()).resolves.not.toThrow();
// Cleanup
await piholeContainer.stop();
}, 20_000); // Timeout of 20 seconds
test("testConnectionAsync should throw with wrong credentials", async () => {
// Arrange
const piholeContainer = await createPiHoleV6Container(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegrationV6(piholeContainer, "wrong-api-key");
// Act
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
// Assert
await expect(actAsync()).rejects.toThrow();
// Cleanup
await piholeContainer.stop();
}, 20_000); // Timeout of 20 seconds
});
const createPiHoleV5Container = (password: string) => {
return new GenericContainer("pihole/pihole:2024.07.0") // v5
.withEnvironment({
WEBPASSWORD: password,
@@ -66,8 +178,31 @@ const createPiHoleContainer = (password: string) => {
.withWaitStrategy(Wait.forLogMessage("Pi-hole Enabled"));
};
const createPiHoleIntegration = (container: StartedTestContainer, apiKey: string) => {
return new PiHoleIntegration({
const createPiHoleIntegrationV5 = (container: StartedTestContainer, apiKey: string) => {
return new PiHoleIntegrationV5({
id: "1",
decryptedSecrets: [
{
kind: "apiKey",
value: apiKey,
},
],
name: "Pi hole",
url: `http://${container.getHost()}:${container.getMappedPort(80)}`,
});
};
const createPiHoleV6Container = (password: string) => {
return new GenericContainer("pihole/pihole:latest")
.withEnvironment({
FTLCONF_webserver_api_password: password,
})
.withExposedPorts(80)
.withWaitStrategy(Wait.forHttp("/admin", 80));
};
const createPiHoleIntegrationV6 = (container: StartedTestContainer, apiKey: string) => {
return new PiHoleIntegrationV6({
id: "1",
decryptedSecrets: [
{

View File

@@ -2,7 +2,7 @@
"name": "@homarr/log",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -2,7 +2,7 @@
"name": "@homarr/modals-collection",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"
@@ -33,8 +33,8 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.17.0",
"@tabler/icons-react": "^3.30.0",
"@mantine/core": "^7.17.1",
"@tabler/icons-react": "^3.31.0",
"dayjs": "^1.11.13",
"next": "15.1.7",
"react": "19.0.0",

View File

@@ -1,4 +1,4 @@
import { Button, Group, Image, List, LoadingOverlay, Stack, Text, TextInput } from "@mantine/core";
import { Avatar, Button, Group, List, LoadingOverlay, Stack, Text, TextInput } from "@mantine/core";
import { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
@@ -60,25 +60,35 @@ export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(
<form onSubmit={form.onSubmit(handleSubmit)}>
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
<Stack>
<List>
<List spacing={"xs"}>
{innerProps.selectedContainers.map((container, index) => (
<List.Item
styles={{ itemWrapper: { width: "100%" }, itemLabel: { flex: 1 } }}
icon={<Image src={container.iconUrl} alt="container image" w={30} h={30} />}
icon={
<Avatar
variant="outline"
radius={container.iconUrl ? "sm" : "md"}
size={30}
styles={{ image: { objectFit: "contain" } }}
src={container.iconUrl}
>
{container.name.at(0)?.toUpperCase()}
</Avatar>
}
key={container.id}
>
<Group justify="space-between">
<Text>{container.name}</Text>
<Group justify="space-between" wrap={"nowrap"}>
<Text lineClamp={1}>{container.name}</Text>
<TextInput {...form.getInputProps(`containerUrls.${index}`)} />
</Group>
</List.Item>
))}
</List>
<Group justify="end">
<Button onClick={actions.closeModal} variant="light">
<Button onClick={actions.closeModal} variant="light" px={"xl"}>
{t("common.action.cancel")}
</Button>
<Button disabled={!form.isValid()} type="submit">
<Button type="submit" px={"xl"}>
{t("common.action.add")}
</Button>
</Group>
@@ -89,4 +99,5 @@ export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(
defaultTitle(t) {
return t("docker.action.addToHomarr.modal.title");
},
size: "lg",
});

View File

@@ -2,7 +2,7 @@
"name": "@homarr/modals",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"
@@ -24,8 +24,8 @@
"dependencies": {
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.17.0",
"@mantine/hooks": "^7.17.0",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"react": "19.0.0"
},
"devDependencies": {

View File

@@ -2,7 +2,7 @@
"name": "@homarr/notifications",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",
@@ -24,8 +24,8 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.17.0",
"@tabler/icons-react": "^3.30.0"
"@mantine/notifications": "^7.17.1",
"@tabler/icons-react": "^3.31.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -2,7 +2,7 @@
"name": "@homarr/old-import",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",
@@ -37,8 +37,8 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.17.0",
"@mantine/hooks": "^7.17.0",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"adm-zip": "0.5.16",
"next": "15.1.7",
"react": "19.0.0",

View File

@@ -14,12 +14,40 @@ export const mapBoard = (preparedBoard: PreparedBoard): InferInsertModel<typeof
backgroundImageUrl: preparedBoard.config.settings.customization.backgroundImageUrl,
backgroundImageRepeat: preparedBoard.config.settings.customization.backgroundImageRepeat,
backgroundImageSize: preparedBoard.config.settings.customization.backgroundImageSize,
faviconImageUrl: preparedBoard.config.settings.customization.faviconUrl,
faviconImageUrl: mapFavicon(preparedBoard.config.settings.customization.faviconUrl),
isPublic: preparedBoard.config.settings.access.allowGuests,
logoImageUrl: preparedBoard.config.settings.customization.logoImageUrl,
logoImageUrl: mapLogo(preparedBoard.config.settings.customization.logoImageUrl),
pageTitle: preparedBoard.config.settings.customization.pageTitle,
metaTitle: preparedBoard.config.settings.customization.metaTitle,
opacity: preparedBoard.config.settings.customization.appOpacity,
primaryColor: mapColor(preparedBoard.config.settings.customization.colors.primary, "#fa5252"),
secondaryColor: mapColor(preparedBoard.config.settings.customization.colors.secondary, "#fd7e14"),
});
const defaultOldmarrLogoPath = "/imgs/logo/logo.png";
const mapLogo = (logo: string | null | undefined) => {
if (!logo) {
return null;
}
if (logo.trim() === defaultOldmarrLogoPath) {
return null; // We fallback to default logo when null
}
return logo;
};
const defaultOldmarrFaviconPath = "/imgs/favicon/favicon-squared.png";
const mapFavicon = (favicon: string | null | undefined) => {
if (!favicon) {
return null;
}
if (favicon.trim() === defaultOldmarrFaviconPath) {
return null; // We fallback to default favicon when null
}
return favicon;
};

View File

@@ -2,7 +2,7 @@
"name": "@homarr/old-schema",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"

View File

@@ -2,7 +2,7 @@
"name": "@homarr/ping",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"

View File

@@ -2,7 +2,7 @@
"name": "@homarr/redis",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"

View File

@@ -9,6 +9,7 @@ export {
createChannelWithLatestAndEvents,
handshakeAsync,
createSubPubChannel,
createGetSetChannel,
} from "./lib/channel";
export const exampleChannel = createSubPubChannel<{ message: string }>("example");

View File

@@ -94,6 +94,36 @@ export const createListChannel = <TItem>(name: string) => {
};
};
/**
* Creates a new redis channel for getting and setting data
* @param name name of channel
*/
export const createGetSetChannel = <TData>(name: string) => {
return {
/**
* Get data from the channel
* @returns data or null if not found
*/
getAsync: async () => {
const data = await getSetClient.get(name);
return data ? superjson.parse<TData>(data) : null;
},
/**
* Set data in the channel
* @param data data to be stored in the channel
*/
setAsync: async (data: TData) => {
await getSetClient.set(name, superjson.stringify(data));
},
/**
* Remove data from the channel
*/
removeAsync: async () => {
await getSetClient.del(name);
},
};
};
/**
* Creates a new cache channel.
* @param name name of the channel

View File

@@ -2,7 +2,7 @@
"name": "@homarr/request-handler",
"version": "0.1.0",
"private": true,
"license": "MIT",
"license": "Apache-2.0",
"type": "module",
"exports": {
"./*": "./src/*.ts"

View File

@@ -1,7 +1,7 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import type { CalendarEvent, RadarrReleaseType } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
@@ -12,7 +12,7 @@ export const calendarMonthRequestHandler = createCachedIntegrationRequestHandler
{ year: number; month: number; releaseType: RadarrReleaseType[] }
>({
async requestAsync(integration, input) {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
const startDate = dayjs().year(input.year).month(input.month).startOf("month");
const endDate = startDate.clone().endOf("month");
return await integrationInstance.getCalendarEventsAsync(startDate.toDate(), endDate.toDate());

View File

@@ -1,7 +1,7 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
@@ -12,7 +12,7 @@ export const dnsHoleRequestHandler = createCachedIntegrationRequestHandler<
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
},
cacheDuration: dayjs.duration(5, "seconds"),

View File

@@ -2,7 +2,7 @@ import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
@@ -12,7 +12,7 @@ export const downloadClientRequestHandler = createCachedIntegrationRequestHandle
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getClientJobsAndStatusAsync();
},
cacheDuration: dayjs.duration(5, "seconds"),

View File

@@ -1,7 +1,7 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import type { HealthMonitoring, ProxmoxClusterInfo } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
@@ -12,7 +12,7 @@ export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSystemInfoAsync();
},
cacheDuration: dayjs.duration(5, "seconds"),
@@ -25,7 +25,7 @@ export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler<
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getClusterInfoAsync();
},
cacheDuration: dayjs.duration(5, "seconds"),

View File

@@ -1,7 +1,7 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import type { Indexer } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
@@ -12,7 +12,7 @@ export const indexerManagerRequestHandler = createCachedIntegrationRequestHandle
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getIndexersAsync();
},
cacheDuration: dayjs.duration(5, "minutes"),

View File

@@ -1,7 +1,7 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequest } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
@@ -12,7 +12,7 @@ export const mediaRequestListRequestHandler = createCachedIntegrationRequestHand
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getRequestsAsync();
},
cacheDuration: dayjs.duration(1, "minute"),

View File

@@ -1,7 +1,7 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequestStats } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
@@ -12,7 +12,7 @@ export const mediaRequestStatsRequestHandler = createCachedIntegrationRequestHan
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
return {
stats: await integrationInstance.getStatsAsync(),
users: await integrationInstance.getUsersAsync(),

View File

@@ -2,7 +2,7 @@ import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
@@ -12,7 +12,7 @@ export const mediaServerRequestHandler = createCachedIntegrationRequestHandler<
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getCurrentSessionsAsync();
},
cacheDuration: dayjs.duration(5, "seconds"),

View File

@@ -1,7 +1,7 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
@@ -14,7 +14,7 @@ export const mediaTranscodingRequestHandler = createCachedIntegrationRequestHand
queryKey: "mediaTranscoding",
cacheDuration: dayjs.duration(5, "minutes"),
async requestAsync(integration, input) {
const integrationInstance = integrationCreator(integration);
const integrationInstance = await createIntegrationAsync(integration);
return {
queue: await integrationInstance.getQueueAsync(input.pageOffset, input.pageSize),
workers: await integrationInstance.getWorkersAsync(),

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