chore(release): automatic release v1.13.0

This commit is contained in:
homarr-releases[bot]
2025-03-28 19:14:18 +00:00
committed by GitHub
139 changed files with 10299 additions and 1844 deletions

View File

@@ -31,6 +31,7 @@ body:
label: Version
description: What version of Homarr are you running?
options:
- 1.12.0
- 1.11.0
- 1.10.0
- 1.9.0

View File

@@ -6,11 +6,6 @@
matchPackagePatterns: ["^@homarr/"],
enabled: false,
},
// 15.2.0 crashes with turbopack error (panic)
{
matchPackagePatterns: ["^next$", "^@next/eslint-plugin-next$"],
enabled: false,
},
{
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true,

14
.run/All Tests.run.xml Normal file
View File

@@ -0,0 +1,14 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="All Tests" type="JavaScriptTestRunnerVitest" nameIsGenerated="true">
<node-interpreter value="project" />
<vitest-package value="$PROJECT_DIR$/node_modules/vitest" />
<working-dir value="$PROJECT_DIR$" />
<vitest-options value="--exclude e2e --coverage.enabled" />
<envs>
<env name="NODE_ENV" value="development" />
<env name="CI" value="true" />
</envs>
<scope-kind value="ALL" />
<method v="2" />
</configuration>
</component>

View File

@@ -12,17 +12,17 @@ languageIds:
# You should unescape RegEx strings in order to fit in the YAML file
# To help with this, you can use https://www.freeformatter.com/json-escape.html
usageMatchRegex:
# The following example shows how to detect `t("your.i18n.keys")`
# the `{key}` will be placed by a proper keypath matching regex,
# you can ignore it and use your own matching rules as well
# For direct t("your.i18n.keys") usage
- "[^\\w\\d]t\\(['\"`]({key})['\"`]"
# For variable t assigned from getScopedI18n or useScopedI18n
- "\\bt\\(['\"`]({key})['\"`]\\)"
# A RegEx to set a custom scope range. This scope will be used as a prefix when detecting keys
# and works like how the i18next framework identifies the namespace scope from the
# useTranslation() hook.
# You should unescape RegEx strings in order to fit in the YAML file
# To help with this, you can use https://www.freeformatter.com/json-escape.html
scopeRangeRegex: "(getScopedI18n|useScopedI18n)\\(\\s*['\"](.*?)['\"]\\)"
scopeRangeRegex: "(?:const|let|var)\\s+t\\s*=\\s*(?:await\\s+)?(?:getScopedI18n|useScopedI18n)\\(\\s*['\"](.*?)['\"]\\)"
# An array of strings containing refactor templates.
# The "$1" will be replaced by the keypath specified.

View File

@@ -48,21 +48,21 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.17.2",
"@mantine/core": "^7.17.2",
"@mantine/dropzone": "^7.17.2",
"@mantine/hooks": "^7.17.2",
"@mantine/modals": "^7.17.2",
"@mantine/tiptap": "^7.17.2",
"@mantine/colors-generator": "^7.17.3",
"@mantine/core": "^7.17.3",
"@mantine/dropzone": "^7.17.3",
"@mantine/hooks": "^7.17.3",
"@mantine/modals": "^7.17.3",
"@mantine/tiptap": "^7.17.3",
"@million/lint": "1.0.14",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-query-devtools": "^5.69.0",
"@tanstack/react-query-next-experimental": "^5.69.0",
"@trpc/client": "^11.0.0",
"@trpc/next": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@tanstack/react-query": "^5.70.0",
"@tanstack/react-query-devtools": "^5.70.0",
"@tanstack/react-query-next-experimental": "^5.70.0",
"@trpc/client": "^11.0.1",
"@trpc/next": "^11.0.1",
"@trpc/react-query": "^11.0.1",
"@trpc/server": "^11.0.1",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0",
@@ -74,7 +74,7 @@
"glob": "^11.0.1",
"jotai": "^2.12.2",
"mantine-react-table": "2.0.0-beta.9",
"next": "15.1.7",
"next": "15.2.4",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.30.0",
"react": "19.0.0",
@@ -83,7 +83,7 @@
"react-simple-code-editor": "^0.14.1",
"sass": "^1.86.0",
"superjson": "2.2.2",
"swagger-ui-react": "^5.20.1",
"swagger-ui-react": "^5.20.2",
"use-deep-compare-effect": "^1.8.1",
"zod": "^3.24.2"
},
@@ -92,13 +92,13 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.1",
"@types/node": "^22.13.11",
"@types/node": "^22.13.14",
"@types/prismjs": "^1.26.5",
"@types/react": "19.0.12",
"@types/react-dom": "19.0.4",
"@types/swagger-ui-react": "^5.18.0",
"concurrently": "^9.1.2",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"node-loader": "^2.1.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2"

View File

@@ -4,6 +4,7 @@ import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -30,6 +31,13 @@ export const InitUserForm = () => {
title: tUser("notification.success.title"),
message: tUser("notification.success.message"),
});
await signIn("credentials", {
name: values.username,
password: values.password,
redirect: false,
});
await revalidatePathActionAsync("/init");
},
onError: (error) => {

View File

@@ -3,49 +3,55 @@ import { Container, Group, Stack, Title } from "@mantine/core";
import { z } from "zod";
import { auth } from "@homarr/auth/next";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import type { validation } from "@homarr/validation";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NewIntegrationForm } from "./_integration-new-form";
import { NewIntegrationForm } from "../_integration-new-form";
interface NewIntegrationPageProps {
searchParams: Promise<
Partial<z.infer<typeof validation.integration.create>> & {
kind: IntegrationKind;
}
>;
interface NewIntegrationByIdPageProps {
params: {
id: string;
};
searchParams: Partial<z.infer<typeof validation.integration.create>>;
}
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
const searchParams = await props.searchParams;
export function generateStaticParams() {
return integrationKinds.map((kind) => ({
id: kind,
}));
}
export default async function IntegrationNewByIdPage(props: NewIntegrationByIdPageProps) {
const { id } = props.params;
const session = await auth();
if (!session?.user.permissions.includes("integration-create")) {
notFound();
}
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
const result = z.enum(integrationKinds).safeParse(id);
if (!result.success) {
notFound();
}
const tCreate = await getScopedI18n("integration.page.create");
const currentKind = result.data;
const dynamicMappings = new Map<string, string>([[id, getIntegrationName(currentKind)]]);
return (
<>
<DynamicBreadcrumb />
<DynamicBreadcrumb dynamicMappings={dynamicMappings} nonInteractable={["new"]} />
<Container>
<Stack>
<Group align="center">
<IntegrationAvatar kind={currentKind} size="md" />
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
</Group>
<NewIntegrationForm searchParams={searchParams} />
<NewIntegrationForm searchParams={{ kind: currentKind }} />
</Stack>
</Container>
</>

View File

@@ -39,7 +39,7 @@ export const IntegrationCreateDropdownContent = () => {
{filteredKinds.length > 0 ? (
<ScrollArea.Autosize mah={384}>
{filteredKinds.map((kind) => (
<Menu.Item component={Link} href={`/manage/integrations/new?kind=${kind}`} key={kind}>
<Menu.Item component={Link} href={`/manage/integrations/new/${kind}`} key={kind}>
<Group>
<IntegrationAvatar kind={kind} size="sm" />
<Text size="sm">{getIntegrationName(kind)}</Text>

View File

@@ -66,15 +66,17 @@ export const JobsList = ({ initialJobs }: JobsListProps) => {
{job.status && <TimeAgo timestamp={job.status.lastExecutionTimestamp} />}
</Stack>
<ActionIcon
onClick={() => handleJobTrigger(job)}
disabled={job.status?.status === "running"}
variant={"default"}
size={"xl"}
radius={"xl"}
>
<IconPlayerPlay stroke={1.5} />
</ActionIcon>
{!job.job.preventManualExecution && (
<ActionIcon
onClick={() => handleJobTrigger(job)}
disabled={job.status?.status === "running"}
variant={"default"}
size={"xl"}
radius={"xl"}
>
<IconPlayerPlay stroke={1.5} />
</ActionIcon>
)}
</Group>
</Card>
))}

View File

@@ -118,15 +118,13 @@ export default async function EditUserPage(props: Props) {
<PingIconsEnabled user={user} />
</Stack>
{isCredentialsUser && (
<DangerZoneRoot>
<DangerZoneItem
label={t("user.action.delete.label")}
description={t("user.action.delete.description")}
action={<DeleteUserButton user={user} />}
/>
</DangerZoneRoot>
)}
<DangerZoneRoot>
<DangerZoneItem
label={t("user.action.delete.label")}
description={t("user.action.delete.description")}
action={<DeleteUserButton user={user} />}
/>
</DangerZoneRoot>
</Stack>
);
}

View File

@@ -26,6 +26,9 @@ const handlerAsync = async (req: NextRequest) => {
endpoint: "/",
router: appRouter,
createContext: () => createTRPCContext({ session, headers: req.headers }),
onError({ error, path, type }) {
logger.error(new Error(`tRPC Error with ${type} on '${path}'`, { cause: error.cause }));
},
});
};

View File

@@ -31,9 +31,7 @@ const handler = auth(async (req) => {
req,
createContext: () => createTRPCContext({ session: req.auth, headers: req.headers }),
onError({ error, path, type }) {
logger.error(
`tRPC Error with ${type} on '${path}': (${error.code}) - ${error.message}\n${error.stack}\n${error.cause}`,
);
logger.error(new Error(`tRPC Error with ${type} on '${path}'`, { cause: error.cause }));
},
});

View File

@@ -10,6 +10,7 @@ export class DynamicSectionMockBuilder {
id: createId(),
kind: "dynamic",
options: {
title: "",
borderColor: "",
},
layouts: [],

View File

@@ -5,6 +5,8 @@ import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "node_modules/@homarr/widgets/src/errors";
import { ErrorBoundary } from "react-error-boundary";
import { useSession } from "@homarr/auth/client";
import { isWidgetRestricted } from "@homarr/auth/shared";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
@@ -15,6 +17,7 @@ import type { SectionItem } from "~/app/[locale]/boards/_types";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import { BoardItemMenu } from "./item-menu";
import { RestrictedWidgetContent } from "./restricted";
interface BoardItemContentProps {
item: SectionItem;
@@ -59,6 +62,7 @@ interface InnerContentProps {
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const settings = useSettings();
const board = useRequiredBoard();
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const Comp = loadWidgetDynamic(item.kind);
const { definition } = widgetImports[item.kind];
@@ -70,6 +74,16 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const widgetSupportsIntegrations =
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
if (
isWidgetRestricted({
definition,
user: session?.user ?? null,
check: (level) => level === "all",
})
) {
return <RestrictedWidgetContent kind={item.kind} />;
}
return (
<QueryErrorResetBoundary>
{({ reset }) => (

View File

@@ -3,6 +3,8 @@ import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { isWidgetRestricted } from "@homarr/auth/shared";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
@@ -37,6 +39,7 @@ export const BoardItemMenu = ({
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const settings = useSettings();
const { data: session } = useSession();
// Reset error boundary on next render if item has been edited
useEffect(() => {
@@ -91,6 +94,16 @@ export const BoardItemMenu = ({
});
};
if (
isWidgetRestricted({
definition: currentDefinition,
user: session?.user ?? null,
check: (level) => level !== "none",
})
) {
return null;
}
return (
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
<Menu.Target>

View File

@@ -2,6 +2,8 @@ import { useMemo, useState } from "react";
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { isWidgetRestricted } from "@homarr/auth/shared";
import { objectEntries } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { createModal } from "@homarr/modals";
@@ -15,10 +17,18 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
const [search, setSearch] = useState("");
const t = useI18n();
const { createItem } = useItemActions();
const { data: session } = useSession();
const items = useMemo(
() =>
objectEntries(widgetImports)
.filter(([, value]) => {
return !isWidgetRestricted({
definition: value.definition,
user: session?.user ?? null,
check: (level) => level !== "none",
});
})
.map(([kind, value]) => ({
kind,
icon: value.definition.icon,
@@ -26,7 +36,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
description: t(`widget.${kind}.description`),
}))
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
[t],
[t, session?.user],
);
const filteredItems = useMemo(

View File

@@ -0,0 +1,28 @@
import { Center, Group, Stack, Text } from "@mantine/core";
import { IconShield } from "@tabler/icons-react";
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
interface RestrictedWidgetProps {
kind: WidgetKind;
}
export const RestrictedWidgetContent = ({ kind }: RestrictedWidgetProps) => {
const tCurrentWidget = useScopedI18n(`widget.${kind}`);
const tCommonWidget = useScopedI18n("widget.common");
return (
<Center h="100%">
<Stack ta="center" gap="xs" align="center">
<Group gap="sm">
<IconShield size={16} />
<Text size="sm" fw="bold">
{tCommonWidget("restricted.title")}
</Text>
</Group>
<Text size="sm">{tCommonWidget("restricted.description", { name: tCurrentWidget("name") })}</Text>
</Stack>
</Center>
);
};

View File

@@ -1,4 +1,4 @@
import { Box, Card } from "@mantine/core";
import { Badge, Box, Card } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
@@ -17,7 +17,12 @@ export const BoardDynamicSection = ({ section }: Props) => {
const options = section.options;
return (
<Box className="grid-stack-item-content">
<Box
className="grid-stack-item-content"
style={{
overflow: "visible",
}}
>
<Card
className={classes.itemCard}
w="100%"
@@ -25,14 +30,28 @@ export const BoardDynamicSection = ({ section }: Props) => {
withBorder
styles={{
root: {
overflow: "visible",
"--opacity": board.opacity / 100,
overflow: "hidden",
"--border-color": options.borderColor !== "" ? options.borderColor : undefined,
"--border-color": options.borderColor || undefined,
},
}}
radius={board.itemRadius}
p={0}
>
{options.title && (
<Badge
pos="absolute"
top={-15}
left={10}
size="md"
radius={board.itemRadius}
color="var(--background-color)"
c="var(--mantine-color-text)"
bd="1px solid var(--border-color)"
>
{options.title}
</Badge>
)}
{/* Use unique key by layout to reinitialize gridstack */}
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
</Card>

View File

@@ -17,6 +17,7 @@ export const addDynamicSectionCallback = () => (board: Board) => {
id: createId(),
kind: "dynamic",
options: {
title: "",
borderColor: "",
},
layouts: createDynamicSectionLayouts(board, firstSection),

View File

@@ -1,6 +1,6 @@
"use client";
import { Button, CloseButton, ColorInput, Group, Stack, useMantineTheme } from "@mantine/core";
import { Button, CloseButton, ColorInput, Group, Stack, TextInput, useMantineTheme } from "@mantine/core";
import type { z } from "zod";
import { useZodForm } from "@homarr/form";
@@ -30,6 +30,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
})}
>
<Stack>
<TextInput label={t("section.dynamic.option.title.label")} {...form.getInputProps("title")} />
<ColorInput
label={t("section.dynamic.option.borderColor.label")}
format="hex"

View File

@@ -38,15 +38,15 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"superjson": "2.2.2",
"undici": "7.5.0"
"undici": "7.6.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.13.11",
"@types/node": "^22.13.14",
"dotenv-cli": "^8.0.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"prettier": "^3.5.3",
"tsx": "4.19.3",
"typescript": "^5.8.2"

View File

@@ -1,7 +1,7 @@
// This import has to be the first import in the file so that the agent is overridden before any other modules are imported.
import "./undici-log-agent-override";
import { registerCronJobRunner } from "@homarr/cron-job-runner";
import { registerCronJobRunner } from "@homarr/cron-job-runner/register";
import { jobGroup } from "@homarr/cron-jobs";
void (async () => {

View File

@@ -34,7 +34,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.18.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
}

View File

@@ -47,13 +47,13 @@
"jsdom": "^26.0.0",
"prettier": "^3.5.3",
"semantic-release": "^24.2.3",
"testcontainers": "^10.21.0",
"testcontainers": "^10.23.0",
"turbo": "^2.4.4",
"typescript": "^5.8.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9"
},
"packageManager": "pnpm@10.6.5",
"packageManager": "pnpm@10.7.0",
"engines": {
"node": ">=22.14.0"
},
@@ -69,13 +69,13 @@
"tree-sitter",
"tree-sitter-json"
],
"allowNonAppliedPatches": true,
"overrides": {
"proxmox-api>undici": "7.5.0"
"proxmox-api>undici": "7.6.0"
},
"patchedDependencies": {
"pretty-print-error": "patches/pretty-print-error.patch"
},
"allowUnusedPatches": true,
"ignoredBuiltDependencies": [
"@scarf/scarf",
"core-js-pure",

View File

@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -41,11 +41,11 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "^1.1.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@trpc/client": "^11.0.1",
"@trpc/react-query": "^11.0.1",
"@trpc/server": "^11.0.1",
"lodash.clonedeep": "^4.5.0",
"next": "15.1.7",
"next": "15.2.4",
"pretty-print-error": "^1.1.2",
"react": "19.0.0",
"react-dom": "19.0.0",
@@ -57,7 +57,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
}

View File

@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { constructBoardPermissions, isWidgetRestricted } from "@homarr/auth/shared";
import type { DeviceType } from "@homarr/common/server";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, asc, createId, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
@@ -40,6 +40,7 @@ import { oldmarrConfigSchema } from "@homarr/old-schema";
import type { BoardItemAdvancedOptions } from "@homarr/validation";
import { sectionSchema, sharedItemSchema, validation, zodUnionFromArray } from "@homarr/validation";
import { widgetImports } from "../../../widgets/src";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm";
@@ -251,6 +252,13 @@ export const boardRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const boardId = createId();
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
homeBoardId: true,
},
});
const createBoardCollection = createDbInsertCollectionWithoutTransaction(["boards", "sections", "layouts"]);
createBoardCollection.boards.push({
@@ -275,6 +283,12 @@ export const boardRouter = createTRPCRouter({
});
await createBoardCollection.insertAllAsync(ctx.db);
if (!user?.homeBoardId) {
await ctx.db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, ctx.session.user.id));
}
return { boardId };
}),
duplicateBoard: permissionRequiredProcedure
.requiresPermission("board-create")
@@ -310,6 +324,13 @@ export const boardRouter = createTRPCRouter({
}
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
const allowedBoardItems = boardItems.filter((item) => {
return !isWidgetRestricted({
definition: widgetImports[item.kind].definition,
user: ctx.session.user,
check: (level) => level !== "none",
});
});
const newBoardId = createId();
@@ -357,8 +378,8 @@ export const boardRouter = createTRPCRouter({
),
);
const itemMap = new Map<string, string>(boardItems.map((item) => [item.id, createId()]));
const itemsToInsert: InferInsertModel<typeof items>[] = boardItems.map(
const itemMap = new Map<string, string>(allowedBoardItems.map((item) => [item.id, createId()]));
const itemsToInsert: InferInsertModel<typeof items>[] = allowedBoardItems.map(
({ integrations: _, layouts: _layouts, ...item }) => ({
...item,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -367,7 +388,7 @@ export const boardRouter = createTRPCRouter({
}),
);
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems.flatMap((item) =>
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = allowedBoardItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
...layoutSection,
@@ -400,7 +421,7 @@ export const boardRouter = createTRPCRouter({
)
.then((result) => result.map((row) => row.id));
const itemIntegrationsToInsert = boardItems.flatMap((item) =>
const itemIntegrationsToInsert = allowedBoardItems.flatMap((item) =>
item.integrations
// Restrict integrations to only those the user has access to
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
@@ -730,105 +751,140 @@ export const boardRouter = createTRPCRouter({
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
const sectionsToInsert = addedSections.map(
(section): InferInsertModel<typeof sections> => ({
id: section.id,
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : 0,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
}),
);
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "dynamic")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
const addedItems = filterAddedItems(input.items, dbBoard.items).filter((item) => {
return !isWidgetRestricted({
definition: widgetImports[item.kind].definition,
user: ctx.session.user,
check: (level) => level !== "none",
});
});
const itemsToInsert = addedItems.map(
(item): InferInsertModel<typeof items> => ({
id: item.id,
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
boardId: dbBoard.id,
}),
);
const itemLayoutsToInsert = addedItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
);
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
),
);
const integrationItemsToInsert = addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
}));
const updatedItems = filterUpdatedItems(input.items, dbBoard.items).filter((item) => {
return !isWidgetRestricted({
definition: widgetImports[item.kind].definition,
user: ctx.session.user,
check: (level) => level !== "none",
});
});
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
),
);
const removedItems = filterRemovedItems(input.items, dbBoard.items).filter((item) => {
return !isWidgetRestricted({
definition: widgetImports[item.kind].definition,
user: ctx.session.user,
check: (level) => level !== "none",
});
});
const itemIdsToRemove = removedItems.map((item) => item.id);
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIdsToRemove = removedSections.map((section) => section.id);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
await transaction.insert(schema.sections).values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : 0,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
);
if (addedSections.some((section) => section.kind === "dynamic")) {
await transaction.insert(schema.sectionLayouts).values(
addedSections
.filter((section) => section.kind === "dynamic")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
),
);
}
if (sectionsToInsert.length > 0) {
await transaction.insert(schema.sections).values(sectionsToInsert);
}
const addedItems = filterAddedItems(input.items, dbBoard.items);
if (addedItems.length > 0) {
await transaction.insert(schema.items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
boardId: dbBoard.id,
})),
);
await transaction.insert(schema.itemLayouts).values(
addedItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
),
);
if (sectionLayoutsToInsert.length > 0) {
await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
}
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
await transaction.insert(schema.integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
);
if (itemsToInsert.length > 0) {
await transaction.insert(schema.items).values(itemsToInsert);
}
if (itemLayoutsToInsert.length > 0) {
await transaction.insert(schema.itemLayouts).values(itemLayoutsToInsert);
}
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
if (integrationItemsToInsert.length > 0) {
await transaction.insert(schema.integrationItems).values(integrationItemsToInsert);
}
for (const item of updatedItems) {
await transaction
@@ -859,8 +915,6 @@ export const boardRouter = createTRPCRouter({
}
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
await transaction
@@ -894,15 +948,6 @@ export const boardRouter = createTRPCRouter({
}
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(schema.integrationItems)
@@ -914,134 +959,36 @@ export const boardRouter = createTRPCRouter({
);
}
const removedItems = filterRemovedItems(input.items, dbBoard.items);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await transaction.delete(schema.items).where(inArray(schema.items.id, itemIds));
if (itemIdsToRemove.length > 0) {
await transaction.delete(schema.items).where(inArray(schema.items.id, itemIdsToRemove));
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
await transaction.delete(schema.sections).where(inArray(schema.sections.id, sectionIds));
if (sectionIdsToRemove.length > 0) {
await transaction.delete(schema.sections).where(inArray(schema.sections.id, sectionIdsToRemove));
}
});
},
handleSync(db) {
db.transaction((transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
transaction
.insert(sections)
.values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : 0,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
)
.run();
if (addedSections.some((section) => section.kind === "dynamic")) {
transaction
.insert(sectionLayouts)
.values(
addedSections
.filter((section) => section.kind === "dynamic")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
),
)
.run();
}
if (sectionsToInsert.length > 0) {
transaction.insert(sections).values(sectionsToInsert).run();
}
const addedItems = filterAddedItems(input.items, dbBoard.items);
if (addedItems.length > 0) {
transaction
.insert(items)
.values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
boardId: dbBoard.id,
})),
)
.run();
transaction
.insert(itemLayouts)
.values(
addedItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
),
)
.run();
if (sectionLayoutsToInsert.length > 0) {
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
}
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
transaction
.insert(integrationItems)
.values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
)
.run();
if (itemsToInsert.length > 0) {
transaction.insert(items).values(itemsToInsert).run();
}
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
if (itemLayoutsToInsert.length > 0) {
transaction.insert(itemLayouts).values(itemLayoutsToInsert).run();
}
if (integrationItemsToInsert.length > 0) {
transaction.insert(integrationItems).values(integrationItemsToInsert).run();
}
for (const item of updatedItems) {
transaction
@@ -1069,8 +1016,6 @@ export const boardRouter = createTRPCRouter({
}
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
transaction
@@ -1103,15 +1048,6 @@ export const boardRouter = createTRPCRouter({
}
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
transaction
.delete(integrationItems)
@@ -1124,18 +1060,12 @@ export const boardRouter = createTRPCRouter({
.run();
}
const removedItems = filterRemovedItems(input.items, dbBoard.items);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
transaction.delete(items).where(inArray(items.id, itemIds)).run();
if (itemIdsToRemove.length > 0) {
transaction.delete(items).where(inArray(items.id, itemIdsToRemove)).run();
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
transaction.delete(sections).where(inArray(sections.id, sectionIds)).run();
if (sectionIdsToRemove.length > 0) {
transaction.delete(sections).where(inArray(sections.id, sectionIdsToRemove)).run();
}
});
},
@@ -1305,7 +1235,7 @@ export const boardRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const content = await input.file.text();
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
await importOldmarrAsync(ctx.db, oldmarr, input.configuration, ctx.session);
}),
});

View File

@@ -1,9 +1,9 @@
import { observable } from "@trpc/server/observable";
import { jobNameSchema, triggerCronJobAsync } from "@homarr/cron-job-runner";
import { objectEntries } from "@homarr/common";
import { cronJobNames, cronJobs, jobNameSchema, triggerCronJobAsync } from "@homarr/cron-job-runner";
import type { TaskStatus } from "@homarr/cron-job-status";
import { createCronJobStatusChannel } from "@homarr/cron-job-status";
import { jobGroup } from "@homarr/cron-jobs";
import { logger } from "@homarr/log";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
@@ -16,18 +16,17 @@ export const cronJobsRouter = createTRPCRouter({
await triggerCronJobAsync(input);
}),
getJobs: permissionRequiredProcedure.requiresPermission("admin").query(() => {
const registry = jobGroup.getJobRegistry();
return [...registry.values()].map((job) => ({
name: job.name,
expression: job.cronExpression,
return objectEntries(cronJobs).map(([name, options]) => ({
name,
preventManualExecution: options.preventManualExecution,
}));
}),
subscribeToStatusUpdates: permissionRequiredProcedure.requiresPermission("admin").subscription(() => {
return observable<TaskStatus>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const job of jobGroup.getJobRegistry().values()) {
const channel = createCronJobStatusChannel(job.name);
for (const name of cronJobNames) {
const channel = createCronJobStatusChannel(name);
const unsubscribe = channel.subscribe((data) => {
emit.next(data);
});

View File

@@ -37,7 +37,7 @@ export const importRouter = createTRPCRouter({
.requiresStep("import")
.input(importInitialOldmarrInputSchema)
.mutation(async ({ ctx, input }) => {
await importInitialOldmarrAsync(ctx.db, input);
await importInitialOldmarrAsync(ctx.db, input, ctx.session);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
});

View File

@@ -1,5 +1,3 @@
import { formatError } from "pretty-print-error";
import { decryptSecret } from "@homarr/common/server";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
@@ -41,7 +39,10 @@ export const testConnectionAsync = async (
};
} catch (error) {
logger.warn(
`Failed to decrypt secret from database integration="${integration.name}" secretKind="${secret.kind}"\n${formatError(error)}`,
new Error(
`Failed to decrypt secret from database integration="${integration.name}" secretKind="${secret.kind}"`,
{ cause: error },
),
);
return null;
}

View File

@@ -65,9 +65,7 @@ export class KubernetesClient {
}
public static getInstance(): KubernetesClient {
if (!KubernetesClient.instance) {
KubernetesClient.instance = new KubernetesClient();
}
KubernetesClient.instance ??= new KubernetesClient();
return KubernetesClient.instance;
}
}

View File

@@ -1,5 +1,3 @@
import { formatError } from "pretty-print-error";
import { logger } from "@homarr/log";
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
@@ -12,7 +10,7 @@ export const updateCheckerRouter = createTRPCRouter({
const data = await handler.getCachedOrUpdatedDataAsync({});
return data.data.availableUpdates;
} catch (error) {
logger.error(`Failed to get available updates\n${formatError(error)}`);
logger.error(new Error("Failed to get available updates", { cause: error }));
return undefined; // We return undefined to not show the indicator in the UI
}
}),

View File

@@ -2,7 +2,6 @@ import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapte
import { cookies } from "next/headers";
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { formatError } from "pretty-print-error";
import { db } from "@homarr/db";
import type { SupportedAuthProvider } from "@homarr/definitions";
@@ -36,8 +35,7 @@ export const createConfiguration = (
return;
}
logger.error(formatError(error));
logger.error(formatError(error.cause));
logger.error(error);
},
},
trustHost: true,

View File

@@ -39,6 +39,7 @@ export const env = createEnv({
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
}
: {}),
...(authProviders.includes("ldap")

View File

@@ -34,8 +34,8 @@
"@homarr/validation": "workspace:^0.1.0",
"bcrypt": "^5.1.1",
"cookies": "^0.9.1",
"ldapts": "7.3.1",
"next": "15.1.7",
"ldapts": "7.3.3",
"next": "15.2.4",
"next-auth": "5.0.0-beta.25",
"pretty-print-error": "^1.1.2",
"react": "19.0.0",
@@ -48,7 +48,7 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
}

View File

@@ -1,2 +1,3 @@
export * from "./board-permissions";
export * from "./integration-permissions";
export * from "./widget-restriction";

View File

@@ -0,0 +1,14 @@
import type { Session } from "next-auth";
import type { WidgetDefinition } from "../../widgets/src";
import type { RestrictionLevel } from "../../widgets/src/definition";
export const isWidgetRestricted = <TDefinition extends WidgetDefinition>(props: {
definition: TDefinition;
user: Session["user"] | null;
check: (level: RestrictionLevel) => boolean;
}) => {
if (!("restrict" in props.definition)) return false;
if (props.definition.restrict === undefined) return false;
return props.check(props.definition.restrict({ user: props.user ?? null }));
};

View File

@@ -22,6 +22,31 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
redirect_uri: createRedirectUri(headers, "/api/auth/callback/oidc", "https"),
},
},
token: {
// Providers like fusionauth may return www-authenticate which results in an error
// https://github.com/nextauthjs/next-auth/issues/8745
// https://github.com/homarr-labs/homarr/issues/2690
conform: (response: Response) => {
if (response.status === 401) return response;
const newHeaders = Array.from(response.headers.entries())
.filter(([key]) => key.toLowerCase() !== "www-authenticate")
.reduce((headers, [key, value]) => {
headers.append(key, value);
return headers;
}, new Headers());
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
},
},
// idToken false forces the use of the userinfo endpoint
// Userinfo endpoint is required for authelia since v4.39
// See https://github.com/homarr-labs/homarr/issues/2635
idToken: !env.AUTH_OIDC_FORCE_USERINFO,
profile(profile) {
if (!profile.sub) {
throw new Error(`OIDC provider did not return a sub property='${Object.keys(profile).join(",")}'`);

View File

@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -23,13 +23,13 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"undici": "7.5.0"
"undici": "7.6.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -30,17 +30,17 @@
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"next": "15.1.7",
"next": "15.2.4",
"react": "19.0.0",
"react-dom": "19.0.0",
"undici": "7.5.0",
"undici": "7.6.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -8,12 +8,9 @@ export const extractBaseUrlFromHeaders = (
headers: ReadonlyHeaders,
fallbackProtocol: "http" | "https" = "http",
): `${string}://${string}` => {
let protocol = headers.get("x-forwarded-proto");
// If the protocol is not set or an empty string
if (!protocol) {
protocol = fallbackProtocol;
}
// For empty string we also use the fallback protocol
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
let protocol = headers.get("x-forwarded-proto") || fallbackProtocol;
// @see https://support.glitch.com/t/x-forwarded-proto-contains-multiple-protocols/17219
if (protocol.includes(",")) {

View File

@@ -5,7 +5,8 @@
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"
".": "./index.ts",
"./register": "./src/register.ts"
},
"typesVersions": {
"*": {
@@ -22,15 +23,17 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0"
"@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

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

View File

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

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -32,7 +32,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -44,7 +44,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -1,18 +1,22 @@
import SuperJSON from "superjson";
import { objectKeys } from "@homarr/common";
import { everyoneGroup } from "@homarr/definitions";
import { createDocumentationLink, everyoneGroup } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { createId, eq } from "..";
import type { Database } from "..";
import { onboarding, serverSettings } from "../schema";
import { createId, eq } from "..";
import {
getServerSettingByKeyAsync,
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
import { onboarding, searchEngines } from "../schema";
import { groups } from "../schema/mysql";
export const seedDataAsync = async (db: Database) => {
await seedEveryoneGroupAsync(db);
await seedOnboardingAsync(db);
await seedServerSettingsAsync(db);
await seedDefaultSearchEnginesAsync(db);
};
const seedEveryoneGroupAsync = async (db: Database) => {
@@ -48,21 +52,73 @@ const seedOnboardingAsync = async (db: Database) => {
console.log("Created onboarding step through seed");
};
const seedDefaultSearchEnginesAsync = async (db: Database) => {
const existingSearchEngines = await db.$count(searchEngines);
if (existingSearchEngines > 0) {
console.log("Skipping seeding of default search engines as some already exists");
return;
}
const homarrId = createId();
const defaultSearchEngines = [
{
id: createId(),
name: "Google",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/google.svg",
short: "g",
description: "Search the web with Google",
urlTemplate: "https://www.google.com/search?q=%s",
type: "generic" as const,
integrationId: null,
},
{
id: createId(),
name: "YouTube",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/youtube.svg",
short: "yt",
description: "Search for videos on YouTube",
urlTemplate: "https://www.youtube.com/results?search_query=%s",
type: "generic" as const,
integrationId: null,
},
{
id: homarrId,
name: "Homarr Docs",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
short: "docs",
description: "Search the Homarr documentation",
urlTemplate: createDocumentationLink("/search", undefined, { q: "%s" }),
type: "generic" as const,
integrationId: null,
},
];
await db.insert(searchEngines).values(defaultSearchEngines);
console.log(`Created ${defaultSearchEngines.length} default search engines through seeding process`);
// Set Homarr docs as the default search engine in server settings
const searchSettings = await getServerSettingByKeyAsync(db, "search");
await updateServerSettingByKeyAsync(db, "search", {
...searchSettings,
defaultSearchEngineId: homarrId,
});
console.log("Set Homarr docs as the default search engine");
};
const seedServerSettingsAsync = async (db: Database) => {
const serverSettingsData = await db.query.serverSettings.findMany();
for (const settingsKey of defaultServerSettingsKeys) {
const currentDbEntry = serverSettingsData.find((setting) => setting.settingKey === settingsKey);
if (!currentDbEntry) {
await db.insert(serverSettings).values({
settingKey: settingsKey,
value: SuperJSON.stringify(defaultServerSettings[settingsKey]),
});
await insertServerSettingByKeyAsync(db, settingsKey, defaultServerSettings[settingsKey]);
console.log(`Created serverSetting through seed key=${settingsKey}`);
continue;
}
const currentSettings = SuperJSON.parse<Record<string, unknown>>(currentDbEntry.value);
const currentSettings = await getServerSettingByKeyAsync(db, settingsKey);
const defaultSettings = defaultServerSettings[settingsKey];
const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings));
@@ -71,12 +127,7 @@ const seedServerSettingsAsync = async (db: Database) => {
continue;
}
await db
.update(serverSettings)
.set({
value: SuperJSON.stringify({ ...defaultSettings, ...currentSettings }), // Add missing keys
})
.where(eq(serverSettings.settingKey, settingsKey));
await updateServerSettingByKeyAsync(db, settingsKey, { ...defaultSettings, ...currentSettings });
console.log(`Updated serverSetting through seed key=${settingsKey}`);
}
};

View File

@@ -44,14 +44,14 @@
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@mantine/core": "^7.17.2",
"@mantine/core": "^7.17.3",
"@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^10.21.0",
"@testcontainers/mysql": "^10.23.0",
"better-sqlite3": "^11.9.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.5",
"drizzle-kit": "^0.30.6",
"drizzle-orm": "^0.41.0",
"drizzle-zod": "^0.7.0",
"drizzle-zod": "^0.7.1",
"mysql2": "3.14.0"
},
"devDependencies": {
@@ -60,7 +60,7 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.12",
"dotenv-cli": "^8.0.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"prettier": "^3.5.3",
"tsx": "4.19.3",
"typescript": "^5.8.2"

View File

@@ -50,3 +50,14 @@ export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSet
})
.where(eq(serverSettings.settingKey, key));
};
export const insertServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
db: Database,
key: TKey,
value: ServerSettings[TKey],
) => {
await db.insert(serverSettings).values({
settingKey: key,
value: SuperJSON.stringify(value),
});
};

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -207,6 +207,178 @@ export type HomarrDocumentationPath =
| "/docs/1.10.0/widgets/rss"
| "/docs/1.10.0/widgets/video"
| "/docs/1.10.0/widgets/weather"
| "/docs/1.11.0/tags"
| "/docs/1.11.0/tags/active-directory"
| "/docs/1.11.0/tags/ad-guard"
| "/docs/1.11.0/tags/ad-guard-home"
| "/docs/1.11.0/tags/administration"
| "/docs/1.11.0/tags/advanced"
| "/docs/1.11.0/tags/analytics"
| "/docs/1.11.0/tags/api"
| "/docs/1.11.0/tags/apps"
| "/docs/1.11.0/tags/banner"
| "/docs/1.11.0/tags/blocking"
| "/docs/1.11.0/tags/boards"
| "/docs/1.11.0/tags/bookmark"
| "/docs/1.11.0/tags/bookmarks"
| "/docs/1.11.0/tags/caddy"
| "/docs/1.11.0/tags/certificates"
| "/docs/1.11.0/tags/checklist"
| "/docs/1.11.0/tags/code"
| "/docs/1.11.0/tags/community"
| "/docs/1.11.0/tags/configuration"
| "/docs/1.11.0/tags/connections"
| "/docs/1.11.0/tags/customization"
| "/docs/1.11.0/tags/data-sources"
| "/docs/1.11.0/tags/database"
| "/docs/1.11.0/tags/developer"
| "/docs/1.11.0/tags/development"
| "/docs/1.11.0/tags/dns"
| "/docs/1.11.0/tags/docker"
| "/docs/1.11.0/tags/donation"
| "/docs/1.11.0/tags/edit-mode"
| "/docs/1.11.0/tags/env"
| "/docs/1.11.0/tags/environment-variables"
| "/docs/1.11.0/tags/feeds"
| "/docs/1.11.0/tags/getting-started"
| "/docs/1.11.0/tags/google"
| "/docs/1.11.0/tags/grafana"
| "/docs/1.11.0/tags/groups"
| "/docs/1.11.0/tags/hardware"
| "/docs/1.11.0/tags/health"
| "/docs/1.11.0/tags/help"
| "/docs/1.11.0/tags/icon-picker"
| "/docs/1.11.0/tags/icon-repositories"
| "/docs/1.11.0/tags/icons"
| "/docs/1.11.0/tags/iframe"
| "/docs/1.11.0/tags/images"
| "/docs/1.11.0/tags/installation"
| "/docs/1.11.0/tags/integrade"
| "/docs/1.11.0/tags/integration"
| "/docs/1.11.0/tags/integrations"
| "/docs/1.11.0/tags/interface"
| "/docs/1.11.0/tags/jellyserr"
| "/docs/1.11.0/tags/layout"
| "/docs/1.11.0/tags/ldap"
| "/docs/1.11.0/tags/links"
| "/docs/1.11.0/tags/lists"
| "/docs/1.11.0/tags/management"
| "/docs/1.11.0/tags/media"
| "/docs/1.11.0/tags/minecraft"
| "/docs/1.11.0/tags/monitoring"
| "/docs/1.11.0/tags/news"
| "/docs/1.11.0/tags/notebook"
| "/docs/1.11.0/tags/notes"
| "/docs/1.11.0/tags/oidc"
| "/docs/1.11.0/tags/open-collective"
| "/docs/1.11.0/tags/open-media-vault"
| "/docs/1.11.0/tags/overseerr"
| "/docs/1.11.0/tags/permissions"
| "/docs/1.11.0/tags/pgid"
| "/docs/1.11.0/tags/pi-hole"
| "/docs/1.11.0/tags/ping"
| "/docs/1.11.0/tags/programming"
| "/docs/1.11.0/tags/proxmox"
| "/docs/1.11.0/tags/proxy"
| "/docs/1.11.0/tags/puid"
| "/docs/1.11.0/tags/responsive"
| "/docs/1.11.0/tags/roles"
| "/docs/1.11.0/tags/rss"
| "/docs/1.11.0/tags/search"
| "/docs/1.11.0/tags/search-engines"
| "/docs/1.11.0/tags/security"
| "/docs/1.11.0/tags/self-signed"
| "/docs/1.11.0/tags/seo"
| "/docs/1.11.0/tags/server"
| "/docs/1.11.0/tags/settings"
| "/docs/1.11.0/tags/sinkhole"
| "/docs/1.11.0/tags/sso"
| "/docs/1.11.0/tags/system"
| "/docs/1.11.0/tags/table"
| "/docs/1.11.0/tags/technical-documentation"
| "/docs/1.11.0/tags/text"
| "/docs/1.11.0/tags/torrent"
| "/docs/1.11.0/tags/traefik"
| "/docs/1.11.0/tags/translations"
| "/docs/1.11.0/tags/unraid"
| "/docs/1.11.0/tags/uploads"
| "/docs/1.11.0/tags/usenet"
| "/docs/1.11.0/tags/users"
| "/docs/1.11.0/tags/variables"
| "/docs/1.11.0/tags/widgets"
| "/docs/1.11.0/advanced/command-line"
| "/docs/1.11.0/advanced/command-line/fix-usernames"
| "/docs/1.11.0/advanced/command-line/password-recovery"
| "/docs/1.11.0/advanced/development/getting-started"
| "/docs/1.11.0/advanced/development/kubernetes"
| "/docs/1.11.0/advanced/environment-variables"
| "/docs/1.11.0/advanced/icons"
| "/docs/1.11.0/advanced/keyboard-shortcuts"
| "/docs/1.11.0/advanced/proxy"
| "/docs/1.11.0/advanced/running-as-different-user"
| "/docs/1.11.0/advanced/single-sign-on"
| "/docs/1.11.0/category/advanced"
| "/docs/1.11.0/category/community"
| "/docs/1.11.0/category/developer-guides"
| "/docs/1.11.0/category/getting-started"
| "/docs/1.11.0/category/installation"
| "/docs/1.11.0/category/installation-1"
| "/docs/1.11.0/category/integrations"
| "/docs/1.11.0/category/management"
| "/docs/1.11.0/category/widgets"
| "/docs/1.11.0/community/donate"
| "/docs/1.11.0/community/faq"
| "/docs/1.11.0/community/get-in-touch"
| "/docs/1.11.0/community/license"
| "/docs/1.11.0/community/translations"
| "/docs/1.11.0/getting-started"
| "/docs/1.11.0/getting-started/after-the-installation"
| "/docs/1.11.0/getting-started/glossary"
| "/docs/1.11.0/getting-started/installation/docker"
| "/docs/1.11.0/getting-started/installation/easy-panel"
| "/docs/1.11.0/getting-started/installation/helm"
| "/docs/1.11.0/getting-started/installation/home-assistant"
| "/docs/1.11.0/getting-started/installation/portainer"
| "/docs/1.11.0/getting-started/installation/qnap"
| "/docs/1.11.0/getting-started/installation/railway"
| "/docs/1.11.0/getting-started/installation/saltbox"
| "/docs/1.11.0/getting-started/installation/source"
| "/docs/1.11.0/getting-started/installation/synology"
| "/docs/1.11.0/getting-started/installation/unraid"
| "/docs/1.11.0/integrations/containers"
| "/docs/1.11.0/integrations/dns"
| "/docs/1.11.0/integrations/hardware"
| "/docs/1.11.0/integrations/kubernetes"
| "/docs/1.11.0/integrations/media-requester"
| "/docs/1.11.0/integrations/media-server"
| "/docs/1.11.0/integrations/servarr"
| "/docs/1.11.0/integrations/torrent"
| "/docs/1.11.0/integrations/usenet"
| "/docs/1.11.0/management/api"
| "/docs/1.11.0/management/apps"
| "/docs/1.11.0/management/boards"
| "/docs/1.11.0/management/certificates"
| "/docs/1.11.0/management/integrations"
| "/docs/1.11.0/management/media"
| "/docs/1.11.0/management/search-engines"
| "/docs/1.11.0/management/settings"
| "/docs/1.11.0/management/users"
| "/docs/1.11.0/widgets/bookmarks"
| "/docs/1.11.0/widgets/calendar"
| "/docs/1.11.0/widgets/clock"
| "/docs/1.11.0/widgets/dns-hole"
| "/docs/1.11.0/widgets/downloads"
| "/docs/1.11.0/widgets/health-monitoring"
| "/docs/1.11.0/widgets/home-assistant"
| "/docs/1.11.0/widgets/iframe"
| "/docs/1.11.0/widgets/indexer-manager"
| "/docs/1.11.0/widgets/media-requests"
| "/docs/1.11.0/widgets/media-server"
| "/docs/1.11.0/widgets/minecraft-server-status"
| "/docs/1.11.0/widgets/notebook"
| "/docs/1.11.0/widgets/rss"
| "/docs/1.11.0/widgets/video"
| "/docs/1.11.0/widgets/weather"
| "/docs/next/tags"
| "/docs/next/tags/active-directory"
| "/docs/next/tags/ad-guard"
@@ -240,6 +412,7 @@ export type HomarrDocumentationPath =
| "/docs/next/tags/env"
| "/docs/next/tags/environment-variables"
| "/docs/next/tags/feeds"
| "/docs/next/tags/finance"
| "/docs/next/tags/getting-started"
| "/docs/next/tags/google"
| "/docs/next/tags/grafana"
@@ -263,6 +436,7 @@ export type HomarrDocumentationPath =
| "/docs/next/tags/links"
| "/docs/next/tags/lists"
| "/docs/next/tags/management"
| "/docs/next/tags/market"
| "/docs/next/tags/media"
| "/docs/next/tags/minecraft"
| "/docs/next/tags/monitoring"
@@ -293,6 +467,7 @@ export type HomarrDocumentationPath =
| "/docs/next/tags/settings"
| "/docs/next/tags/sinkhole"
| "/docs/next/tags/sso"
| "/docs/next/tags/stocks"
| "/docs/next/tags/system"
| "/docs/next/tags/table"
| "/docs/next/tags/technical-documentation"
@@ -317,6 +492,7 @@ export type HomarrDocumentationPath =
| "/docs/next/advanced/proxy"
| "/docs/next/advanced/running-as-different-user"
| "/docs/next/advanced/single-sign-on"
| "/docs/next/advanced/styling"
| "/docs/next/category/advanced"
| "/docs/next/category/community"
| "/docs/next/category/developer-guides"
@@ -346,6 +522,7 @@ export type HomarrDocumentationPath =
| "/docs/next/getting-started/installation/source"
| "/docs/next/getting-started/installation/synology"
| "/docs/next/getting-started/installation/unraid"
| "/docs/next/integrations/cloud"
| "/docs/next/integrations/containers"
| "/docs/next/integrations/dns"
| "/docs/next/integrations/hardware"
@@ -378,6 +555,7 @@ export type HomarrDocumentationPath =
| "/docs/next/widgets/minecraft-server-status"
| "/docs/next/widgets/notebook"
| "/docs/next/widgets/rss"
| "/docs/next/widgets/stocks"
| "/docs/next/widgets/video"
| "/docs/next/widgets/weather"
| "/docs/tags"
@@ -413,6 +591,7 @@ export type HomarrDocumentationPath =
| "/docs/tags/env"
| "/docs/tags/environment-variables"
| "/docs/tags/feeds"
| "/docs/tags/finance"
| "/docs/tags/getting-started"
| "/docs/tags/google"
| "/docs/tags/grafana"
@@ -436,6 +615,7 @@ export type HomarrDocumentationPath =
| "/docs/tags/links"
| "/docs/tags/lists"
| "/docs/tags/management"
| "/docs/tags/market"
| "/docs/tags/media"
| "/docs/tags/minecraft"
| "/docs/tags/monitoring"
@@ -466,6 +646,7 @@ export type HomarrDocumentationPath =
| "/docs/tags/settings"
| "/docs/tags/sinkhole"
| "/docs/tags/sso"
| "/docs/tags/stocks"
| "/docs/tags/system"
| "/docs/tags/table"
| "/docs/tags/technical-documentation"
@@ -512,6 +693,7 @@ export type HomarrDocumentationPath =
| "/docs/getting-started/installation/helm"
| "/docs/getting-started/installation/home-assistant"
| "/docs/getting-started/installation/portainer"
| "/docs/getting-started/installation/proxmox"
| "/docs/getting-started/installation/qnap"
| "/docs/getting-started/installation/railway"
| "/docs/getting-started/installation/saltbox"
@@ -550,6 +732,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/minecraft-server-status"
| "/docs/widgets/notebook"
| "/docs/widgets/rss"
| "/docs/widgets/stocks"
| "/docs/widgets/video"
| "/docs/widgets/weather"
| ""

View File

@@ -3,5 +3,12 @@ import type { HomarrDocumentationPath } from "./homarr-docs-sitemap";
const documentationBaseUrl = "https://homarr.dev";
// Please use the method so the path can be checked!
export const createDocumentationLink = (path: HomarrDocumentationPath, hashTag?: `#${string}`) =>
`${documentationBaseUrl}${path}${hashTag ?? ""}`;
export const createDocumentationLink = (
path: HomarrDocumentationPath,
hashTag?: `#${string}`,
queryParams?: Record<string, string>,
) => {
const url = `${documentationBaseUrl}${path}`;
const params = queryParams ? `?${new URLSearchParams(queryParams)}` : "";
return `${url}${params}${hashTag ?? ""}`;
};

View File

@@ -0,0 +1,47 @@
/* eslint-disable no-restricted-syntax */
import { describe, expect, test } from "vitest";
import { createDocumentationLink } from "../docs";
import type { HomarrDocumentationPath } from "../docs/homarr-docs-sitemap";
describe("createDocumentationLink should generate correct URLs", () => {
test.each([
["/docs/getting-started", undefined, undefined, "https://homarr.dev/docs/getting-started"],
["/blog", undefined, undefined, "https://homarr.dev/blog"],
["/docs/widgets/weather", "#configuration", undefined, "https://homarr.dev/docs/widgets/weather#configuration"],
[
"/docs/advanced/environment-variables",
undefined,
{ lang: "en" },
"https://homarr.dev/docs/advanced/environment-variables?lang=en",
],
[
"/docs/widgets/bookmarks",
"#sorting",
{ lang: "fr", theme: "dark" },
"https://homarr.dev/docs/widgets/bookmarks?lang=fr&theme=dark#sorting",
],
] satisfies [HomarrDocumentationPath, `#${string}` | undefined, Record<string, string> | undefined, string][])(
"should create correct URL for path %s with hash %s and params %o",
(path, hashTag, queryParams, expected) => {
expect(createDocumentationLink(path, hashTag, queryParams)).toBe(expected);
},
);
});
describe("createDocumentationLink parameter validation", () => {
test("should work with only path parameter", () => {
const result = createDocumentationLink("/docs/getting-started");
expect(result).toBe("https://homarr.dev/docs/getting-started");
});
test("should work with path and hashtag", () => {
const result = createDocumentationLink("/docs/getting-started", "#installation");
expect(result).toBe("https://homarr.dev/docs/getting-started#installation");
});
test("should work with path and query params", () => {
const result = createDocumentationLink("/docs/getting-started", undefined, { version: "1.0" });
expect(result).toBe("https://homarr.dev/docs/getting-started?version=1.0");
});
});

View File

@@ -31,8 +31,8 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.35",
"eslint": "^9.22.0",
"@types/dockerode": "^3.3.36",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -26,14 +26,14 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.17.2",
"@mantine/form": "^7.17.3",
"zod": "^3.24.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -29,7 +29,7 @@
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.17.2",
"@mantine/core": "^7.17.3",
"react": "19.0.0",
"zod": "^3.24.2"
},
@@ -37,7 +37,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -33,9 +33,17 @@ interface IconPickerProps {
error?: string | null;
onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler;
withAsterisk?: boolean;
}
export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur }: IconPickerProps) => {
export const IconPicker = ({
value: propsValue,
onChange,
error,
onFocus,
onBlur,
withAsterisk = true,
}: IconPickerProps) => {
const [value, setValue] = useUncontrolled({
value: propsValue,
onChange,
@@ -145,7 +153,7 @@ export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur
setSearch(value || "");
}}
rightSectionPointerEvents="none"
withAsterisk
withAsterisk={withAsterisk}
error={error}
label={tCommon("iconPicker.label")}
placeholder={tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}

View File

@@ -1,17 +1,19 @@
"use client";
import type { ChangeEventHandler } from "react";
import { useRef } from "react";
import { useEffect, useRef } from "react";
import Link from "next/link";
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { IconPicker } from "../icon-picker/icon-picker";
import { findBestIconMatch } from "./icon-matcher";
type FormType = z.infer<typeof validation.app.manage>;
@@ -45,6 +47,9 @@ export const AppForm = ({
},
});
// Debounce the name value with 200ms delay
const [debouncedName] = useDebouncedValue(form.values.name, 200);
const shouldCreateAnother = useRef(false);
const handleSubmit = (values: FormType) => {
const redirect = !shouldCreateAnother.current;
@@ -68,6 +73,25 @@ export const AppForm = ({
}
};
// Auto-select icon based on app name with debounced search
const { data: iconsData } = clientApi.icon.findIcons.useQuery(
{
searchText: debouncedName,
},
{
enabled: debouncedName.length > 3,
},
);
useEffect(() => {
if (debouncedName && !form.values.iconUrl && iconsData?.icons) {
const bestMatch = findBestIconMatch(debouncedName, iconsData.icons);
if (bestMatch) {
form.setFieldValue("iconUrl", bestMatch);
}
}
}, [debouncedName, iconsData]);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>

View File

@@ -0,0 +1,30 @@
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "@homarr/api";
type RouterOutput = inferRouterOutputs<AppRouter>;
type IconGroupsOutput = RouterOutput["icon"]["findIcons"]["icons"];
export const findBestIconMatch = (searchTerm: string, iconGroups: IconGroupsOutput): string | null => {
const nameLower = searchTerm.toLowerCase();
const allIcons = iconGroups.flatMap((group) => group.icons);
const getIconPriority = (iconUrl: string) => {
const fileName = iconUrl.toLowerCase().split("/").pop()?.split(".")[0];
if (!fileName) return -1;
const isSvg = iconUrl.endsWith(".svg");
const isExactMatch = fileName === nameLower;
if (isExactMatch) return isSvg ? 0 : 1;
if (fileName.includes(nameLower)) return isSvg ? 2 : 3;
return -1;
};
for (let priority = 0; priority <= 3; priority++) {
const match = allIcons.find((icon) => getIconPriority(icon.url) === priority);
if (match) return match.url;
}
return null;
};

View File

@@ -31,7 +31,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -39,7 +39,7 @@
"node-ical": "^0.20.1",
"proxmox-api": "1.1.1",
"tsdav": "^2.1.3",
"undici": "7.5.0",
"undici": "7.6.0",
"xml2js": "^0.6.2",
"zod": "^3.24.2"
},
@@ -48,7 +48,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/xml2js": "^0.4.14",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -280,6 +280,7 @@ const mediaInformationSchema = z.union([
seasons: z.array(
z.object({
id: z.number(),
seasonNumber: z.number(),
name: z.string().min(0),
episodeCount: z.number().min(0),
}),

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

50
packages/log/src/error.ts Normal file
View File

@@ -0,0 +1,50 @@
import { formatMetadata } from "./metadata";
/**
* Formats the cause of an error in the format
* @example caused by Error: {message}
* {stack-trace}
* @param cause next cause in the chain
* @param iteration current iteration of the function
* @returns formatted and stacked causes
*/
export const formatErrorCause = (cause: unknown, iteration = 0): string => {
// Prevent infinite recursion
if (iteration > 5) {
return "";
}
if (cause instanceof Error) {
if (!cause.cause) {
return `\ncaused by ${formatErrorTitle(cause)}\n${formatErrorStack(cause.stack)}`;
}
return `\ncaused by ${formatErrorTitle(cause)}\n${formatErrorStack(cause.stack)}${formatErrorCause(cause.cause, iteration + 1)}`;
}
return `\ncaused by ${cause as string}`;
};
const ignoredErrorProperties = ["stack", "message", "name", "cause"];
/**
* Formats the title of an error
* @example {name}: {message} {metadata}
* @param error error to format title from
* @returns formatted error title
*/
export const formatErrorTitle = (error: Error) => {
const title = error.message.length === 0 ? error.name : `${error.name}: ${error.message}`;
const metadata = formatMetadata(error, ignoredErrorProperties);
return `${title} ${metadata}`;
};
/**
* Formats the stack trance of an error
* We remove the first line as it contains the error name and message
* @param stack stack trace
* @returns formatted stack trace
*/
export const formatErrorStack = (stack: string | undefined) => (stack ? removeFirstLine(stack) : "");
const removeFirstLine = (stack: string) => stack.split("\n").slice(1).join("\n");

View File

@@ -2,43 +2,22 @@ import type { transport as Transport } from "winston";
import winston, { format, transports } from "winston";
import { env } from "./env";
import { formatErrorCause, formatErrorStack } from "./error";
import { formatMetadata } from "./metadata";
import { RedisTransport } from "./redis-transport";
/**
* Formats the cause of an error in the format
* @example caused by Error: {message}
* {stack-trace}
* @param cause next cause in the chain
* @param iteration current iteration of the function
* @returns formatted and stacked causes
*/
const formatCause = (cause: unknown, iteration = 0): string => {
// Prevent infinite recursion
if (iteration > 5) {
return "";
}
if (cause instanceof Error) {
if (!cause.cause) {
return `\ncaused by ${cause.stack}`;
}
return `\ncaused by ${cause.stack}${formatCause(cause.cause, iteration + 1)}`;
}
return `\ncaused by ${cause as string}`;
};
const logMessageFormat = format.printf(({ level, message, timestamp, cause, stack }) => {
const logMessageFormat = format.printf(({ level, message, timestamp, cause, stack, ...metadata }) => {
if (!cause && !stack) {
return `${timestamp as string} ${level}: ${message as string}`;
}
const formatedStack = formatErrorStack(stack as string | undefined);
if (!cause) {
return `${timestamp as string} ${level}: ${message as string}\n${stack as string}`;
return `${timestamp as string} ${level}: ${message as string} ${formatMetadata(metadata)}\n${formatedStack}`;
}
return `${timestamp as string} ${level}: ${message as string}\n${stack as string}${formatCause(cause)}`;
return `${timestamp as string} ${level}: ${message as string} ${formatMetadata(metadata)}\n${formatedStack}${formatErrorCause(cause)}`;
});
const logTransports: Transport[] = [new transports.Console()];

View File

@@ -0,0 +1,8 @@
export const formatMetadata = (metadata: Record<string, unknown> | Error, ignoreKeys?: string[]) => {
const filteredMetadata = Object.keys(metadata)
.filter((key) => !ignoreKeys?.includes(key))
.map((key) => ({ key, value: metadata[key as keyof typeof metadata] }))
.filter(({ value }) => typeof value !== "object" && typeof value !== "function");
return filteredMetadata.map(({ key, value }) => `${key}="${value as string}"`).join(" ");
};

View File

@@ -17,10 +17,8 @@ export class RedisTransport extends Transport {
this.emit("logged", info);
});
if (!this.redis) {
// Is only initialized here because it did not work when initialized in the constructor or outside the class
this.redis = new Redis();
}
// Is only initialized here because it did not work when initialized in the constructor or outside the class
this.redis ??= new Redis();
this.redis
.publish(

View File

@@ -33,10 +33,10 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.17.2",
"@mantine/core": "^7.17.3",
"@tabler/icons-react": "^3.31.0",
"dayjs": "^1.11.13",
"next": "15.1.7",
"next": "15.2.4",
"react": "19.0.0",
"react-dom": "19.0.0",
"zod": "^3.24.2"
@@ -45,7 +45,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -72,7 +72,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
const anySelected = Object.keys(table.getState().rowSelection).length > 0;
const handleMutate = () => {
const selectedSeasons = table.getSelectedRowModel().rows.flatMap((row) => row.original.id);
const selectedSeasons = table.getSelectedRowModel().rows.flatMap((row) => row.original.seasonNumber);
mutate({
integrationId: innerProps.integrationId,
mediaId: innerProps.mediaId,
@@ -114,6 +114,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
interface Season {
id: number;
seasonNumber: number;
name: string;
episodeCount: number;
}

View File

@@ -24,15 +24,15 @@
"dependencies": {
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.17.2",
"@mantine/hooks": "^7.17.2",
"@mantine/core": "^7.17.3",
"@mantine/hooks": "^7.17.3",
"react": "19.0.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -24,14 +24,14 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.17.2",
"@mantine/notifications": "^7.17.3",
"@tabler/icons-react": "^3.31.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -26,6 +26,7 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
@@ -37,10 +38,10 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.17.2",
"@mantine/hooks": "^7.17.2",
"@mantine/core": "^7.17.3",
"@mantine/hooks": "^7.17.3",
"adm-zip": "0.5.16",
"next": "15.1.7",
"next": "15.2.4",
"react": "19.0.0",
"react-dom": "19.0.0",
"superjson": "2.2.2",
@@ -52,7 +53,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "0.5.7",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -1,4 +1,4 @@
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
import type { OldmarrConfig } from "@homarr/old-schema";
export class OldHomarrImportError extends Error {
constructor(oldConfig: OldmarrConfig, cause: unknown) {
@@ -7,9 +7,3 @@ export class OldHomarrImportError extends Error {
});
}
}
export class OldHomarrScreenSizeError extends Error {
constructor(type: "app" | "widget", id: string, screenSize: BoardSize) {
super(`Screen size not found for type=${type} id=${id} screenSize=${screenSize}`);
}
}

View File

@@ -1,10 +1,14 @@
import type { Session } from "@homarr/auth";
import { isWidgetRestricted } from "@homarr/auth/shared";
import { createId } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { logger } from "@homarr/log";
import type { BoardSize } from "@homarr/old-schema";
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
import { boardSizes, getBoardSizeName } from "@homarr/old-schema";
import { widgetImports } from "../../../../widgets/src";
import { fixSectionIssues } from "../../fix-section-issues";
import { OldHomarrImportError } from "../../import-error";
import { mapBoard } from "../../mappers/map-board";
import { mapBreakpoint } from "../../mappers/map-breakpoint";
import { mapColumnCount } from "../../mappers/map-column-count";
@@ -17,6 +21,7 @@ import type { InitialOldmarrImportSettings } from "../../settings";
export const createBoardInsertCollection = (
{ preparedApps, preparedBoards }: Omit<ReturnType<typeof prepareMultipleImports>, "preparedIntegrations">,
settings: InitialOldmarrImportSettings,
session: Session | null,
) => {
const insertCollection = createDbInsertCollectionForTransaction([
"apps",
@@ -57,6 +62,13 @@ export const createBoardInsertCollection = (
logger.debug(`Added apps to board insert collection count=${insertCollection.apps.length}`);
preparedBoards.forEach((board) => {
if (!hasEnoughItemShapes(board.config)) {
throw new OldHomarrImportError(
board.config,
new Error("Your config contains items without shapes for all board sizes."),
);
}
const { wrappers, categories, wrapperIdsToMerge } = fixSectionIssues(board.config);
const { apps, widgets } = moveWidgetsAndAppsIfMerge(board.config, wrapperIdsToMerge, {
...settings,
@@ -105,10 +117,18 @@ export const createBoardInsertCollection = (
layoutMapping,
mappedBoard.id,
);
preparedItems.forEach(({ layouts, ...item }) => {
insertCollection.items.push(item);
insertCollection.itemLayouts.push(...layouts);
});
preparedItems
.filter((item) => {
return !isWidgetRestricted({
definition: widgetImports[item.kind].definition,
user: session?.user ?? null,
check: (level) => level !== "none",
});
})
.forEach(({ layouts, ...item }) => {
insertCollection.items.push(item);
insertCollection.itemLayouts.push(...layouts);
});
logger.debug(`Added items to board insert collection count=${insertCollection.items.length}`);
});
@@ -118,3 +138,26 @@ export const createBoardInsertCollection = (
return insertCollection;
};
export const hasEnoughItemShapes = (config: {
apps: Pick<OldmarrConfig["apps"][number], "shape">[];
widgets: Pick<OldmarrConfig["widgets"][number], "shape">[];
}) => {
const invalidSizes: BoardSize[] = [];
for (const size of boardSizes) {
if (invalidSizes.includes(size)) continue;
if (config.apps.some((app) => app.shape[size] === undefined)) {
invalidSizes.push(size);
}
if (invalidSizes.includes(size)) continue;
if (config.widgets.some((widget) => widget.shape[size] === undefined)) {
invalidSizes.push(size);
}
}
return invalidSizes.length <= 2;
};

View File

@@ -1,5 +1,6 @@
import type { z } from "zod";
import type { Session } from "@homarr/auth";
import { Stopwatch } from "@homarr/common";
import { handleTransactionsAsync } from "@homarr/db";
import type { Database } from "@homarr/db";
@@ -16,6 +17,7 @@ import { ensureValidTokenOrThrow } from "./validate-token";
export const importInitialOldmarrAsync = async (
db: Database,
input: z.infer<typeof importInitialOldmarrInputSchema>,
session: Session | null,
) => {
const stopwatch = new Stopwatch();
const { checksum, configs, users: importUsers } = await analyseOldmarrImportAsync(input.file);
@@ -29,7 +31,7 @@ export const importInitialOldmarrAsync = async (
logger.info("Preparing import data in insert collections for database");
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, input.settings);
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, input.settings, session);
const userInsertCollection = createUserInsertCollection(importUsers, input.token);
const integrationInsertCollection = createIntegrationInsertCollection(preparedIntegrations, input.token);

View File

@@ -1,3 +1,4 @@
import type { Session } from "@homarr/auth";
import { handleTransactionsAsync, inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import { apps } from "@homarr/db/schema";
@@ -12,6 +13,7 @@ export const importSingleOldmarrConfigAsync = async (
db: Database,
config: OldmarrConfig,
settings: OldmarrImportConfiguration,
session: Session | null,
) => {
const { preparedApps, preparedBoards } = prepareSingleImport(config, settings);
const existingApps = await db.query.apps.findMany({
@@ -29,7 +31,7 @@ export const importSingleOldmarrConfigAsync = async (
return app;
});
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, settings);
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, settings, session);
await handleTransactionsAsync(db, {
async handleAsync(db) {

View File

@@ -0,0 +1,53 @@
import { describe, expect, test } from "vitest";
import type { BoardSize } from "@homarr/old-schema";
import { hasEnoughItemShapes } from "../collections/board-collection";
const defaultShape = {
location: {
x: 0,
y: 0,
},
size: {
width: 1,
height: 1,
},
};
describe("hasEnoughItemShapes should check if there are more than one shape available for automatic reconstruction", () => {
test.each([
[true, [], []], // no items, so nothing to check
[true, [{ lg: true }], []], // lg always exists
[true, [], [{ md: true }]], // md always exists
[true, [{ md: true, sm: true }], [{ md: true, lg: true }]], // md always exists
[true, [{ md: true }], [{ md: true }]], // md always exists
[false, [{ md: true }, { md: true }], [{ lg: true }]], // md is missing for widgets
[false, [{ md: true }], [{ lg: true }]], // md is missing for widgets
[false, [{ md: true }], [{ md: true, lg: true }, { lg: true }]], // md is missing for 2. widget
] as [boolean, Shape[], Shape[]][])(
"should return %s if there are more than one shape available",
(returnValue, appShapes, widgetShapes) => {
const result = hasEnoughItemShapes({
apps: appShapes.map((shapes) => ({
shape: {
sm: shapes.sm ? defaultShape : undefined,
md: shapes.md ? defaultShape : undefined,
lg: shapes.lg ? defaultShape : undefined,
},
})),
widgets: widgetShapes.map((shapes) => ({
shape: {
sm: shapes.sm ? defaultShape : undefined,
md: shapes.md ? defaultShape : undefined,
lg: shapes.lg ? defaultShape : undefined,
},
})),
});
expect(result).toBe(returnValue);
},
);
});
type Shape = Partial<Record<BoardSize, true>>;

View File

@@ -1,3 +1,4 @@
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import type { OldmarrConfig } from "@homarr/old-schema";
@@ -8,6 +9,7 @@ export const importOldmarrAsync = async (
db: Database,
old: OldmarrConfig,
configuration: OldmarrImportConfiguration,
session: Session | null,
) => {
await importSingleOldmarrConfigAsync(db, old, configuration);
await importSingleOldmarrConfigAsync(db, old, configuration, session);
};

View File

@@ -3,7 +3,6 @@ import { logger } from "@homarr/log";
import type { BoardSize, OldmarrApp, OldmarrConfig, OldmarrWidget } from "@homarr/old-schema";
import { boardSizes } from "@homarr/old-schema";
import { OldHomarrScreenSizeError } from "./import-error";
import { mapColumnCount } from "./mappers/map-column-count";
import type { OldmarrImportConfiguration } from "./settings";
@@ -60,7 +59,7 @@ export const moveWidgetsAndAppsIfMerge = (
for (const screenSize of boardSizes) {
const screenSizeShape = app.shape[screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("app", app.id, screenSize);
continue;
}
// Find the highest widget in the wrapper to increase the offset accordingly
@@ -81,7 +80,7 @@ export const moveWidgetsAndAppsIfMerge = (
for (const screenSize of boardSizes) {
const screenSizeShape = widget.shape[screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("widget", widget.id, screenSize);
continue;
}
// Find the highest widget in the wrapper to increase the offset accordingly
@@ -145,15 +144,6 @@ const moveWidgetsAndAppsInLeftSidebar = (
item.area.properties.location === "left" &&
(columnCount >= 2 || item.shape[screenSize]?.location.x === 0),
update: (item) => {
const screenSizeShape = item.shape[screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("kind" in item ? "widget" : "app", item.id, screenSize);
}
// Reduce width to one if column count is one
if (screenSizeShape.size.width > columnCount) {
screenSizeShape.size.width = columnCount;
}
item.area = {
type: "wrapper",
properties: {
@@ -161,6 +151,14 @@ const moveWidgetsAndAppsInLeftSidebar = (
},
};
const screenSizeShape = item.shape[screenSize];
if (!screenSizeShape) return;
// Reduce width to one if column count is one
if (screenSizeShape.size.width > columnCount) {
screenSizeShape.size.width = columnCount;
}
screenSizeShape.location.y += offset;
},
});
@@ -184,11 +182,6 @@ const moveWidgetsAndAppsInLeftSidebar = (
item.area.properties.location === "left" &&
item.shape[screenSize]?.location.x === 1,
update: (item) => {
const screenSizeShape = item.shape[screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("kind" in item ? "widget" : "app", item.id, screenSize);
}
item.area = {
type: "wrapper",
properties: {
@@ -196,6 +189,9 @@ const moveWidgetsAndAppsInLeftSidebar = (
},
};
const screenSizeShape = item.shape[screenSize];
if (!screenSizeShape) return;
screenSizeShape.location.x = 0;
screenSizeShape.location.y += offset;
},
@@ -222,16 +218,6 @@ const moveWidgetsAndAppsInRightSidebar = (
item.area.properties.location === "right" &&
(columnCount >= 2 || item.shape[screenSize]?.location.x === 0),
update: (item) => {
const screenSizeShape = item.shape[screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("kind" in item ? "widget" : "app", item.id, screenSize);
}
// Reduce width to one if column count is one
if (screenSizeShape.size.width > columnCount) {
screenSizeShape.size.width = columnCount;
}
item.area = {
type: "wrapper",
properties: {
@@ -239,6 +225,14 @@ const moveWidgetsAndAppsInRightSidebar = (
},
};
const screenSizeShape = item.shape[screenSize];
if (!screenSizeShape) return;
// Reduce width to one if column count is one
if (screenSizeShape.size.width > columnCount) {
screenSizeShape.size.width = columnCount;
}
screenSizeShape.location.y += offset;
screenSizeShape.location.x += xOffsetDelta;
},
@@ -260,11 +254,6 @@ const moveWidgetsAndAppsInRightSidebar = (
item.area.properties.location === "left" &&
item.shape[screenSize]?.location.x === 1,
update: (item) => {
const screenSizeShape = item.shape[screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("kind" in item ? "widget" : "app", item.id, screenSize);
}
item.area = {
type: "wrapper",
properties: {
@@ -272,6 +261,9 @@ const moveWidgetsAndAppsInRightSidebar = (
},
};
const screenSizeShape = item.shape[screenSize];
if (!screenSizeShape) return;
screenSizeShape.location.x = 0;
screenSizeShape.location.y += offset;
},
@@ -312,16 +304,15 @@ const updateItems = (options: {
for (const item of items) {
const before = createItemSnapshot(item, options.screenSize);
options.update(item);
const screenSizeShape = item.shape[options.screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("kind" in item ? "widget" : "app", item.id, options.screenSize);
}
if (!screenSizeShape) return requiredHeight;
if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeight) {
requiredHeight = screenSizeShape.location.y + screenSizeShape.size.height;
}
options.update(item);
const after = createItemSnapshot(item, options.screenSize);
logger.debug(

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -31,7 +31,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -1,16 +1,16 @@
import { formatError } from "pretty-print-error";
import type { fetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { extractErrorMessage } from "@homarr/common";
import { logger } from "@homarr/log";
export const sendPingRequestAsync = async (url: string) => {
try {
return await fetchWithTimeoutAndCertificates(url).then((response) => ({ statusCode: response.status }));
} catch (error) {
logger.error("packages/ping/src/index.ts:", formatError(error));
logger.error(new Error(`Failed to send ping request to "${url}"`, { cause: error }));
return {
error: formatError(error),
error: extractErrorMessage(error),
};
}
};

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -38,7 +38,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -1,4 +1,3 @@
import { formatError } from "pretty-print-error";
import SuperJSON from "superjson";
import { hashObjectBase64, Stopwatch } from "@homarr/common";
@@ -107,7 +106,9 @@ export const createRequestIntegrationJobHandler = <
);
} catch (error) {
logger.error(
`Failed to run integration job integration=${integrationId} inputHash='${inputHash}' error=${formatError(error)}`,
new Error(`Failed to run integration job integration=${integrationId} inputHash='${inputHash}'`, {
cause: error,
}),
);
}
}

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -25,8 +25,8 @@
"@homarr/api": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@mantine/dates": "^7.17.2",
"next": "15.1.7",
"@mantine/dates": "^7.17.3",
"next": "15.2.4",
"react": "19.0.0",
"react-dom": "19.0.0"
},
@@ -34,7 +34,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -33,12 +33,12 @@
"@homarr/settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.17.2",
"@mantine/hooks": "^7.17.2",
"@mantine/spotlight": "^7.17.2",
"@mantine/core": "^7.17.3",
"@mantine/hooks": "^7.17.3",
"@mantine/spotlight": "^7.17.3",
"@tabler/icons-react": "^3.31.0",
"jotai": "^2.12.2",
"next": "15.1.7",
"next": "15.2.4",
"react": "19.0.0",
"react-dom": "19.0.0",
"use-deep-compare-effect": "^1.8.1"
@@ -47,7 +47,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -32,7 +32,7 @@
"dayjs": "^1.11.13",
"deepmerge": "4.3.1",
"mantine-react-table": "2.0.0-beta.9",
"next": "15.1.7",
"next": "15.2.4",
"next-intl": "4.0.2",
"react": "19.0.0",
"react-dom": "19.0.0"
@@ -41,7 +41,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.22.0",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}
}

View File

@@ -60,9 +60,31 @@ export const localeConfigurations = {
return import("dayjs/locale/de").then((module) => module.default);
},
},
"de-CH": {
name: "Deutsch (Schweiz)",
translatedName: "German (Swiss)",
flagIcon: "ch",
importMrtLocalization() {
return import("mantine-react-table/locales/de/index.esm.mjs").then((module) => module.MRT_Localization_DE);
},
importDayJsLocale() {
return import("dayjs/locale/de-ch").then((module) => module.default);
},
},
"en-gb": {
name: "English (UK)",
translatedName: "English (UK)",
flagIcon: "gb",
importMrtLocalization() {
return import("mantine-react-table/locales/en/index.esm.mjs").then((module) => module.MRT_Localization_EN);
},
importDayJsLocale() {
return import("dayjs/locale/en-gb").then((module) => module.default);
},
},
en: {
name: "English",
translatedName: "English",
name: "English (US)",
translatedName: "English (US)",
flagIcon: "us",
importMrtLocalization() {
return import("mantine-react-table/locales/en/index.esm.mjs").then((module) => module.MRT_Localization_EN);

View File

@@ -980,6 +980,9 @@
"remove": ""
},
"option": {
"title": {
"label": ""
},
"borderColor": {
"label": ""
}
@@ -1721,7 +1724,11 @@
"noIntegration": "",
"noData": ""
},
"option": {}
"option": {},
"restricted": {
"title": "",
"description": ""
}
},
"video": {
"name": "",

View File

@@ -980,6 +980,9 @@
"remove": "删除动态部分"
},
"option": {
"title": {
"label": ""
},
"borderColor": {
"label": "边界颜色"
}
@@ -1721,7 +1724,11 @@
"noIntegration": "未选择集成",
"noData": "没有可用的集成数据"
},
"option": {}
"option": {},
"restricted": {
"title": "",
"description": ""
}
},
"video": {
"name": "视频流",

View File

@@ -980,6 +980,9 @@
"remove": "Odstranit dynamickou sekci"
},
"option": {
"title": {
"label": ""
},
"borderColor": {
"label": ""
}
@@ -1721,7 +1724,11 @@
"noIntegration": "Nebyla vybrána žádná integrace",
"noData": "Nejsou k dispozici žádná data o integraci"
},
"option": {}
"option": {},
"restricted": {
"title": "",
"description": ""
}
},
"video": {
"name": "Streamování videa",

View File

@@ -980,6 +980,9 @@
"remove": "Fjern dynamisk sektion"
},
"option": {
"title": {
"label": ""
},
"borderColor": {
"label": "Kantfarve"
}
@@ -1425,76 +1428,76 @@
}
},
"stockPrice": {
"name": "",
"description": "",
"name": "Aktiepris",
"description": "Viser den aktuelle aktiepris for en virksomhed",
"option": {
"stock": {
"label": ""
"label": "Aktiesymbol"
},
"timeRange": {
"label": "",
"label": "Tidsinterval",
"option": {
"1d": {
"label": ""
"label": "1 dag"
},
"5d": {
"label": ""
"label": "5 Dage"
},
"1mo": {
"label": ""
"label": "1 Måned"
},
"3mo": {
"label": ""
"label": "3 Måneder"
},
"6mo": {
"label": ""
"label": "6 Måneder"
},
"ytd": {
"label": ""
"label": "Året indtil nu"
},
"1y": {
"label": ""
"label": "1 År"
},
"2y": {
"label": ""
"label": "2 År"
},
"5y": {
"label": ""
"label": "5 År"
},
"10y": {
"label": ""
"label": "10 År"
},
"max": {
"label": ""
"label": "Maks"
}
}
},
"timeInterval": {
"label": "",
"label": "Tidsinterval",
"option": {
"5m": {
"label": ""
"label": "5 Minutter"
},
"15m": {
"label": ""
"label": "15 Minutter"
},
"30m": {
"label": ""
"label": "30 Minutter"
},
"1h": {
"label": ""
"label": "1 Time"
},
"1d": {
"label": ""
"label": "1 Dag"
},
"5d": {
"label": ""
"label": "5 Dage"
},
"1wk": {
"label": ""
"label": "1 Uge"
},
"1mo": {
"label": ""
"label": "1 Måned"
}
}
}
@@ -1721,7 +1724,11 @@
"noIntegration": "Ingen integration valgt",
"noData": "Ingen tilgængelige integrationsdata"
},
"option": {}
"option": {},
"restricted": {
"title": "",
"description": ""
}
},
"video": {
"name": "Video Stream",

File diff suppressed because it is too large Load Diff

View File

@@ -980,6 +980,9 @@
"remove": "Dynamischen Abschnitt entfernen"
},
"option": {
"title": {
"label": ""
},
"borderColor": {
"label": "Rahmenfarbe"
}
@@ -1721,7 +1724,11 @@
"noIntegration": "Keine Integration ausgewählt",
"noData": "Keine Integrationsdaten verfügbar"
},
"option": {}
"option": {},
"restricted": {
"title": "",
"description": ""
}
},
"video": {
"name": "Videostream",

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