chore(release): automatic release v1.13.0
This commit is contained in:
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -31,6 +31,7 @@ body:
|
||||
label: Version
|
||||
description: What version of Homarr are you running?
|
||||
options:
|
||||
- 1.12.0
|
||||
- 1.11.0
|
||||
- 1.10.0
|
||||
- 1.9.0
|
||||
|
||||
5
.github/renovate.json5
vendored
5
.github/renovate.json5
vendored
@@ -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
14
.run/All Tests.run.xml
Normal 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>
|
||||
8
.vscode/i18n-ally-custom-framework.yml
vendored
8
.vscode/i18n-ally-custom-framework.yml
vendored
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export class DynamicSectionMockBuilder {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
title: "",
|
||||
borderColor: "",
|
||||
},
|
||||
layouts: [],
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
28
apps/nextjs/src/components/board/items/restricted.tsx
Normal file
28
apps/nextjs/src/components/board/items/restricted.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -17,6 +17,7 @@ export const addDynamicSectionCallback = () => (board: Board) => {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
title: "",
|
||||
borderColor: "",
|
||||
},
|
||||
layouts: createDynamicSectionLayouts(board, firstSection),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./board-permissions";
|
||||
export * from "./integration-permissions";
|
||||
export * from "./widget-restriction";
|
||||
|
||||
14
packages/auth/permissions/widget-restriction.ts
Normal file
14
packages/auth/permissions/widget-restriction.ts
Normal 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 }));
|
||||
};
|
||||
@@ -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(",")}'`);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(",")) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
12
packages/cron-job-runner/src/register.ts
Normal file
12
packages/cron-job-runner/src/register.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
| ""
|
||||
|
||||
@@ -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 ?? ""}`;
|
||||
};
|
||||
|
||||
47
packages/definitions/src/test/docs.spec.ts
Normal file
47
packages/definitions/src/test/docs.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
2
packages/env/package.json
vendored
2
packages/env/package.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) })}
|
||||
|
||||
@@ -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>
|
||||
|
||||
30
packages/forms-collection/src/new-app/icon-matcher.ts
Normal file
30
packages/forms-collection/src/new-app/icon-matcher.ts
Normal 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;
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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
50
packages/log/src/error.ts
Normal 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");
|
||||
@@ -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()];
|
||||
|
||||
8
packages/log/src/metadata.ts
Normal file
8
packages/log/src/metadata.ts
Normal 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(" ");
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
53
packages/old-import/src/import/test/board-collection.spec.ts
Normal file
53
packages/old-import/src/import/test/board-collection.spec.ts
Normal 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>>;
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -980,6 +980,9 @@
|
||||
"remove": ""
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"borderColor": {
|
||||
"label": ""
|
||||
}
|
||||
@@ -1721,7 +1724,11 @@
|
||||
"noIntegration": "",
|
||||
"noData": ""
|
||||
},
|
||||
"option": {}
|
||||
"option": {},
|
||||
"restricted": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"video": {
|
||||
"name": "",
|
||||
|
||||
@@ -980,6 +980,9 @@
|
||||
"remove": "删除动态部分"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"borderColor": {
|
||||
"label": "边界颜色"
|
||||
}
|
||||
@@ -1721,7 +1724,11 @@
|
||||
"noIntegration": "未选择集成",
|
||||
"noData": "没有可用的集成数据"
|
||||
},
|
||||
"option": {}
|
||||
"option": {},
|
||||
"restricted": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"video": {
|
||||
"name": "视频流",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
3759
packages/translation/src/lang/de-CH.json
Normal file
3759
packages/translation/src/lang/de-CH.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user