chore(release): automatic release v1.9.0
This commit is contained in:
@@ -48,17 +48,17 @@
|
|||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"@mantine/colors-generator": "^7.17.0",
|
"@mantine/colors-generator": "^7.17.1",
|
||||||
"@mantine/core": "^7.17.0",
|
"@mantine/core": "^7.17.1",
|
||||||
"@mantine/dropzone": "^7.17.0",
|
"@mantine/dropzone": "^7.17.1",
|
||||||
"@mantine/hooks": "^7.17.0",
|
"@mantine/hooks": "^7.17.1",
|
||||||
"@mantine/modals": "^7.17.0",
|
"@mantine/modals": "^7.17.1",
|
||||||
"@mantine/tiptap": "^7.17.0",
|
"@mantine/tiptap": "^7.17.1",
|
||||||
"@million/lint": "1.0.14",
|
"@million/lint": "1.0.14",
|
||||||
"@tabler/icons-react": "^3.30.0",
|
"@tabler/icons-react": "^3.31.0",
|
||||||
"@tanstack/react-query": "^5.66.11",
|
"@tanstack/react-query": "^5.67.1",
|
||||||
"@tanstack/react-query-devtools": "^5.66.11",
|
"@tanstack/react-query-devtools": "^5.67.1",
|
||||||
"@tanstack/react-query-next-experimental": "^5.66.11",
|
"@tanstack/react-query-next-experimental": "^5.67.1",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "3.1.1",
|
"@types/chroma-js": "3.1.1",
|
||||||
"@types/node": "^22.13.5",
|
"@types/node": "^22.13.9",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "19.0.10",
|
"@types/react": "19.0.10",
|
||||||
"@types/react-dom": "19.0.4",
|
"@types/react-dom": "19.0.4",
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"eslint": "^9.21.0",
|
"eslint": "^9.21.0",
|
||||||
"node-loader": "^2.1.0",
|
"node-loader": "^2.1.0",
|
||||||
"prettier": "^3.5.2",
|
"prettier": "^3.5.3",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export default async function AppsPage(props: AppsPageProps) {
|
|||||||
</Stack>
|
</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)} />
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
|||||||
</TableTbody>
|
</TableTbody>
|
||||||
</Table>
|
</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)} />
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
|||||||
</Stack>
|
</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)} />
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -35,7 +35,13 @@ const createColumns = (
|
|||||||
Cell({ renderedCellValue, row }) {
|
Cell({ renderedCellValue, row }) {
|
||||||
return (
|
return (
|
||||||
<Group gap="xs">
|
<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()}
|
{row.original.name.at(0)?.toUpperCase()}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text>{renderedCellValue}</Text>
|
<Text>{renderedCellValue}</Text>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export class DynamicSectionMockBuilder {
|
|||||||
this.section = {
|
this.section = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
kind: "dynamic",
|
kind: "dynamic",
|
||||||
|
options: {
|
||||||
|
borderColor: "",
|
||||||
|
},
|
||||||
layouts: [],
|
layouts: [],
|
||||||
...section,
|
...section,
|
||||||
} satisfies DynamicSection;
|
} satisfies DynamicSection;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface Props {
|
|||||||
export const BoardDynamicSection = ({ section }: Props) => {
|
export const BoardDynamicSection = ({ section }: Props) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const currentLayoutId = useCurrentLayout();
|
const currentLayoutId = useCurrentLayout();
|
||||||
|
const options = section.options;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="grid-stack-item-content">
|
<Box className="grid-stack-item-content">
|
||||||
<Card
|
<Card
|
||||||
@@ -25,6 +27,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
|||||||
root: {
|
root: {
|
||||||
"--opacity": board.opacity / 100,
|
"--opacity": board.opacity / 100,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
"--border-color": options.borderColor !== "" ? options.borderColor : undefined,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
radius={board.itemRadius}
|
radius={board.itemRadius}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export const addDynamicSectionCallback = () => (board: Board) => {
|
|||||||
const newSection = {
|
const newSection = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
kind: "dynamic",
|
kind: "dynamic",
|
||||||
|
options: {
|
||||||
|
borderColor: "",
|
||||||
|
},
|
||||||
layouts: createDynamicSectionLayouts(board, firstSection),
|
layouts: createDynamicSectionLayouts(board, firstSection),
|
||||||
} satisfies DynamicSection;
|
} satisfies DynamicSection;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||||
|
import type { dynamicSectionOptionsSchema } from "@homarr/validation";
|
||||||
|
|
||||||
import { addDynamicSectionCallback } from "./actions/add-dynamic-section";
|
import { addDynamicSectionCallback } from "./actions/add-dynamic-section";
|
||||||
import type { RemoveDynamicSectionInput } from "./actions/remove-dynamic-section";
|
import type { RemoveDynamicSectionInput } from "./actions/remove-dynamic-section";
|
||||||
import { removeDynamicSectionCallback } from "./actions/remove-dynamic-section";
|
import { removeDynamicSectionCallback } from "./actions/remove-dynamic-section";
|
||||||
|
|
||||||
|
interface UpdateDynamicOptions {
|
||||||
|
itemId: string;
|
||||||
|
newOptions: z.infer<typeof dynamicSectionOptionsSchema>;
|
||||||
|
}
|
||||||
|
|
||||||
export const useDynamicSectionActions = () => {
|
export const useDynamicSectionActions = () => {
|
||||||
const { updateBoard } = useUpdateBoard();
|
const { updateBoard } = useUpdateBoard();
|
||||||
|
|
||||||
@@ -13,6 +20,16 @@ export const useDynamicSectionActions = () => {
|
|||||||
updateBoard(addDynamicSectionCallback());
|
updateBoard(addDynamicSectionCallback());
|
||||||
}, [updateBoard]);
|
}, [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(
|
const removeDynamicSection = useCallback(
|
||||||
(input: RemoveDynamicSectionInput) => {
|
(input: RemoveDynamicSectionInput) => {
|
||||||
updateBoard(removeDynamicSectionCallback(input));
|
updateBoard(removeDynamicSectionCallback(input));
|
||||||
@@ -22,6 +39,7 @@ export const useDynamicSectionActions = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
addDynamicSection,
|
addDynamicSection,
|
||||||
|
updateDynamicSection,
|
||||||
removeDynamicSection,
|
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 { 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 { 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 { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { useDynamicSectionActions } from "./dynamic-actions";
|
import { useDynamicSectionActions } from "./dynamic-actions";
|
||||||
|
import { DynamicSectionEditModal } from "./dynamic-edit-modal";
|
||||||
|
|
||||||
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionItem }) => {
|
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionItem }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tDynamic = useScopedI18n("section.dynamic");
|
const tDynamic = useScopedI18n("section.dynamic");
|
||||||
const { removeDynamicSection } = useDynamicSectionActions();
|
const tItem = useScopedI18n("item");
|
||||||
|
const { openModal } = useModalAction(DynamicSectionEditModal);
|
||||||
|
const { updateDynamicSection, removeDynamicSection } = useDynamicSectionActions();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
|
|
||||||
if (!isEditMode) return null;
|
if (!isEditMode) return null;
|
||||||
|
|
||||||
|
const openEditModal = () => {
|
||||||
|
openModal({
|
||||||
|
value: section.options,
|
||||||
|
onSuccessfulEdit: (options) => {
|
||||||
|
updateDynamicSection({
|
||||||
|
itemId: section.id,
|
||||||
|
newOptions: options,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const openRemoveModal = () => {
|
const openRemoveModal = () => {
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: tDynamic("remove.title"),
|
title: tDynamic("remove.title"),
|
||||||
@@ -35,6 +50,11 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown miw={128}>
|
<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.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||||
{tDynamic("action.remove")}
|
{tDynamic("action.remove")}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/tasks",
|
"name": "@homarr/tasks",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
@@ -44,10 +44,10 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node": "^22.13.5",
|
"@types/node": "^22.13.9",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"eslint": "^9.21.0",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.5.2",
|
"prettier": "^3.5.3",
|
||||||
"tsx": "4.19.3",
|
"tsx": "4.19.3",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/websocket",
|
"name": "@homarr/websocket",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/main.ts",
|
"main": "./src/main.ts",
|
||||||
"types": "./src/main.ts",
|
"types": "./src/main.ts",
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.18.0",
|
||||||
"eslint": "^9.21.0",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.5.2",
|
"prettier": "^3.5.3",
|
||||||
"typescript": "^5.8.2"
|
"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",
|
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"prettier": "^3.5.2",
|
"prettier": "^3.5.3",
|
||||||
"semantic-release": "^24.2.3",
|
"semantic-release": "^24.2.3",
|
||||||
"testcontainers": "^10.18.0",
|
"testcontainers": "^10.18.0",
|
||||||
"turbo": "^2.4.4",
|
"turbo": "^2.4.4",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/analytics",
|
"name": "@homarr/analytics",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts"
|
".": "./index.ts"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const sendWidgetDataAsync = async (umamiInstance: Umami, analyticsSettings: type
|
|||||||
if (!analyticsSettings.enableWidgetData) {
|
if (!analyticsSettings.enableWidgetData) {
|
||||||
return;
|
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", {
|
const response = await umamiInstance.track("server-widget-data", {
|
||||||
countWidgets: widgetCount,
|
countWidgets: widgetCount,
|
||||||
@@ -52,7 +52,7 @@ const sendUserDataAsync = async (umamiInstance: Umami, analyticsSettings: typeof
|
|||||||
if (!analyticsSettings.enableUserData) {
|
if (!analyticsSettings.enableUserData) {
|
||||||
return;
|
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", {
|
const response = await umamiInstance.track("server-user-data", {
|
||||||
countUsers: userCount,
|
countUsers: userCount,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/api",
|
"name": "@homarr/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.21.0",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.5.2",
|
"prettier": "^3.5.3",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
|||||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||||
import type { DeviceType } from "@homarr/common/server";
|
import type { DeviceType } from "@homarr/common/server";
|
||||||
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
|
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 { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
|
||||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +27,13 @@ import {
|
|||||||
users,
|
users,
|
||||||
} from "@homarr/db/schema";
|
} from "@homarr/db/schema";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
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 { importOldmarrAsync } from "@homarr/old-import";
|
||||||
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
||||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
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);
|
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||||
}),
|
}),
|
||||||
getBoardByName: publicProcedure.input(validation.board.byName).query(async ({ input, ctx }) => {
|
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");
|
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
||||||
|
|
||||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||||
@@ -736,6 +742,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
kind: section.kind,
|
kind: section.kind,
|
||||||
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
|
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
|
||||||
xOffset: section.kind === "dynamic" ? null : 0,
|
xOffset: section.kind === "dynamic" ? null : 0,
|
||||||
|
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
|
||||||
name: "name" in section ? section.name : null,
|
name: "name" in section ? section.name : null,
|
||||||
boardId: dbBoard.id,
|
boardId: dbBoard.id,
|
||||||
})),
|
})),
|
||||||
@@ -861,6 +868,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
.set({
|
.set({
|
||||||
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
|
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
|
||||||
xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : 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,
|
name: prev?.kind === "category" && "name" in section ? section.name : null,
|
||||||
})
|
})
|
||||||
.where(eq(schema.sections.id, section.id));
|
.where(eq(schema.sections.id, section.id));
|
||||||
@@ -934,6 +942,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
kind: section.kind,
|
kind: section.kind,
|
||||||
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
|
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
|
||||||
xOffset: section.kind === "dynamic" ? null : 0,
|
xOffset: section.kind === "dynamic" ? null : 0,
|
||||||
|
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
|
||||||
name: "name" in section ? section.name : null,
|
name: "name" in section ? section.name : null,
|
||||||
boardId: dbBoard.id,
|
boardId: dbBoard.id,
|
||||||
})),
|
})),
|
||||||
@@ -1069,6 +1078,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
.set({
|
.set({
|
||||||
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
|
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
|
||||||
xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : 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,
|
name: prev?.kind === "category" && "name" in section ? section.name : null,
|
||||||
})
|
})
|
||||||
.where(eq(sections.id, section.id))
|
.where(eq(sections.id, section.id))
|
||||||
@@ -1561,6 +1571,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
|||||||
...section,
|
...section,
|
||||||
xOffset: section.xOffset,
|
xOffset: section.xOffset,
|
||||||
yOffset: section.yOffset,
|
yOffset: section.yOffset,
|
||||||
|
options: superjson.parse(section.options ?? emptySuperJSON),
|
||||||
layouts: section.layouts.map((layout) => ({
|
layouts: section.layouts.map((layout) => ({
|
||||||
xOffset: layout.xOffset,
|
xOffset: layout.xOffset,
|
||||||
yOffset: layout.yOffset,
|
yOffset: layout.yOffset,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { Database } from "@homarr/db";
|
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 { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
|
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
|
||||||
import { everyoneGroup } from "@homarr/definitions";
|
import { everyoneGroup } from "@homarr/definitions";
|
||||||
@@ -42,12 +42,7 @@ export const groupRouter = createTRPCRouter({
|
|||||||
.input(validation.common.paginated)
|
.input(validation.common.paginated)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
|
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
|
||||||
const groupCount = await ctx.db
|
const groupCount = await ctx.db.$count(groups, whereQuery);
|
||||||
.select({
|
|
||||||
count: sql<number>`count(*)`,
|
|
||||||
})
|
|
||||||
.from(groups)
|
|
||||||
.where(whereQuery);
|
|
||||||
|
|
||||||
const dbGroups = await ctx.db.query.groups.findMany({
|
const dbGroups = await ctx.db.query.groups.findMany({
|
||||||
with: {
|
with: {
|
||||||
@@ -74,7 +69,7 @@ export const groupRouter = createTRPCRouter({
|
|||||||
...group,
|
...group,
|
||||||
members: group.members.map((member) => member.user),
|
members: group.members.map((member) => member.user),
|
||||||
})),
|
})),
|
||||||
totalCount: groupCount[0]?.count ?? 0,
|
totalCount: groupCount,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
getById: permissionRequiredProcedure
|
getById: permissionRequiredProcedure
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { AnySQLiteTable } from "drizzle-orm/sqlite-core";
|
|||||||
|
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { count } from "@homarr/db";
|
|
||||||
import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema";
|
import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema";
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||||
@@ -28,5 +27,5 @@ const getCountForTableAsync = async (db: Database, table: AnySQLiteTable, canVie
|
|||||||
return 0;
|
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 { icons } from "@homarr/db/schema";
|
||||||
import { validation } from "@homarr/validation";
|
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,
|
integrationKinds,
|
||||||
integrationSecretKindObject,
|
integrationSecretKindObject,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
@@ -465,7 +465,7 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
.unstable_concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("search")))
|
.unstable_concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("search")))
|
||||||
.input(z.object({ integrationId: z.string(), query: z.string() }))
|
.input(z.object({ integrationId: z.string(), query: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const integrationInstance = integrationCreator(ctx.integration);
|
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||||
return await integrationInstance.searchAsync(encodeURI(input.query));
|
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 { Integration } from "@homarr/db/schema";
|
||||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import { getAllSecretKindOptions } 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";
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
type FormIntegration = Integration & {
|
type FormIntegration = Integration & {
|
||||||
@@ -66,7 +66,7 @@ export const testConnectionAsync = async (
|
|||||||
|
|
||||||
const { secrets: _, ...baseIntegration } = integration;
|
const { secrets: _, ...baseIntegration } = integration;
|
||||||
|
|
||||||
const integrationInstance = integrationCreator({
|
const integrationInstance = await createIntegrationAsync({
|
||||||
...baseIntegration,
|
...baseIntegration,
|
||||||
decryptedSecrets,
|
decryptedSecrets,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
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 { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import { searchEngines, users } from "@homarr/db/schema";
|
import { searchEngines, users } from "@homarr/db/schema";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
@@ -13,12 +13,7 @@ import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publ
|
|||||||
export const searchEngineRouter = createTRPCRouter({
|
export const searchEngineRouter = createTRPCRouter({
|
||||||
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
|
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
|
||||||
const whereQuery = input.search ? like(searchEngines.name, `%${input.search.trim()}%`) : undefined;
|
const whereQuery = input.search ? like(searchEngines.name, `%${input.search.trim()}%`) : undefined;
|
||||||
const searchEngineCount = await ctx.db
|
const searchEngineCount = await ctx.db.$count(searchEngines, whereQuery);
|
||||||
.select({
|
|
||||||
count: sql<number>`count(*)`,
|
|
||||||
})
|
|
||||||
.from(searchEngines)
|
|
||||||
.where(whereQuery);
|
|
||||||
|
|
||||||
const dbSearachEngines = await ctx.db.query.searchEngines.findMany({
|
const dbSearachEngines = await ctx.db.query.searchEngines.findMany({
|
||||||
limit: input.pageSize,
|
limit: input.pageSize,
|
||||||
@@ -28,7 +23,7 @@ export const searchEngineRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
items: dbSearachEngines,
|
items: dbSearachEngines,
|
||||||
totalCount: searchEngineCount[0]?.count ?? 0,
|
totalCount: searchEngineCount,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
getSelectable: protectedProcedure
|
getSelectable: protectedProcedure
|
||||||
@@ -139,14 +134,14 @@ export const searchEngineRouter = createTRPCRouter({
|
|||||||
.unstable_concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
|
.unstable_concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
|
||||||
.input(validation.common.mediaRequestOptions)
|
.input(validation.common.mediaRequestOptions)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const integration = integrationCreator(ctx.integration);
|
const integration = await createIntegrationAsync(ctx.integration);
|
||||||
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
|
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
|
||||||
}),
|
}),
|
||||||
requestMedia: protectedProcedure
|
requestMedia: protectedProcedure
|
||||||
.unstable_concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
|
.unstable_concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
|
||||||
.input(validation.common.requestMedia)
|
.input(validation.common.requestMedia)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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);
|
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||||
}),
|
}),
|
||||||
create: permissionRequiredProcedure
|
create: permissionRequiredProcedure
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ vi.mock("@homarr/common/server", async (importActual) => {
|
|||||||
describe("testConnectionAsync should run test connection of integration", () => {
|
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 () => {
|
test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
|
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||||
factorySpy.mockReturnValue({
|
factorySpy.mockReturnValue(
|
||||||
testConnectionAsync: async () => await Promise.resolve(),
|
Promise.resolve({
|
||||||
} as homarrIntegrations.PiHoleIntegration);
|
testConnectionAsync: async () => await Promise.resolve(),
|
||||||
|
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||||
|
);
|
||||||
optionsSpy.mockReturnValue([["apiKey"]]);
|
optionsSpy.mockReturnValue([["apiKey"]]);
|
||||||
|
|
||||||
const integration = {
|
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 () => {
|
test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
|
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||||
factorySpy.mockReturnValue({
|
factorySpy.mockReturnValue(
|
||||||
testConnectionAsync: async () => await Promise.resolve(),
|
Promise.resolve({
|
||||||
} as homarrIntegrations.PiHoleIntegration);
|
testConnectionAsync: async () => await Promise.resolve(),
|
||||||
|
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||||
|
);
|
||||||
optionsSpy.mockReturnValue([["apiKey"]]);
|
optionsSpy.mockReturnValue([["apiKey"]]);
|
||||||
|
|
||||||
const integration = {
|
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 () => {
|
test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
|
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||||
factorySpy.mockReturnValue({
|
factorySpy.mockReturnValue(
|
||||||
testConnectionAsync: async () => await Promise.resolve(),
|
Promise.resolve({
|
||||||
} as homarrIntegrations.PiHoleIntegration);
|
testConnectionAsync: async () => await Promise.resolve(),
|
||||||
|
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||||
|
);
|
||||||
optionsSpy.mockReturnValue([["apiKey"]]);
|
optionsSpy.mockReturnValue([["apiKey"]]);
|
||||||
|
|
||||||
const integration = {
|
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 () => {
|
test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
|
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||||
factorySpy.mockReturnValue({
|
factorySpy.mockReturnValue(
|
||||||
testConnectionAsync: async () => await Promise.resolve(),
|
Promise.resolve({
|
||||||
} as homarrIntegrations.PiHoleIntegration);
|
testConnectionAsync: async () => await Promise.resolve(),
|
||||||
|
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||||
|
);
|
||||||
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
|
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
|
||||||
|
|
||||||
const integration = {
|
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 () => {
|
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
|
// Arrange
|
||||||
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
|
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||||
factorySpy.mockReturnValue({
|
factorySpy.mockReturnValue(
|
||||||
testConnectionAsync: async () => await Promise.resolve(),
|
Promise.resolve({
|
||||||
} as homarrIntegrations.PiHoleIntegration);
|
testConnectionAsync: async () => await Promise.resolve(),
|
||||||
|
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||||
|
);
|
||||||
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
|
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
|
||||||
|
|
||||||
const integration = {
|
const integration = {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { observable } from "@trpc/server/observable";
|
import { observable } from "@trpc/server/observable";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { Modify } from "@homarr/common/types";
|
import type { Modify } from "@homarr/common/types";
|
||||||
import type { Integration } from "@homarr/db/schema";
|
import type { Integration } from "@homarr/db/schema";
|
||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
import { getIntegrationKindsByCategory } 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 type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||||
import { controlsInputSchema } from "@homarr/integrations/types";
|
|
||||||
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
@@ -65,7 +65,7 @@ export const dnsHoleRouter = createTRPCRouter({
|
|||||||
enable: protectedProcedure
|
enable: protectedProcedure
|
||||||
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
|
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
|
||||||
.mutation(async ({ ctx: { integration } }) => {
|
.mutation(async ({ ctx: { integration } }) => {
|
||||||
const client = integrationCreator(integration);
|
const client = await createIntegrationAsync(integration);
|
||||||
await client.enableAsync();
|
await client.enableAsync();
|
||||||
|
|
||||||
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
|
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
|
||||||
@@ -76,10 +76,14 @@ export const dnsHoleRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
disable: protectedProcedure
|
disable: protectedProcedure
|
||||||
.input(controlsInputSchema)
|
.input(
|
||||||
|
z.object({
|
||||||
|
duration: z.number().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
|
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
|
||||||
.mutation(async ({ ctx: { integration }, input }) => {
|
.mutation(async ({ ctx: { integration }, input }) => {
|
||||||
const client = integrationCreator(integration);
|
const client = await createIntegrationAsync(integration);
|
||||||
await client.disableAsync(input.duration);
|
await client.disableAsync(input.duration);
|
||||||
|
|
||||||
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
|
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Integration } from "@homarr/db/schema";
|
|||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
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 { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
|
||||||
|
|
||||||
import type { IntegrationAction } from "../../middlewares/integration";
|
import type { IntegrationAction } from "../../middlewares/integration";
|
||||||
@@ -69,7 +69,7 @@ export const downloadsRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx }) => {
|
.mutation(async ({ ctx }) => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
await integrationInstance.pauseQueueAsync();
|
await integrationInstance.pauseQueueAsync();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -80,7 +80,7 @@ export const downloadsRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
await integrationInstance.pauseItemAsync(input.item);
|
await integrationInstance.pauseItemAsync(input.item);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -90,7 +90,7 @@ export const downloadsRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx }) => {
|
.mutation(async ({ ctx }) => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
await integrationInstance.resumeQueueAsync();
|
await integrationInstance.resumeQueueAsync();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -101,7 +101,7 @@ export const downloadsRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
await integrationInstance.resumeItemAsync(input.item);
|
await integrationInstance.resumeItemAsync(input.item);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -112,7 +112,7 @@ export const downloadsRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
|
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { observable } from "@trpc/server/observable";
|
import { observable } from "@trpc/server/observable";
|
||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import type { Indexer } from "@homarr/integrations/types";
|
import type { Indexer } from "@homarr/integrations/types";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
|
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
|
||||||
@@ -59,7 +59,7 @@ export const indexerManagerRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx }) => {
|
.mutation(async ({ ctx }) => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const client = integrationCreator(integration);
|
const client = await createIntegrationAsync(integration);
|
||||||
await client.testAllAsync().catch((err) => {
|
await client.testAllAsync().catch((err) => {
|
||||||
logger.error("indexer-manager router - ", err);
|
logger.error("indexer-manager router - ", err);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import { integrationCreator, MediaRequestStatus } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import type { MediaRequest } from "@homarr/integrations/types";
|
import type { MediaRequest } from "@homarr/integrations/types";
|
||||||
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
||||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||||
@@ -30,14 +30,12 @@ export const mediaRequestsRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
return results
|
return results
|
||||||
.flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id })))
|
.flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id })))
|
||||||
.sort(({ status: statusA }, { status: statusB }) => {
|
.sort((dataA, dataB) => {
|
||||||
if (statusA === MediaRequestStatus.PendingApproval) {
|
if (dataA.status === dataB.status) {
|
||||||
return -1;
|
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
|
||||||
}
|
}
|
||||||
if (statusB === MediaRequestStatus.PendingApproval) {
|
|
||||||
return 1;
|
return dataA.status - dataB.status;
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
subscribeToLatestRequests: publicProcedure
|
subscribeToLatestRequests: publicProcedure
|
||||||
@@ -96,7 +94,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
|||||||
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
|
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||||
.input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
|
.input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
|
||||||
.mutation(async ({ ctx: { integration }, input }) => {
|
.mutation(async ({ ctx: { integration }, input }) => {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
|
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
|
||||||
|
|
||||||
if (input.answer === "approve") {
|
if (input.answer === "approve") {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
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 { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
|
||||||
|
|
||||||
import type { IntegrationAction } from "../../middlewares/integration";
|
import type { IntegrationAction } from "../../middlewares/integration";
|
||||||
@@ -45,7 +45,7 @@ export const smartHomeRouter = createTRPCRouter({
|
|||||||
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
|
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
|
||||||
.input(z.object({ entityId: z.string() }))
|
.input(z.object({ entityId: z.string() }))
|
||||||
.mutation(async ({ ctx: { integration }, input }) => {
|
.mutation(async ({ ctx: { integration }, input }) => {
|
||||||
const client = integrationCreator(integration);
|
const client = await createIntegrationAsync(integration);
|
||||||
const success = await client.triggerToggleAsync(input.entityId);
|
const success = await client.triggerToggleAsync(input.entityId);
|
||||||
|
|
||||||
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
||||||
@@ -57,7 +57,7 @@ export const smartHomeRouter = createTRPCRouter({
|
|||||||
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
|
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
|
||||||
.input(z.object({ automationId: z.string() }))
|
.input(z.object({ automationId: z.string() }))
|
||||||
.mutation(async ({ ctx: { integration }, input }) => {
|
.mutation(async ({ ctx: { integration }, input }) => {
|
||||||
const client = integrationCreator(integration);
|
const client = await createIntegrationAsync(integration);
|
||||||
await client.triggerAutomationAsync(input.automationId);
|
await client.triggerAutomationAsync(input.automationId);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/auth",
|
"name": "@homarr/auth",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.38.0",
|
"@auth/core": "^0.38.0",
|
||||||
"@auth/drizzle-adapter": "^1.8.0",
|
"@auth/drizzle-adapter": "^1.8.0",
|
||||||
|
"@homarr/certificates": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/cookies": "0.9.0",
|
"@types/cookies": "0.9.0",
|
||||||
"eslint": "^9.21.0",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.5.2",
|
"prettier": "^3.5.3",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||||
import type { OIDCConfig } from "@auth/core/providers";
|
import type { OIDCConfig } from "@auth/core/providers";
|
||||||
import type { Profile } from "@auth/core/types";
|
import type { Profile } from "@auth/core/types";
|
||||||
|
import { customFetch } from "next-auth";
|
||||||
|
|
||||||
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||||
|
|
||||||
import { env } from "../../env";
|
import { env } from "../../env";
|
||||||
import { createRedirectUri } from "../../redirect";
|
import { createRedirectUri } from "../../redirect";
|
||||||
@@ -35,6 +38,10 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
|
|||||||
provider: "oidc",
|
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) => {
|
export const extractProfileName = (profile: Profile) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
|
|||||||
|
|
||||||
import { createSignInEventHandler } from "../events";
|
import { createSignInEventHandler } from "../events";
|
||||||
|
|
||||||
|
vi.mock("next-auth", () => ({}));
|
||||||
vi.mock("../env", () => {
|
vi.mock("../env", () => {
|
||||||
return {
|
return {
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/boards",
|
"name": "@homarr/boards",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./context": "./src/context.tsx",
|
"./context": "./src/context.tsx",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/certificates",
|
"name": "@homarr/certificates",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./server": "./src/server.ts"
|
"./server": "./src/server.ts"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/cli",
|
"name": "@homarr/cli",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts"
|
".": "./index.ts"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/common",
|
"name": "@homarr/common",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/cron-job-runner",
|
"name": "@homarr/cron-job-runner",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts"
|
".": "./index.ts"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/cron-job-status",
|
"name": "@homarr/cron-job-status",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/cron-jobs-core",
|
"name": "@homarr/cron-jobs-core",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AxiosError } from "axios";
|
||||||
import cron from "node-cron";
|
import cron from "node-cron";
|
||||||
|
|
||||||
import { Stopwatch } from "@homarr/common";
|
import { Stopwatch } from "@homarr/common";
|
||||||
@@ -48,8 +49,15 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
|||||||
}
|
}
|
||||||
await creatorOptions.onCallbackSuccess?.(name);
|
await creatorOptions.onCallbackSuccess?.(name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
// Log AxiosError in a less detailed way to prevent very long output
|
||||||
creatorOptions.logger.logError(`Failed to run job '${name}': ${error}`);
|
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);
|
await creatorOptions.onCallbackError?.(name, error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/cron-jobs",
|
"name": "@homarr/cron-jobs",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts"
|
".": "./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,
|
"when": 1740256006328,
|
||||||
"tag": "0030_migrate_item_and_section_for_layouts",
|
"tag": "0030_migrate_item_and_section_for_layouts",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1740255968549,
|
||||||
"tag": "0030_migrate_item_and_section_for_layouts",
|
"tag": "0030_migrate_item_and_section_for_layouts",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 31,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1740784849045,
|
||||||
|
"tag": "0031_add_dynamic_section_options",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/db",
|
"name": "@homarr/db",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"@homarr/env": "workspace:^0.1.0",
|
"@homarr/env": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "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",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@testcontainers/mysql": "^10.18.0",
|
"@testcontainers/mysql": "^10.18.0",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
"@types/better-sqlite3": "7.6.12",
|
"@types/better-sqlite3": "7.6.12",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"eslint": "^9.21.0",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.5.2",
|
"prettier": "^3.5.3",
|
||||||
"tsx": "4.19.3",
|
"tsx": "4.19.3",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ import {
|
|||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/mysql-core";
|
} from "drizzle-orm/mysql-core";
|
||||||
|
|
||||||
|
import {
|
||||||
|
backgroundImageAttachments,
|
||||||
|
backgroundImageRepeats,
|
||||||
|
backgroundImageSizes,
|
||||||
|
emptySuperJSON,
|
||||||
|
} from "@homarr/definitions";
|
||||||
import type {
|
import type {
|
||||||
BackgroundImageAttachment,
|
BackgroundImageAttachment,
|
||||||
BackgroundImageRepeat,
|
BackgroundImageRepeat,
|
||||||
@@ -33,7 +39,6 @@ import type {
|
|||||||
SupportedAuthProvider,
|
SupportedAuthProvider,
|
||||||
WidgetKind,
|
WidgetKind,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
|
||||||
|
|
||||||
const customBlob = customType<{ data: Buffer }>({
|
const customBlob = customType<{ data: Buffer }>({
|
||||||
dataType() {
|
dataType() {
|
||||||
@@ -388,6 +393,7 @@ export const sections = mysqlTable("section", {
|
|||||||
xOffset: int(),
|
xOffset: int(),
|
||||||
yOffset: int(),
|
yOffset: int(),
|
||||||
name: text(),
|
name: text(),
|
||||||
|
options: text().default(emptySuperJSON),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sectionCollapseStates = mysqlTable(
|
export const sectionCollapseStates = mysqlTable(
|
||||||
@@ -414,8 +420,8 @@ export const items = mysqlTable("item", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => boards.id, { onDelete: "cascade" }),
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
kind: text().$type<WidgetKind>().notNull(),
|
kind: text().$type<WidgetKind>().notNull(),
|
||||||
options: text().default('{"json": {}}').notNull(), // empty superjson object
|
options: text().default(emptySuperJSON).notNull(),
|
||||||
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
|
advancedOptions: text().default(emptySuperJSON).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apps = mysqlTable("app", {
|
export const apps = mysqlTable("app", {
|
||||||
@@ -461,7 +467,7 @@ export const iconRepositories = mysqlTable("iconRepository", {
|
|||||||
|
|
||||||
export const serverSettings = mysqlTable("serverSetting", {
|
export const serverSettings = mysqlTable("serverSetting", {
|
||||||
settingKey: varchar({ length: 64 }).notNull().unique().primaryKey(),
|
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 }) => ({
|
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 type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||||
import { blob, index, int, primaryKey, sqliteTable, text } 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 {
|
import type {
|
||||||
BackgroundImageAttachment,
|
BackgroundImageAttachment,
|
||||||
BackgroundImageRepeat,
|
BackgroundImageRepeat,
|
||||||
@@ -373,6 +378,7 @@ export const sections = sqliteTable("section", {
|
|||||||
xOffset: int(),
|
xOffset: int(),
|
||||||
yOffset: int(),
|
yOffset: int(),
|
||||||
name: text(),
|
name: text(),
|
||||||
|
options: text().default(emptySuperJSON),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sectionCollapseStates = sqliteTable(
|
export const sectionCollapseStates = sqliteTable(
|
||||||
@@ -399,8 +405,8 @@ export const items = sqliteTable("item", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => boards.id, { onDelete: "cascade" }),
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
kind: text().$type<WidgetKind>().notNull(),
|
kind: text().$type<WidgetKind>().notNull(),
|
||||||
options: text().default('{"json": {}}').notNull(), // empty superjson object
|
options: text().default(emptySuperJSON).notNull(),
|
||||||
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
|
advancedOptions: text().default(emptySuperJSON).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apps = sqliteTable("app", {
|
export const apps = sqliteTable("app", {
|
||||||
@@ -446,7 +452,7 @@ export const iconRepositories = sqliteTable("iconRepository", {
|
|||||||
|
|
||||||
export const serverSettings = sqliteTable("serverSetting", {
|
export const serverSettings = sqliteTable("serverSetting", {
|
||||||
settingKey: text().notNull().unique().primaryKey(),
|
settingKey: text().notNull().unique().primaryKey(),
|
||||||
value: text().default('{"json": {}}').notNull(), // empty superjson object
|
value: text().default(emptySuperJSON).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
|
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/definitions",
|
"name": "@homarr/definitions",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts"
|
".": "./index.ts"
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/tags/integrations"
|
| "/docs/tags/integrations"
|
||||||
| "/docs/tags/interface"
|
| "/docs/tags/interface"
|
||||||
| "/docs/tags/jellyserr"
|
| "/docs/tags/jellyserr"
|
||||||
|
| "/docs/tags/layout"
|
||||||
| "/docs/tags/ldap"
|
| "/docs/tags/ldap"
|
||||||
| "/docs/tags/links"
|
| "/docs/tags/links"
|
||||||
| "/docs/tags/lists"
|
| "/docs/tags/lists"
|
||||||
@@ -110,6 +111,7 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/tags/proxmox"
|
| "/docs/tags/proxmox"
|
||||||
| "/docs/tags/proxy"
|
| "/docs/tags/proxy"
|
||||||
| "/docs/tags/puid"
|
| "/docs/tags/puid"
|
||||||
|
| "/docs/tags/responsive"
|
||||||
| "/docs/tags/roles"
|
| "/docs/tags/roles"
|
||||||
| "/docs/tags/rss"
|
| "/docs/tags/rss"
|
||||||
| "/docs/tags/search"
|
| "/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 "./cookie";
|
||||||
export * from "./search-engine";
|
export * from "./search-engine";
|
||||||
export * from "./onboarding";
|
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",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
|
||||||
category: ["mediaService"],
|
category: ["mediaService"],
|
||||||
},
|
},
|
||||||
|
emby: {
|
||||||
|
name: "Emby",
|
||||||
|
secretKinds: [["apiKey"]],
|
||||||
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
|
||||||
|
category: ["mediaService"],
|
||||||
|
},
|
||||||
plex: {
|
plex: {
|
||||||
name: "Plex",
|
name: "Plex",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/docker",
|
"name": "@homarr/docker",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
|
|||||||
2
packages/env/package.json
vendored
2
packages/env/package.json
vendored
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/env",
|
"name": "@homarr/env",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/form",
|
"name": "@homarr/form",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/form": "^7.17.0",
|
"@mantine/form": "^7.17.1",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/forms-collection",
|
"name": "@homarr/forms-collection",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts"
|
".": "./index.ts"
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"@homarr/notifications": "workspace:^0.1.0",
|
"@homarr/notifications": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.17.0",
|
"@mantine/core": "^7.17.1",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/icons",
|
"name": "@homarr/icons",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/integrations",
|
"name": "@homarr/integrations",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration"
|
|||||||
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
|
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
|
||||||
import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
|
import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
|
||||||
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
|
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
|
||||||
|
import { EmbyIntegration } from "../emby/emby-integration";
|
||||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||||
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
||||||
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-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 { TdarrIntegration } from "../media-transcoding/tdarr-integration";
|
||||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||||
import { OverseerrIntegration } from "../overseerr/overseerr-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 { PlexIntegration } from "../plex/plex-integration";
|
||||||
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
||||||
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
||||||
import type { Integration, IntegrationInput } from "./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 },
|
integration: IntegrationInput & { kind: TKind },
|
||||||
) => {
|
) => {
|
||||||
if (!(integration.kind in integrationCreators)) {
|
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 }> & {
|
integration: Modify<DbIntegration, { kind: TKind }> & {
|
||||||
secrets: { kind: IntegrationSecretKind; value: `${string}.${string}` }[];
|
secrets: { kind: IntegrationSecretKind; value: `${string}.${string}` }[];
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
return integrationCreator({
|
return createIntegrationAsync({
|
||||||
...integration,
|
...integration,
|
||||||
decryptedSecrets: integration.secrets.map((secret) => ({
|
decryptedSecrets: integration.secrets.map((secret) => ({
|
||||||
...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 = {
|
export const integrationCreators = {
|
||||||
piHole: PiHoleIntegration,
|
piHole: [createPiHoleIntegrationAsync],
|
||||||
adGuardHome: AdGuardHomeIntegration,
|
adGuardHome: AdGuardHomeIntegration,
|
||||||
homeAssistant: HomeAssistantIntegration,
|
homeAssistant: HomeAssistantIntegration,
|
||||||
jellyfin: JellyfinIntegration,
|
jellyfin: JellyfinIntegration,
|
||||||
@@ -74,4 +85,13 @@ export const integrationCreators = {
|
|||||||
dashDot: DashDotIntegration,
|
dashDot: DashDotIntegration,
|
||||||
tdarr: TdarrIntegration,
|
tdarr: TdarrIntegration,
|
||||||
proxmox: ProxmoxIntegration,
|
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 { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||||
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
|
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
|
||||||
export { OverseerrIntegration } from "./overseerr/overseerr-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 { PlexIntegration } from "./plex/plex-integration";
|
||||||
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
||||||
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-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";
|
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
export { integrationCreator, integrationCreatorFromSecrets } from "./base/creator";
|
export { createIntegrationAsync, createIntegrationAsyncFromSecrets } from "./base/creator";
|
||||||
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
||||||
|
|||||||
@@ -2,4 +2,6 @@ import type { DnsHoleSummary } from "./dns-hole-summary-types";
|
|||||||
|
|
||||||
export interface DnsHoleSummaryIntegration {
|
export interface DnsHoleSummaryIntegration {
|
||||||
getSummaryAsync(): Promise<DnsHoleSummary>;
|
getSummaryAsync(): Promise<DnsHoleSummary>;
|
||||||
|
enableAsync(): Promise<void>;
|
||||||
|
disableAsync(duration?: number): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Jellyfin } from "@jellyfin/sdk";
|
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 { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-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}`);
|
throw new Error(`Jellyfin server ${this.url("/")} returned a non successful status code: ${sessions.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessions.data.map((sessionInfo): StreamSession => {
|
return sessions.data
|
||||||
let nowPlaying: StreamSession["currentlyPlaying"] | null = null;
|
.filter((sessionInfo) => sessionInfo.UserId !== undefined)
|
||||||
|
.filter((sessionInfo) => sessionInfo.DeviceId !== "homarr")
|
||||||
|
.map((sessionInfo): StreamSession => {
|
||||||
|
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
||||||
|
|
||||||
if (sessionInfo.NowPlayingItem) {
|
if (sessionInfo.NowPlayingItem) {
|
||||||
nowPlaying = {
|
currentlyPlaying = {
|
||||||
type: "tv",
|
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||||
name: sessionInfo.NowPlayingItem.Name ?? "",
|
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
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;
|
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 { 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 { 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 { IntegrationTestConnectionError } from "../base/test-connection-error";
|
||||||
import type { HealthMonitoring } from "../types";
|
import type { HealthMonitoring } from "../types";
|
||||||
import { cpuTempSchema, fileSystemSchema, smartSchema, systemInformationSchema } from "./openmediavault-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 {
|
export class OpenMediaVaultIntegration extends Integration {
|
||||||
static extractSessionIdFromCookies(headers: Headers): string {
|
private readonly sessionStore: SessionStore<SessionStoreValue>;
|
||||||
const cookies = headers.get("set-cookie") ?? "";
|
|
||||||
const sessionId = cookies
|
|
||||||
.split(";")
|
|
||||||
.find((cookie) => cookie.includes("X-OPENMEDIAVAULT-SESSIONID") || cookie.includes("OPENMEDIAVAULT-SESSIONID"));
|
|
||||||
|
|
||||||
if (sessionId) {
|
constructor(integration: IntegrationInput) {
|
||||||
return sessionId;
|
super(integration);
|
||||||
} else {
|
this.sessionStore = createSessionStore(integration);
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSystemInfoAsync(): Promise<HealthMonitoring> {
|
public async getSystemInfoAsync(): Promise<HealthMonitoring> {
|
||||||
if (!this.headers) {
|
const systemResponses = await this.makeAuthenticatedRpcCallAsync("system", "getInformation");
|
||||||
await this.authenticateAndConstructSessionInHeaderAsync();
|
const fileSystemResponse = await this.makeAuthenticatedRpcCallAsync(
|
||||||
}
|
|
||||||
|
|
||||||
const systemResponses = await this.makeOpenMediaVaultRPCCallAsync("system", "getInformation", {}, this.headers);
|
|
||||||
const fileSystemResponse = await this.makeOpenMediaVaultRPCCallAsync(
|
|
||||||
"filesystemmgmt",
|
"filesystemmgmt",
|
||||||
"enumerateMountedFilesystems",
|
"enumerateMountedFilesystems",
|
||||||
{ includeroot: true },
|
{ includeroot: true },
|
||||||
this.headers,
|
|
||||||
);
|
);
|
||||||
const smartResponse = await this.makeOpenMediaVaultRPCCallAsync("smart", "enumerateDevices", {}, this.headers);
|
const smartResponse = await this.makeAuthenticatedRpcCallAsync("smart", "enumerateDevices");
|
||||||
const cpuTempResponse = await this.makeOpenMediaVaultRPCCallAsync("cputemp", "get", {}, this.headers);
|
const cpuTempResponse = await this.makeAuthenticatedRpcCallAsync("cputemp", "get");
|
||||||
|
|
||||||
const systemResult = systemInformationSchema.safeParse(await systemResponses.json());
|
const systemResult = systemInformationSchema.safeParse(await systemResponses.json());
|
||||||
const fileSystemResult = fileSystemSchema.safeParse(await fileSystemResponse.json());
|
const fileSystemResult = fileSystemSchema.safeParse(await fileSystemResponse.json());
|
||||||
@@ -98,30 +85,43 @@ export class OpenMediaVaultIntegration extends Integration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async testConnectionAsync(): Promise<void> {
|
public async testConnectionAsync(): Promise<void> {
|
||||||
const response = await this.makeOpenMediaVaultRPCCallAsync("session", "login", {
|
await this.getSessionAsync().catch((error) => {
|
||||||
username: this.getSecretValue("username"),
|
if (error instanceof ResponseError) {
|
||||||
password: this.getSecretValue("password"),
|
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,
|
serviceName: string,
|
||||||
method: string,
|
method: string,
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown> = {},
|
||||||
headers: Record<string, string> = {},
|
): Promise<UndiciResponse> {
|
||||||
): Promise<Response> {
|
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"), {
|
return await fetchWithTrustedCertificatesAsync(this.url("/rpc.php"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "Homarr",
|
||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
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() {
|
if (storedSession) {
|
||||||
const authResponse = await this.makeOpenMediaVaultRPCCallAsync("session", "login", {
|
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"),
|
username: this.getSecretValue("username"),
|
||||||
password: this.getSecretValue("password"),
|
password: this.getSecretValue("password"),
|
||||||
});
|
});
|
||||||
const authResult = (await authResponse.json()) as Response;
|
|
||||||
const response = (authResult as { response?: { sessionid?: string } }).response;
|
const data = (await response.json()) as { response?: { sessionid?: string } };
|
||||||
let sessionId;
|
if (data.response?.sessionid) {
|
||||||
const headers: Record<string, string> = {};
|
return {
|
||||||
if (response?.sessionid) {
|
type: "header",
|
||||||
sessionId = response.sessionid;
|
sessionId: data.response.sessionid,
|
||||||
headers["X-OPENMEDIAVAULT-SESSIONID"] = sessionId;
|
};
|
||||||
} else {
|
} else {
|
||||||
sessionId = OpenMediaVaultIntegration.extractSessionIdFromCookies(authResponse.headers);
|
const sessionId = OpenMediaVaultIntegration.extractSessionIdFromCookies(response.headers);
|
||||||
const loginToken = OpenMediaVaultIntegration.extractLoginTokenFromCookies(authResponse.headers);
|
const loginToken = OpenMediaVaultIntegration.extractLoginTokenFromCookies(response.headers);
|
||||||
headers.Cookie = `${loginToken};${sessionId}`;
|
|
||||||
|
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 { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||||
|
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../../base/integration";
|
||||||
import { IntegrationTestConnectionError } from "../base/test-connection-error";
|
import { IntegrationTestConnectionError } from "../../base/test-connection-error";
|
||||||
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
|
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 type { DnsHoleSummary } from "../../interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||||
import { summaryResponseSchema } from "./pi-hole-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> {
|
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||||
const apiKey = super.getSecretValue("apiKey");
|
const apiKey = super.getSecretValue("apiKey");
|
||||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?summaryRaw", { auth: 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(),
|
dns_queries_today: z.number(),
|
||||||
ads_percentage_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/health-monitoring/healt-monitoring";
|
||||||
export * from "./interfaces/indexer-manager/indexer";
|
export * from "./interfaces/indexer-manager/indexer";
|
||||||
export * from "./interfaces/media-requests/media-request";
|
export * from "./interfaces/media-requests/media-request";
|
||||||
export * from "./pi-hole/pi-hole-types";
|
|
||||||
export * from "./base/searchable-integration";
|
export * from "./base/searchable-integration";
|
||||||
export * from "./homeassistant/homeassistant-types";
|
export * from "./homeassistant/homeassistant-types";
|
||||||
export * from "./proxmox/proxmox-types";
|
export * from "./proxmox/proxmox-types";
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import type { StartedTestContainer } from "testcontainers";
|
import type { StartedTestContainer } from "testcontainers";
|
||||||
import { GenericContainer, Wait } 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_PASSWORD = "12341234";
|
||||||
const DEFAULT_API_KEY = "3b1434980677dcf53fa8c4a611db3b1f0f88478790097515c0abb539102778b9"; // Some hash generated from password
|
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 () => {
|
test("getSummaryAsync should return summary from pi-hole", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const piholeContainer = await createPiHoleContainer(DEFAULT_PASSWORD).start();
|
const piholeContainer = await createPiHoleV5Container(DEFAULT_PASSWORD).start();
|
||||||
const piHoleIntegration = createPiHoleIntegration(piholeContainer, DEFAULT_API_KEY);
|
const piHoleIntegration = createPiHoleIntegrationV5(piholeContainer, DEFAULT_API_KEY);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await piHoleIntegration.getSummaryAsync();
|
const result = await piHoleIntegration.getSummaryAsync();
|
||||||
@@ -28,8 +29,8 @@ describe("Pi-hole integration", () => {
|
|||||||
|
|
||||||
test("testConnectionAsync should not throw", async () => {
|
test("testConnectionAsync should not throw", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const piholeContainer = await createPiHoleContainer(DEFAULT_PASSWORD).start();
|
const piholeContainer = await createPiHoleV5Container(DEFAULT_PASSWORD).start();
|
||||||
const piHoleIntegration = createPiHoleIntegration(piholeContainer, DEFAULT_API_KEY);
|
const piHoleIntegration = createPiHoleIntegrationV5(piholeContainer, DEFAULT_API_KEY);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
|
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
|
||||||
@@ -43,8 +44,8 @@ describe("Pi-hole integration", () => {
|
|||||||
|
|
||||||
test("testConnectionAsync should throw with wrong credentials", async () => {
|
test("testConnectionAsync should throw with wrong credentials", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const piholeContainer = await createPiHoleContainer(DEFAULT_PASSWORD).start();
|
const piholeContainer = await createPiHoleV5Container(DEFAULT_PASSWORD).start();
|
||||||
const piHoleIntegration = createPiHoleIntegration(piholeContainer, "wrong-api-key");
|
const piHoleIntegration = createPiHoleIntegrationV5(piholeContainer, "wrong-api-key");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
|
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
|
||||||
@@ -57,7 +58,118 @@ describe("Pi-hole integration", () => {
|
|||||||
}, 20_000); // Timeout of 20 seconds
|
}, 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
|
return new GenericContainer("pihole/pihole:2024.07.0") // v5
|
||||||
.withEnvironment({
|
.withEnvironment({
|
||||||
WEBPASSWORD: password,
|
WEBPASSWORD: password,
|
||||||
@@ -66,8 +178,31 @@ const createPiHoleContainer = (password: string) => {
|
|||||||
.withWaitStrategy(Wait.forLogMessage("Pi-hole Enabled"));
|
.withWaitStrategy(Wait.forLogMessage("Pi-hole Enabled"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPiHoleIntegration = (container: StartedTestContainer, apiKey: string) => {
|
const createPiHoleIntegrationV5 = (container: StartedTestContainer, apiKey: string) => {
|
||||||
return new PiHoleIntegration({
|
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",
|
id: "1",
|
||||||
decryptedSecrets: [
|
decryptedSecrets: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/log",
|
"name": "@homarr/log",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/modals-collection",
|
"name": "@homarr/modals-collection",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts"
|
".": "./index.ts"
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.17.0",
|
"@mantine/core": "^7.17.1",
|
||||||
"@tabler/icons-react": "^3.30.0",
|
"@tabler/icons-react": "^3.31.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "15.1.7",
|
"next": "15.1.7",
|
||||||
"react": "19.0.0",
|
"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 { z } from "zod";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
@@ -60,25 +60,35 @@ export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(
|
|||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||||
<Stack>
|
<Stack>
|
||||||
<List>
|
<List spacing={"xs"}>
|
||||||
{innerProps.selectedContainers.map((container, index) => (
|
{innerProps.selectedContainers.map((container, index) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
styles={{ itemWrapper: { width: "100%" }, itemLabel: { flex: 1 } }}
|
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}
|
key={container.id}
|
||||||
>
|
>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between" wrap={"nowrap"}>
|
||||||
<Text>{container.name}</Text>
|
<Text lineClamp={1}>{container.name}</Text>
|
||||||
<TextInput {...form.getInputProps(`containerUrls.${index}`)} />
|
<TextInput {...form.getInputProps(`containerUrls.${index}`)} />
|
||||||
</Group>
|
</Group>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
<Button onClick={actions.closeModal} variant="light">
|
<Button onClick={actions.closeModal} variant="light" px={"xl"}>
|
||||||
{t("common.action.cancel")}
|
{t("common.action.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!form.isValid()} type="submit">
|
<Button type="submit" px={"xl"}>
|
||||||
{t("common.action.add")}
|
{t("common.action.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -89,4 +99,5 @@ export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(
|
|||||||
defaultTitle(t) {
|
defaultTitle(t) {
|
||||||
return t("docker.action.addToHomarr.modal.title");
|
return t("docker.action.addToHomarr.modal.title");
|
||||||
},
|
},
|
||||||
|
size: "lg",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/modals",
|
"name": "@homarr/modals",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts"
|
".": "./index.ts"
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.17.0",
|
"@mantine/core": "^7.17.1",
|
||||||
"@mantine/hooks": "^7.17.0",
|
"@mantine/hooks": "^7.17.1",
|
||||||
"react": "19.0.0"
|
"react": "19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/notifications",
|
"name": "@homarr/notifications",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@mantine/notifications": "^7.17.0",
|
"@mantine/notifications": "^7.17.1",
|
||||||
"@tabler/icons-react": "^3.30.0"
|
"@tabler/icons-react": "^3.31.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/old-import",
|
"name": "@homarr/old-import",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
@@ -37,8 +37,8 @@
|
|||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.17.0",
|
"@mantine/core": "^7.17.1",
|
||||||
"@mantine/hooks": "^7.17.0",
|
"@mantine/hooks": "^7.17.1",
|
||||||
"adm-zip": "0.5.16",
|
"adm-zip": "0.5.16",
|
||||||
"next": "15.1.7",
|
"next": "15.1.7",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
|
|||||||
@@ -14,12 +14,40 @@ export const mapBoard = (preparedBoard: PreparedBoard): InferInsertModel<typeof
|
|||||||
backgroundImageUrl: preparedBoard.config.settings.customization.backgroundImageUrl,
|
backgroundImageUrl: preparedBoard.config.settings.customization.backgroundImageUrl,
|
||||||
backgroundImageRepeat: preparedBoard.config.settings.customization.backgroundImageRepeat,
|
backgroundImageRepeat: preparedBoard.config.settings.customization.backgroundImageRepeat,
|
||||||
backgroundImageSize: preparedBoard.config.settings.customization.backgroundImageSize,
|
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,
|
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,
|
pageTitle: preparedBoard.config.settings.customization.pageTitle,
|
||||||
metaTitle: preparedBoard.config.settings.customization.metaTitle,
|
metaTitle: preparedBoard.config.settings.customization.metaTitle,
|
||||||
opacity: preparedBoard.config.settings.customization.appOpacity,
|
opacity: preparedBoard.config.settings.customization.appOpacity,
|
||||||
primaryColor: mapColor(preparedBoard.config.settings.customization.colors.primary, "#fa5252"),
|
primaryColor: mapColor(preparedBoard.config.settings.customization.colors.primary, "#fa5252"),
|
||||||
secondaryColor: mapColor(preparedBoard.config.settings.customization.colors.secondary, "#fd7e14"),
|
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",
|
"name": "@homarr/old-schema",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts"
|
".": "./index.ts"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/ping",
|
"name": "@homarr/ping",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts"
|
".": "./index.ts"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/redis",
|
"name": "@homarr/redis",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts"
|
".": "./index.ts"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
createChannelWithLatestAndEvents,
|
createChannelWithLatestAndEvents,
|
||||||
handshakeAsync,
|
handshakeAsync,
|
||||||
createSubPubChannel,
|
createSubPubChannel,
|
||||||
|
createGetSetChannel,
|
||||||
} from "./lib/channel";
|
} from "./lib/channel";
|
||||||
|
|
||||||
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
|
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.
|
* Creates a new cache channel.
|
||||||
* @param name name of the channel
|
* @param name name of the channel
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@homarr/request-handler",
|
"name": "@homarr/request-handler",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
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 type { CalendarEvent, RadarrReleaseType } from "@homarr/integrations/types";
|
||||||
|
|
||||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
@@ -12,7 +12,7 @@ export const calendarMonthRequestHandler = createCachedIntegrationRequestHandler
|
|||||||
{ year: number; month: number; releaseType: RadarrReleaseType[] }
|
{ year: number; month: number; releaseType: RadarrReleaseType[] }
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, input) {
|
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 startDate = dayjs().year(input.year).month(input.month).startOf("month");
|
||||||
const endDate = startDate.clone().endOf("month");
|
const endDate = startDate.clone().endOf("month");
|
||||||
return await integrationInstance.getCalendarEventsAsync(startDate.toDate(), endDate.toDate());
|
return await integrationInstance.getCalendarEventsAsync(startDate.toDate(), endDate.toDate());
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||||
|
|
||||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
@@ -12,7 +12,7 @@ export const dnsHoleRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, _input) {
|
async requestAsync(integration, _input) {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getSummaryAsync();
|
return await integrationInstance.getSummaryAsync();
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(5, "seconds"),
|
cacheDuration: dayjs.duration(5, "seconds"),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import dayjs from "dayjs";
|
|||||||
|
|
||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
|
|
||||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export const downloadClientRequestHandler = createCachedIntegrationRequestHandle
|
|||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, _input) {
|
async requestAsync(integration, _input) {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getClientJobsAndStatusAsync();
|
return await integrationInstance.getClientJobsAndStatusAsync();
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(5, "seconds"),
|
cacheDuration: dayjs.duration(5, "seconds"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
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 type { HealthMonitoring, ProxmoxClusterInfo } from "@homarr/integrations/types";
|
||||||
|
|
||||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
@@ -12,7 +12,7 @@ export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, _input) {
|
async requestAsync(integration, _input) {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getSystemInfoAsync();
|
return await integrationInstance.getSystemInfoAsync();
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(5, "seconds"),
|
cacheDuration: dayjs.duration(5, "seconds"),
|
||||||
@@ -25,7 +25,7 @@ export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, _input) {
|
async requestAsync(integration, _input) {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getClusterInfoAsync();
|
return await integrationInstance.getClusterInfoAsync();
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(5, "seconds"),
|
cacheDuration: dayjs.duration(5, "seconds"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import type { Indexer } from "@homarr/integrations/types";
|
import type { Indexer } from "@homarr/integrations/types";
|
||||||
|
|
||||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
@@ -12,7 +12,7 @@ export const indexerManagerRequestHandler = createCachedIntegrationRequestHandle
|
|||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, _input) {
|
async requestAsync(integration, _input) {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getIndexersAsync();
|
return await integrationInstance.getIndexersAsync();
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(5, "minutes"),
|
cacheDuration: dayjs.duration(5, "minutes"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import type { MediaRequest } from "@homarr/integrations/types";
|
import type { MediaRequest } from "@homarr/integrations/types";
|
||||||
|
|
||||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
@@ -12,7 +12,7 @@ export const mediaRequestListRequestHandler = createCachedIntegrationRequestHand
|
|||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, _input) {
|
async requestAsync(integration, _input) {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getRequestsAsync();
|
return await integrationInstance.getRequestsAsync();
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(1, "minute"),
|
cacheDuration: dayjs.duration(1, "minute"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import type { MediaRequestStats } from "@homarr/integrations/types";
|
import type { MediaRequestStats } from "@homarr/integrations/types";
|
||||||
|
|
||||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
@@ -12,7 +12,7 @@ export const mediaRequestStatsRequestHandler = createCachedIntegrationRequestHan
|
|||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, _input) {
|
async requestAsync(integration, _input) {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return {
|
return {
|
||||||
stats: await integrationInstance.getStatsAsync(),
|
stats: await integrationInstance.getStatsAsync(),
|
||||||
users: await integrationInstance.getUsersAsync(),
|
users: await integrationInstance.getUsersAsync(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import dayjs from "dayjs";
|
|||||||
|
|
||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
import type { StreamSession } from "@homarr/integrations";
|
import type { StreamSession } from "@homarr/integrations";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
|
|
||||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export const mediaServerRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, _input) {
|
async requestAsync(integration, _input) {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getCurrentSessionsAsync();
|
return await integrationInstance.getCurrentSessionsAsync();
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(5, "seconds"),
|
cacheDuration: dayjs.duration(5, "seconds"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
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 type { TdarrQueue, TdarrStatistics, TdarrWorker } from "@homarr/integrations";
|
||||||
|
|
||||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
@@ -14,7 +14,7 @@ export const mediaTranscodingRequestHandler = createCachedIntegrationRequestHand
|
|||||||
queryKey: "mediaTranscoding",
|
queryKey: "mediaTranscoding",
|
||||||
cacheDuration: dayjs.duration(5, "minutes"),
|
cacheDuration: dayjs.duration(5, "minutes"),
|
||||||
async requestAsync(integration, input) {
|
async requestAsync(integration, input) {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return {
|
return {
|
||||||
queue: await integrationInstance.getQueueAsync(input.pageOffset, input.pageSize),
|
queue: await integrationInstance.getQueueAsync(input.pageOffset, input.pageSize),
|
||||||
workers: await integrationInstance.getWorkersAsync(),
|
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