Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
457b970d8f | ||
|
|
8d69970562 | ||
|
|
8a071f9ab6 | ||
|
|
76b67ac8e1 | ||
|
|
eba738e5d6 | ||
|
|
996ec548c4 | ||
|
|
40e00987cb | ||
|
|
93c64baec2 | ||
|
|
b5b0b069d2 | ||
|
|
62c7955e48 | ||
|
|
217818573e | ||
|
|
70879ccb90 | ||
|
|
34eb9b366f | ||
|
|
ac77bdd84c | ||
|
|
70ea093eb6 | ||
|
|
08e0dd76ae | ||
|
|
0ed4c741c5 | ||
|
|
569ec6fe5c | ||
|
|
e7e80dbc7d | ||
|
|
b054bdd987 | ||
|
|
84f73d33a0 | ||
|
|
78b55202e7 | ||
|
|
7706f97228 | ||
|
|
a0ee558582 | ||
|
|
94d3dad909 | ||
|
|
1a3a55934d | ||
|
|
8bf893b392 | ||
|
|
43b8cfa4dd | ||
|
|
a05b5965b6 | ||
|
|
68407eaa19 | ||
|
|
6854e4729d | ||
|
|
f03e381aea | ||
|
|
151ccb7086 | ||
|
|
8f7f2110ec | ||
|
|
579dd5763d | ||
|
|
3e1c000d51 | ||
|
|
f624611540 | ||
|
|
07f0abf28c | ||
|
|
c3264b2fe1 | ||
|
|
d0aa48fab3 | ||
|
|
d634fa2c95 | ||
|
|
5a8405a9a8 | ||
|
|
a6f388d076 | ||
|
|
1dd7b1a6b9 | ||
|
|
b7a6d950db | ||
|
|
00f5a877a6 | ||
|
|
75a949eecf | ||
|
|
ce0705e9d4 | ||
|
|
0f4a9af57d | ||
|
|
703a362356 | ||
|
|
826d54a0ca | ||
|
|
46c95e0da3 | ||
|
|
0aacbe95c2 | ||
|
|
18f845b4c4 | ||
|
|
b5ae5f876b | ||
|
|
ef359b6997 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
+4
-4
@@ -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.
|
||||
|
||||
@@ -1,3 +1,49 @@
|
||||
## [1.13.0](https://github.com/homarr-labs/homarr/compare/v1.12.0...v1.13.0) (2025-03-28)
|
||||
|
||||
### Features
|
||||
|
||||
* add default search engines seeding / set homarr docs as global server search engine ([#2663](https://github.com/homarr-labs/homarr/issues/2663)) ([3e1c000](https://github.com/homarr-labs/homarr/commit/3e1c000d515c0edc6f49241fc381187e1829ca7e))
|
||||
* add german swiss locale ([#2726](https://github.com/homarr-labs/homarr/issues/2726)) ([eba738e](https://github.com/homarr-labs/homarr/commit/eba738e5d6594d46b5b7d1eab875fc8060e0fe7b))
|
||||
* add jetbrains run profile ([#2704](https://github.com/homarr-labs/homarr/issues/2704)) ([07f0abf](https://github.com/homarr-labs/homarr/commit/07f0abf28cb31f81860e623c70970e16d17e2836))
|
||||
* add title to dynamic section ([#2614](https://github.com/homarr-labs/homarr/issues/2614)) ([1dd7b1a](https://github.com/homarr-labs/homarr/commit/1dd7b1a6b97f75bca0d1a5c0dbb4254aa4bbd418))
|
||||
* app dropdown add infinity scroll ([#2696](https://github.com/homarr-labs/homarr/issues/2696)) ([8bf893b](https://github.com/homarr-labs/homarr/commit/8bf893b39292f34f5c697c09ba2c9e53c8a6ea90))
|
||||
* **auth:** add env variable to force user-info usage instead of idtoken ([#2711](https://github.com/homarr-labs/homarr/issues/2711)) ([94d3dad](https://github.com/homarr-labs/homarr/commit/94d3dad909c39cec096ff61c0a2f45e8853b6111))
|
||||
* auto sign-in when creating the initial user ([#2664](https://github.com/homarr-labs/homarr/issues/2664)) ([46c95e0](https://github.com/homarr-labs/homarr/commit/46c95e0da3ad27eb072efc45250abb0baf3e2483))
|
||||
* implement auto-select icon ([#2679](https://github.com/homarr-labs/homarr/issues/2679)) ([1a3a559](https://github.com/homarr-labs/homarr/commit/1a3a55934d8b3e4703e0ce44d9d26ad7ff100aca))
|
||||
* **lang:** add english uk language ([#2715](https://github.com/homarr-labs/homarr/issues/2715)) ([93c64ba](https://github.com/homarr-labs/homarr/commit/93c64baec294c39cd490a85f8b06b57d53fc82b3))
|
||||
* **logs:** improve logs by logging errors with causes and metadata ([#2703](https://github.com/homarr-labs/homarr/issues/2703)) ([579dd57](https://github.com/homarr-labs/homarr/commit/579dd5763d139e1644c2e192620728afa54c96d2))
|
||||
* set home board when creating first board ([#2683](https://github.com/homarr-labs/homarr/issues/2683)) ([8f7f211](https://github.com/homarr-labs/homarr/commit/8f7f2110ec29db12ea2d83cd779ec86851209188))
|
||||
* SSG integration creation page ([#2684](https://github.com/homarr-labs/homarr/issues/2684)) ([ce0705e](https://github.com/homarr-labs/homarr/commit/ce0705e9d4a502273a0d84e4e14d61a061fd21f9))
|
||||
* **widget:** add restriction callback to restrict visibility and modification of widget kinds ([#2658](https://github.com/homarr-labs/homarr/issues/2658)) ([84f73d3](https://github.com/homarr-labs/homarr/commit/84f73d33a061a1d243803078b245a8016a615af9))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow deletion of external users ([#2725](https://github.com/homarr-labs/homarr/issues/2725)) ([40e0098](https://github.com/homarr-labs/homarr/commit/40e00987cb6f4ee58fdce1da9489eedd92b8843d))
|
||||
* **auth:** fusion auth returns www-authenticate token response ([#2719](https://github.com/homarr-labs/homarr/issues/2719)) ([b5b0b06](https://github.com/homarr-labs/homarr/commit/b5b0b069d2029110d5618a29af5ae003030584d9))
|
||||
* bookmarks layout ([#2675](https://github.com/homarr-labs/homarr/issues/2675)) ([8a071f9](https://github.com/homarr-labs/homarr/commit/8a071f9ab6bc4c85073dc52cc2e60da69f30950a))
|
||||
* calendar indicators ([#2712](https://github.com/homarr-labs/homarr/issues/2712)) ([70ea093](https://github.com/homarr-labs/homarr/commit/70ea093eb6530d25d3e60e6d496651e3fc457826))
|
||||
* **deps:** update dependency drizzle-kit to ^0.30.6 ([#2706](https://github.com/homarr-labs/homarr/issues/2706)) ([f03e381](https://github.com/homarr-labs/homarr/commit/f03e381aea414c5fb770c7ba092b31f9c53eee11))
|
||||
* **deps:** update dependency drizzle-zod to ^0.7.1 ([#2717](https://github.com/homarr-labs/homarr/issues/2717)) ([569ec6f](https://github.com/homarr-labs/homarr/commit/569ec6fe5ccec980351162024170fabe06dcc3da))
|
||||
* **deps:** update dependency ldapts to v7.3.3 ([#2691](https://github.com/homarr-labs/homarr/issues/2691)) ([00f5a87](https://github.com/homarr-labs/homarr/commit/00f5a877a6f09037eff4c9934fc273c4d2af0f6c))
|
||||
* **deps:** update dependency swagger-ui-react to ^5.20.2 ([#2708](https://github.com/homarr-labs/homarr/issues/2708)) ([68407ea](https://github.com/homarr-labs/homarr/commit/68407eaa195894db37018cefeeca4ae2da085847))
|
||||
* **deps:** update dependency typescript-eslint to ^8.28.0 ([#2693](https://github.com/homarr-labs/homarr/issues/2693)) ([f624611](https://github.com/homarr-labs/homarr/commit/f6246115405cdc85948059170742b0bae58ab337))
|
||||
* **deps:** update dependency undici to v7.6.0 ([#2710](https://github.com/homarr-labs/homarr/issues/2710)) ([43b8cfa](https://github.com/homarr-labs/homarr/commit/43b8cfa4dd250013fdf15d509a072ba0984f9ab1))
|
||||
* **deps:** update mantine monorepo to ^7.17.3 ([#2707](https://github.com/homarr-labs/homarr/issues/2707)) ([6854e47](https://github.com/homarr-labs/homarr/commit/6854e4729d3ba4a82a318813fec0a20eccdbed18))
|
||||
* **deps:** update nextjs monorepo to v15.2.4 ([#2720](https://github.com/homarr-labs/homarr/issues/2720)) ([ac77bdd](https://github.com/homarr-labs/homarr/commit/ac77bdd84cba47a43d1952725a9c7feccd972766))
|
||||
* **deps:** update tanstack-query monorepo to ^5.69.2 ([#2714](https://github.com/homarr-labs/homarr/issues/2714)) ([78b5520](https://github.com/homarr-labs/homarr/commit/78b55202e7f4739933768d27211556f927b04fed))
|
||||
* **deps:** update tanstack-query monorepo to ^5.69.3 ([#2716](https://github.com/homarr-labs/homarr/issues/2716)) ([e7e80db](https://github.com/homarr-labs/homarr/commit/e7e80dbc7d62830ce1ce150b6e22a67220da8be8))
|
||||
* **deps:** update tanstack-query monorepo to ^5.70.0 ([#2722](https://github.com/homarr-labs/homarr/issues/2722)) ([34eb9b3](https://github.com/homarr-labs/homarr/commit/34eb9b366fdc58c9fc03d18ac7dd68bff1ea83e6))
|
||||
* **deps:** update testcontainers-node monorepo to ^10.22.0 ([#2692](https://github.com/homarr-labs/homarr/issues/2692)) ([b7a6d95](https://github.com/homarr-labs/homarr/commit/b7a6d950db557ea9deadc0be014b5f218611ac83))
|
||||
* **deps:** update testcontainers-node monorepo to ^10.23.0 ([#2699](https://github.com/homarr-labs/homarr/issues/2699)) ([c3264b2](https://github.com/homarr-labs/homarr/commit/c3264b2fe10ad374e92eb16921d43427d7ca866b))
|
||||
* **deps:** update tiptap monorepo to v2.11.6 ([#2718](https://github.com/homarr-labs/homarr/issues/2718)) ([0ed4c74](https://github.com/homarr-labs/homarr/commit/0ed4c741c59c3e498aa247adc292bdbbde1c59d3))
|
||||
* **deps:** update trpc monorepo to ^11.0.1 ([#2724](https://github.com/homarr-labs/homarr/issues/2724)) ([2178185](https://github.com/homarr-labs/homarr/commit/217818573e8bd7d49a821afdf269cd8b3f3d442d))
|
||||
* **import:** autofix missing shapes for sidebars and some sections as well ([#2723](https://github.com/homarr-labs/homarr/issues/2723)) ([62c7955](https://github.com/homarr-labs/homarr/commit/62c7955e48ca62b4bf1baf26ac6a12847795ac18))
|
||||
* Media request via search engine ([#2677](https://github.com/homarr-labs/homarr/issues/2677)) ([5a8405a](https://github.com/homarr-labs/homarr/commit/5a8405a9a81257b023c0265309f7f530b2e23c7a))
|
||||
|
||||
### Reverts
|
||||
|
||||
* add development package and update workspace ([#2700](https://github.com/homarr-labs/homarr/issues/2700))" ([#2728](https://github.com/homarr-labs/homarr/issues/2728)) ([996ec54](https://github.com/homarr-labs/homarr/commit/996ec548c4b9c8561919409b81adc90b0a24fbd6))
|
||||
|
||||
## [1.12.0](https://github.com/homarr-labs/homarr/compare/v1.11.0...v1.12.0) (2025-03-21)
|
||||
|
||||
### Features
|
||||
|
||||
+17
-17
@@ -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) => {
|
||||
|
||||
+20
-14
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.12.0",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+173
-243
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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 ?? ""}`;
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user