mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-27 08:50:56 +01:00
chore(release): automatic release v1.9.0
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,6 +9,9 @@ export class DynamicSectionMockBuilder {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
borderColor: "",
|
||||
},
|
||||
layouts: [],
|
||||
...section,
|
||||
} satisfies DynamicSection;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -16,6 +16,9 @@ export const addDynamicSectionCallback = () => (board: Board) => {
|
||||
const newSection = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
borderColor: "",
|
||||
},
|
||||
layouts: createDynamicSectionLayouts(board, firstSection),
|
||||
} satisfies DynamicSection;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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")}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/analytics",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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, {});
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
|
||||
|
||||
import { createSignInEventHandler } from "../events";
|
||||
|
||||
vi.mock("next-auth", () => ({}));
|
||||
vi.mock("../env", () => {
|
||||
return {
|
||||
env: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/cli",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/common",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `section` ADD `options` text DEFAULT ('{"json": {}}');
|
||||
2020
packages/db/migrations/mysql/meta/0031_snapshot.json
Normal file
2020
packages/db/migrations/mysql/meta/0031_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `section` ADD `options` text DEFAULT '{"json": {}}';
|
||||
1940
packages/db/migrations/sqlite/meta/0031_snapshot.json
Normal file
1940
packages/db/migrations/sqlite/meta/0031_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/definitions",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
|
||||
@@ -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"
|
||||
|
||||
1
packages/definitions/src/emptysuperjson.ts
Normal file
1
packages/definitions/src/emptysuperjson.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const emptySuperJSON = '{"json": {}}';
|
||||
@@ -11,3 +11,4 @@ export * from "./docs";
|
||||
export * from "./cookie";
|
||||
export * from "./search-engine";
|
||||
export * from "./onboarding";
|
||||
export * from "./emptysuperjson";
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/docker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
|
||||
2
packages/env/package.json
vendored
2
packages/env/package.json
vendored
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/env",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/icons",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/integrations",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
|
||||
@@ -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];
|
||||
|
||||
47
packages/integrations/src/base/error.ts
Normal file
47
packages/integrations/src/base/error.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
40
packages/integrations/src/base/session-store.ts
Normal file
40
packages/integrations/src/base/session-store.ts
Normal 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>>;
|
||||
98
packages/integrations/src/emby/emby-integration.ts
Normal file
98
packages/integrations/src/emby/emby-integration.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 }));
|
||||
@@ -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(),
|
||||
});
|
||||
204
packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts
Normal file
204
packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
28
packages/integrations/src/pi-hole/v6/pi-hole-schemas-v6.ts
Normal file
28
packages/integrations/src/pi-hole/v6/pi-hole-schemas-v6.ts
Normal 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(),
|
||||
}),
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/ping",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/redis",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
createChannelWithLatestAndEvents,
|
||||
handshakeAsync,
|
||||
createSubPubChannel,
|
||||
createGetSetChannel,
|
||||
} from "./lib/channel";
|
||||
|
||||
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user