From a7006d746e8f513a06abc0c2e3a5ff1bb5170ab5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 7 Aug 2026 19:37:35 +0000 Subject: [PATCH 01/11] docs(helm): update chart installation page --- apps/docs/docs/getting-started/installation/helm.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/docs/docs/getting-started/installation/helm.md b/apps/docs/docs/getting-started/installation/helm.md index 051c38955..bd969e5e5 100644 --- a/apps/docs/docs/getting-started/installation/helm.md +++ b/apps/docs/docs/getting-started/installation/helm.md @@ -2,9 +2,9 @@ homarr logo -![Version: 8.25.0](https://img.shields.io/badge/Version-8.25.0-informational?style=flat) +![Version: 8.26.0](https://img.shields.io/badge/Version-8.26.0-informational?style=flat) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat) -![AppVersion: v1.73.0](https://img.shields.io/badge/AppVersion-v1.73.0-informational?style=flat) +![AppVersion: v1.74.0](https://img.shields.io/badge/AppVersion-v1.74.0-informational?style=flat) A Helm chart to deploy homarr for Kubernetes @@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack | httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository | -| image.tag | string | `"v1.73.0"` | Overrides the image tag whose default is the chart appVersion | +| image.tag | string | `"v1.74.0"` | Overrides the image tag whose default is the chart appVersion | | imagePullSecrets | list | `[]` | Secrets for Docker registry | | ingress.annotations | object | `{}` | Ingress annotations | | ingress.enabled | bool | `false` | Enable ingress | From 6141d505e488b13af446a0b5bd2e4c74a22a8584 Mon Sep 17 00:00:00 2001 From: Sven Gottwald <2502366+svengo@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:53:50 +0200 Subject: [PATCH 02/11] fix(docker): Expose TCP port in Dockerfile (#6538) --- Dockerfile | 1 + apps/docs/docs/advanced/proxy/index.mdx | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index afc7b5ded..f07fcae3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -95,5 +95,6 @@ ENV AUTH_PROVIDERS='credentials' ENV REDIS_IS_EXTERNAL='false' ENV NODE_ENV='production' +EXPOSE 7575 ENTRYPOINT [ "/app/entrypoint.sh" ] CMD ["sh", "run.sh"] diff --git a/apps/docs/docs/advanced/proxy/index.mdx b/apps/docs/docs/advanced/proxy/index.mdx index ef4e41f1d..668031d9d 100644 --- a/apps/docs/docs/advanced/proxy/index.mdx +++ b/apps/docs/docs/advanced/proxy/index.mdx @@ -47,7 +47,6 @@ services: traefik.http.routers.homarr.rule: Host(`your.internal.dns.address.here.com`) traefik.http.routers.homarr.entrypoints: websecure traefik.http.routers.homarr-secure.app: homarr - traefik.http.apps.homarr.loadbalancer.server.port: 7575 networks: proxy: From 987c9ade18353a2795bafc5079e4d317545b64fc Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 8 Aug 2026 11:16:49 +0200 Subject: [PATCH 03/11] fix: make internal nginx proxy tolerate disabled IPv6 (#4596) (#6539) * fix: keep nginx IPv6 listen only when IPv6 is available (#4596) Commit 2c48b1896 (1.45.2) made the internal nginx proxy listen on the IPv6 wildcard ('listen [::]:7575;'), which aborts nginx startup on hosts/VMs with IPv6 disabled: nginx: [emerg] socket() [::]:7575 failed (97: Address family not supported by protocol) A plain revert would reintroduce #2697 and break IPv6-only/dual-stack environments where this listener is required. Instead, only emit the IPv6 listen directive when the host actually has IPv6 configured. Reuse the envsubst step run.sh already performs: compute NGINX_LISTEN_IPV6 from /proc/net/if_inet6 and substitute it into the template. proc files always report size 0, so presence is detected by grepping the file (not 'test -s'). * fix: honor HOMARR_DISABLE_IPV6 override in nginx config generation Review feedback on #6539: give users an explicit opt-out from the IPv6 listener even when the host reports IPv6. The issue reporter (#4596) requested such an env var, and the pattern matches the existing ENABLE_DNS_CACHING IPv6 toggle. Auto-detection behavior is unchanged when the var is unset. Validated in containers: override wins, dual-stack preserved by default, IPv4-only fallback on IPv6-less hosts. --- .../docs/advanced/environment-variables/index.mdx | 15 ++++++++------- nginx.conf | 2 +- scripts/run.sh | 11 ++++++++++- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index e7dc6960d..f2ae41d4b 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -25,13 +25,14 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation. ::: -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- | -| `PUID` | User ID to run the container as | Any valid user ID | `0` | -| `PGID` | Group ID to run the container as | Any valid group ID | `0` | -| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` | -| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` | -| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | +| Environment Variable | Description | Possible values | Default | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- | +| `PUID` | User ID to run the container as | Any valid user ID | `0` | +| `PGID` | Group ID to run the container as | Any valid group ID | `0` | +| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` | +| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` | +| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | +| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` | ## Authentication diff --git a/nginx.conf b/nginx.conf index c1c082777..7754b4087 100644 --- a/nginx.conf +++ b/nginx.conf @@ -5,7 +5,7 @@ events { http { server { listen 7575; - listen [::]:7575; + ${NGINX_LISTEN_IPV6} # Route websockets traffic to port 3001 location /websockets { diff --git a/scripts/run.sh b/scripts/run.sh index 83eff588e..6357c723c 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -24,8 +24,17 @@ export AUTH_SECRET=$(openssl rand -base64 32) # 1. Replace the HOSTNAME in the nginx template file # 2. Create the nginx configuration file from the template # 3. Start the nginx server +# Only listen on IPv6 when the host actually has IPv6 configured (#4596). +# Otherwise nginx aborts with "socket() [::]:7575 failed (97: Address family +# not supported by protocol)". Check HOMARR_DISABLE_IPV6 first so users can +# force IPv4-only even when the host supports IPv6, then probe for IPv6 +# addresses via grep (not -s: proc files always report size 0). export HOSTNAME -envsubst '${HOSTNAME}' < /etc/nginx/templates/nginx.conf > /etc/nginx/nginx.conf +export NGINX_LISTEN_IPV6='' +if [ "${HOMARR_DISABLE_IPV6:-false}" != "true" ] && grep -q . /proc/net/if_inet6 2>/dev/null; then + NGINX_LISTEN_IPV6='listen [::]:7575;' +fi +envsubst '${HOSTNAME} ${NGINX_LISTEN_IPV6}' < /etc/nginx/templates/nginx.conf > /etc/nginx/nginx.conf # Start services in the background and store their PIDs nginx -g 'daemon off;' & NGINX_PID=$! From 464d98e124726f30c5f92ccf18a5ebff71e0cdd9 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 8 Aug 2026 15:33:38 +0200 Subject: [PATCH 04/11] feat(settings): unify manage and board settings into a single save form (#6541) --- apps/docs/docs/management/boards/index.mdx | 103 +++++--- apps/docs/docs/management/settings/index.mdx | 2 + .../boards/[name]/settings/_appereance.tsx | 182 ++++++-------- .../boards/[name]/settings/_background.tsx | 236 ++++++++---------- .../boards/[name]/settings/_behavior.tsx | 47 +--- .../boards/[name]/settings/_customCss.tsx | 49 ++-- .../boards/[name]/settings/_danger.tsx | 76 ++---- .../boards/[name]/settings/_general.tsx | 157 ++++-------- .../boards/[name]/settings/_layout.tsx | 159 +++++------- .../boards/[name]/settings/_settings-form.tsx | 219 ++++++++++++++++ .../boards/[name]/settings/_shared.tsx | 10 + .../boards/[name]/settings/danger.module.css | 5 - .../[locale]/boards/[name]/settings/page.tsx | 110 +------- .../_components/analytics.settings.tsx | 49 +--- .../_components/appearance-settings-form.tsx | 41 +-- .../_components/board-settings-form.tsx | 69 ++--- .../settings/_components/common-form.tsx | 57 ----- .../crawling-and-indexing.settings.tsx | 75 ++---- .../_components/culture-settings-form.tsx | 30 +-- .../_components/search-settings-form.tsx | 31 +-- .../settings/_components/setting-switch.tsx | 20 +- .../settings/_components/settings-form.tsx | 193 ++++++++++++++ .../_components/user-settings-form.tsx | 29 +-- .../app/[locale]/manage/settings/loading.tsx | 9 + .../src/app/[locale]/manage/settings/page.tsx | 32 +-- .../src/components/manage/danger-zone.tsx | 10 +- .../src/components/manage/section-card.tsx | 19 ++ .../components/manage/unsaved-changes-bar.tsx | 25 ++ 28 files changed, 1050 insertions(+), 994 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx delete mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css delete mode 100644 apps/nextjs/src/app/[locale]/manage/settings/_components/common-form.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/settings/loading.tsx create mode 100644 apps/nextjs/src/components/manage/section-card.tsx create mode 100644 apps/nextjs/src/components/manage/unsaved-changes-bar.tsx diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index f1eb3c572..93bf78ae3 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -9,11 +9,11 @@ tags: - Layout --- -import iconColorImage from './img/icon-color.png' -import itemRadiusImage from './img/item-radius.png' -import layoutLargeImage from './img/layout-large.png' -import layoutMediumImage from './img/layout-medium.png' -import layoutSmallImage from './img/layout-small.png' +import iconColorImage from "./img/icon-color.png"; +import itemRadiusImage from "./img/item-radius.png"; +import layoutLargeImage from "./img/layout-large.png"; +import layoutMediumImage from "./img/layout-medium.png"; +import layoutSmallImage from "./img/layout-small.png"; Each dashboard is called a "board". You can create as many boards as you want. @@ -24,24 +24,29 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home) Names of boards must be unique because this will be used in the URL to load the desired board. ## Create a new board + When creating a board, the availability of the board name will be checked for you. ![](./img/create-board-modal.png) ### Name + The name must not include any special characters and must be at least one character long. Underscores and hyphen-minus are allowed. ### Column count -Homarr has a complex drag and drop system that can be used from the browser. + +Homarr has a complex drag-and-drop system that can be used from the browser. The column count defines how many horizontal columns are available to drag and drop elements to. ### Public + This will enable public access for everyone - regardless of whether they are logged in or not. -This can be really useful if you want to have public boards in your company or for family and fiends. +This can be really useful if you want to have public boards in your company or for family and friends. Modifications for public boards must be allowed explicitly using user groups and their permissions. ## Board settings + You navigate to the board settings by clicking on these three dots: ![](./img/navigate-to-settings.png) @@ -50,8 +55,12 @@ Alternatively, you can also navigate from the board page itself using the cog ic ![](./img/navigate-to-settings-2.png) +All board settings are edited on a single page. Click the **Save changes** button in the save bar at the bottom to apply them, or **Discard** to restore the last saved values. The save bar only appears when you have unsaved changes. Access control and the Danger zone are independent sections and are applied immediately. + ### General + #### Page title + This is the title that will be visible at the top left in the navigation bar: ![](./img/page-title.png) @@ -59,6 +68,7 @@ This is the title that will be visible at the top left in the navigation bar: This field is optional and Homarr will fall back to "Homarr" if nothing is set. #### Meta title + This is the secondary title of the page that is not directly visible in the page itself. Browser will use it to set the title of tabs: @@ -67,10 +77,12 @@ Browser will use it to set the title of tabs: Homarr will fall back to the board name and a suffix if nothing is set. #### Logo image URL + This is the URL that is used for the image at the top left. Homarr will fall back to the Homarr logo if nothing is set. #### Favicon image URL + This is the URL that is used for the favicon in the tab title. Homarr will fall back to the Homarr logo if nothing is set. @@ -94,51 +106,65 @@ Amount of columns for this layout. Defines how many items can be placed in a row #### Breakpoint -Minimum width of the screen where the layout will be used. -Once the minmum of the next layout is reached the layout will change. +Minimum width of the screen where the layout will be used. +Once the minimum of the next layout is reached the layout will change. #### Preview - - - + + + ### Background + #### Background image URL + Homarr can display a background image behind apps and widgets. This URL can be used to define what image should be shown. #### Background image attachment + This defines whether the background should move when the user scrolls. #### Background image size + This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page. #### Background image repeat + This defines whether the background should be repeated if it cannot cover the entire page. This option can be useful if you have patterns that can be repeated easily (e.g. patterns): ![](./img/background-repeat.png) ### Appearance + #### Primary color + Color that can be chosen freely to give accents to active elements. + #### Secondary color + Color that can be chosen freely to give accents. + #### Opacity + Opacity of the colors. + #### Icon color + Change the color of your bookmark / app icons. - + #### Item radius + Change the radius of your items / categories. - - + ### Custom CSS + This is the field where you can enter custom CSS. The styling will only be applied on the respective board page. @@ -147,14 +173,16 @@ The styling will only be applied on the respective board page. ### Behaviour #### Disable app status + This will disable the status check for apps. ### Access control + With the access control, you can permit a single user or a group to your board. The users and groups can have one of these three levels of access: | **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) | -|----------------------|------------|-----------|----------|--------------------------| +| -------------------- | ---------- | --------- | -------- | ------------------------ | | View board | X | | | | | Modify board | X | X | X | | | Full access | X | X | X | X | @@ -173,22 +201,27 @@ After editing permissions, the save button below the table must be pressed. Otherwise, all changes will be discarded. ### Danger zone + #### Rename + Renaming a board will break all existing links to it permanently with no redirect. #### Board visibility + By default, boards will be private. This means only authenticated users that are authorized have access. Making a board public will expose it to everyone - including unauthenticated users. #### Delete + This will permanently delete the board. This action is irreversible and cannot be undone. ## Delete boards + The deletion of boards is permanent and cannot be undone. There are two ways to delete a board: -- Navigate to the settings of your board, open the "Danger zone" dropdown and click on the "delete this board" button. +- Navigate to the settings of your board, open the "Danger zone" section and click on the "delete this board" button. - Navigate to the list of boards in the management pages, click the three dots and click on "delete permanently". Both actions will execute the same deletion process. @@ -196,7 +229,7 @@ Both actions will execute the same deletion process. ## Duplicate boards :::note -Duplicating a board will not copy the users and groups that have access to the board +Duplicating a board will not copy the users and groups that have access to the board nor the integrations the user does not have access to. ::: @@ -206,47 +239,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an ![](./img/duplicate-board-modal.png) ## Setting boards as your home board + The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL. All the following paths will lead to the home board: -- ``my-homarr.com`` -- ``my-homarr.com/boards`` + +- `my-homarr.com` +- `my-homarr.com/boards` Your home board will have a special small badge at the top right in the list of boards: ![](./img/home-board-flag.png) ### For yourself + To set the home board yourself, the easiest option is to set it directly in the list of boards: ![](./img/set-as-home-board-menu.png) Alternatively, you can also define it via your user profile: + 1. Click on your user profile at the top right -2. Click on ``Your preferences`` -3. Change the home board in the ``home board`` dropdown and click on ``Save``. +2. Click on `Your preferences` +3. Change the home board in the `home board` dropdown and click on `Save`. ### For other users -1. Navigate to the users: ``Management`` > ``Users`` > ``Manage`` +1. Navigate to the users: `Management` > `Users` > `Manage` 2. Click on the username of the user that you want to adjust -3. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-other-user.png) +3. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-other-user.png) ### For user groups -1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage`` -2. Go to tab ``Settings`` -3. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-user-group.png) +1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage` +2. Go to tab `Settings` +3. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-user-group.png) ### For whole server You can also set a global home board that will be used for all users that have not set a home board themselves. It will also be used for anonymous users. -1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards`` -2. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-global.png) +1. Navigate to the settings: `Management` > `Settings` > `Boards` +2. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-global.png) :::note For the global home board you can only select boards that are public. @@ -255,4 +292,4 @@ For the global home board you can only select boards that are public. ### Mobile home board Same as the home board, you can also set a mobile home board. -It will be used for devices that are detected as mobile devices. (Using User-Agent header) \ No newline at end of file +It will be used for devices that are detected as mobile devices. (Using User-Agent header) diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx index 016d56d10..8507d2b4f 100644 --- a/apps/docs/docs/management/settings/index.mdx +++ b/apps/docs/docs/management/settings/index.mdx @@ -13,6 +13,8 @@ tags: The settings page applies options to the whole Homarr application. Some changes need a full page reload before they take effect. +All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes. + ## Analytics Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data. diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx index 4af4af15f..b03168912 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx @@ -19,130 +19,106 @@ import { import { useDisclosure } from "@mantine/hooks"; import { IconX } from "@tabler/icons-react"; -import { useZodForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; -import { boardSavePartialSettingsSchema } from "@homarr/validation/board"; -import type { Board } from "../../_types"; +import { SectionCard } from "~/components/manage/section-card"; import { generateColors } from "../../(content)/_theme"; -import { useSavePartialSettingsMutation } from "./_shared"; +import type { FormValues } from "./_settings-form"; interface Props { - board: Board; + form: UseFormReturnType; } const hexRegex = /^#[0-9a-fA-F]{6}$/; const progressPercentageLabel = (value: number) => `${value}%`; -export const ColorSettingsContent = ({ board }: Props) => { - const form = useZodForm(boardSavePartialSettingsSchema, { - initialValues: { - primaryColor: board.primaryColor, - secondaryColor: board.secondaryColor, - opacity: board.opacity, - iconColor: board.iconColor ?? "", - itemRadius: board.itemRadius, - }, - }); +export const ColorSettingsContent = ({ form }: Props) => { const [showPreview, { toggle }] = useDisclosure(false); const t = useI18n(); const theme = useMantineTheme(); - const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); return ( -
{ - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - - - - color[6])} - {...form.getInputProps("primaryColor")} - /> - - - + + + + color[6])} - {...form.getInputProps("secondaryColor")} + {...form.getInputProps("primaryColor")} /> - - - {showPreview ? t("common.preview.hide") : t("common.preview.show")} - - - - - - - - - - - - - - - - - color[6])} - flex={1} - {...form.getInputProps("iconColor")} - /> - - - - - + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx index 7b105fe9c..8a796560f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx @@ -1,41 +1,32 @@ "use client"; import { startTransition } from "react"; -import { ActionIcon, Autocomplete, Button, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core"; +import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { IconPhotoOff, IconUpload } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions"; -import { useZodForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { UploadMedia } from "@homarr/forms-collection"; import type { TranslationObject } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import type { SelectItemWithDescriptionBadge } from "@homarr/ui"; import { SelectWithDescriptionBadge } from "@homarr/ui"; -import { boardSavePartialSettingsSchema } from "@homarr/validation/board"; -import type { Board } from "../../_types"; -import { useSavePartialSettingsMutation } from "./_shared"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./_settings-form"; interface Props { - board: Board; + form: UseFormReturnType; } -export const BackgroundSettingsContent = ({ board }: Props) => { + +export const BackgroundSettingsContent = ({ form }: Props) => { const t = useI18n(); const { data: session } = useSession(); - const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); - const form = useZodForm(boardSavePartialSettingsSchema, { - initialValues: { - backgroundImageUrl: board.backgroundImageUrl ?? "", - backgroundImageAttachment: board.backgroundImageAttachment, - backgroundImageRepeat: board.backgroundImageRepeat, - backgroundImageSize: board.backgroundImageSize, - }, - }); - const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl, 200); + const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200); const medias = clientApi.media.getPaginated.useQuery({ page: 1, pageSize: 10, @@ -53,121 +44,106 @@ export const BackgroundSettingsContent = ({ board }: Props) => { const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats); return ( - { - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - - - - = 2 && ( - - -
- -
-
- - - -
- ) - } - // We filter it on the server - filter={({ options }) => options} - label={t("board.field.backgroundImageUrl.label")} - placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`} - renderOption={({ option }) => { - const current = imageMap.get(option.value); - if (!current) return null; + + + + + = 2 && ( + + +
+ +
+
+ + + +
+ ) + } + // We filter it on the server + filter={({ options }) => options} + label={t("board.field.backgroundImageUrl.label")} + placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`} + renderOption={({ option }) => { + const current = imageMap.get(option.value); + if (!current) return null; - return ( - - - - {current.name} - - {option.value} - - - - ); + return ( + + + + {current.name} + + {option.value} + + + + ); + }} + data={[ + { + group: t("board.field.backgroundImageUrl.group.your"), + items: images + .filter((media) => media.creatorId === session?.user.id) + .map((media) => `/api/user-medias/${media.id}`), + }, + { + group: t("board.field.backgroundImageUrl.group.other"), + items: images + .filter((media) => media.creatorId !== session?.user.id) + .map((media) => `/api/user-medias/${media.id}`), + }, + ]} + {...form.getInputProps("backgroundImageUrl")} + /> + {session?.user.permissions.includes("media-upload") && ( + { + const first = uploadedMedias.at(0); + if (!first) return; + + startTransition(() => { + form.setFieldValue("backgroundImageUrl", first.url); + }); }} - data={[ - { - group: t("board.field.backgroundImageUrl.group.your"), - items: images - .filter((media) => media.creatorId === session?.user.id) - .map((media) => `/api/user-medias/${media.id}`), - }, - { - group: t("board.field.backgroundImageUrl.group.other"), - items: images - .filter((media) => media.creatorId !== session?.user.id) - .map((media) => `/api/user-medias/${media.id}`), - }, - ]} - {...form.getInputProps("backgroundImageUrl")} - /> - {session?.user.permissions.includes("media-upload") && ( - { - const first = medias.at(0); - if (!first) return; - - startTransition(() => { - form.setFieldValue("backgroundImageUrl", first.url); - }); - }} - > - {({ onClick, loading }) => ( - - - - )} - - )} -
-
- - - - - - - - - -
- - - - -
- + > + {({ onClick, loading }) => ( + + + + )} + + )} + +
+ + + + + + + + + +
+ ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_behavior.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_behavior.tsx index 9eded5ec4..563fb97eb 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_behavior.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_behavior.tsx @@ -1,48 +1,27 @@ "use client"; -import { Button, Group, Stack, Switch } from "@mantine/core"; +import { Switch } from "@mantine/core"; -import { useForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; -import type { Board } from "../../_types"; -import { useSavePartialSettingsMutation } from "./_shared"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./_settings-form"; interface Props { - board: Board; + form: UseFormReturnType; } -export const BehaviorSettingsContent = ({ board }: Props) => { +export const BehaviorSettingsContent = ({ form }: Props) => { const t = useI18n(); - const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); - const form = useForm({ - initialValues: { - disableStatus: board.disableStatus, - }, - }); return ( -
{ - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - - - - - - -
+ + + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx index e0d8ec565..e5df59a9f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx @@ -1,6 +1,6 @@ "use client"; -import { Alert, Button, Group, Input, Stack } from "@mantine/core"; +import { Alert, Input } from "@mantine/core"; import { highlight, languages } from "prismjs"; import Editor from "react-simple-code-editor"; @@ -8,59 +8,39 @@ import "~/styles/prismjs.scss"; import { IconInfoCircle } from "@tabler/icons-react"; -import { useForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import type { Board } from "../../_types"; -import { useSavePartialSettingsMutation } from "./_shared"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./_settings-form"; import classes from "./customcss.module.css"; interface Props { - board: Board; + form: UseFormReturnType; } -export const CustomCssSettingsContent = ({ board }: Props) => { +export const CustomCssSettingsContent = ({ form }: Props) => { const t = useI18n(); const customCssT = useScopedI18n("board.field.customCss"); - const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); - const form = useForm({ - initialValues: { - customCss: board.customCss ?? "", - }, - }); return ( -
{ - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - + + - }> - {customCssT("customClassesAlert.description")} - - - - - - -
+ }> + {customCssT("customClassesAlert.description")} + + ); }; interface CustomCssInputProps { value?: string; onChange: (value: string) => void; + error?: React.ReactNode; } -const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => { +const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => { const customCssT = useScopedI18n("board.field.customCss"); return ( @@ -70,6 +50,7 @@ const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => { htmlFor: "custom-css", }} description={customCssT("description")} + error={error} inputWrapperOrder={["label", "description", "input", "error"]} >
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx index 699da6003..92454f20c 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { useRouter } from "next/navigation"; -import { Button, Divider, Group, Stack, Text } from "@mantine/core"; +import { Button } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; @@ -10,7 +10,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import { BoardRenameModal } from "~/components/board/modals/board-rename-modal"; -import classes from "./danger.module.css"; +import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => { const board = useRequiredBoard(); @@ -82,60 +82,36 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: }, [board.id, deleteBoard, router, t, openConfirmModal]); return ( - - - + + {t("section.dangerZone.action.rename.button")} + + } /> - {hideVisibility ? null : ( - <> - - - + {!hideVisibility && ( + + {t(`section.dangerZone.action.visibility.button.${visibility}`)} + + } + /> )} - - + {t("section.dangerZone.action.delete.button")} + + } /> - - ); -}; - -interface DangerZoneRowProps { - label: string; - description: string; - buttonText: string; - isPending?: boolean; - onClick: () => void; -} - -const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => { - return ( - - - - {label} - - {description} - - - - - + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx index 0bfc79173..2fcb119a0 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx @@ -1,128 +1,66 @@ "use client"; -import { useEffect, useRef } from "react"; -import { Button, Grid, Group, Loader, Stack, TextInput } from "@mantine/core"; +import { useEffect } from "react"; +import { Grid, Loader, TextInput } from "@mantine/core"; import { useDebouncedValue, useDocumentTitle } from "@mantine/hooks"; import { useUpdateBoard } from "@homarr/boards/updater"; -import { useZodForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { IconPicker } from "@homarr/forms-collection"; import { useI18n } from "@homarr/translation/client"; -import { boardSavePartialSettingsSchema } from "@homarr/validation/board"; -import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; +import { SectionCard } from "~/components/manage/section-card"; import { createMetaTitle } from "~/metadata"; import type { Board } from "../../_types"; -import { useSavePartialSettingsMutation } from "./_shared"; +import type { FormValues } from "./_settings-form"; interface Props { board: Board; + form: UseFormReturnType; } -export const GeneralSettingsContent = ({ board }: Props) => { +export const GeneralSettingsContent = ({ board, form }: Props) => { const t = useI18n(); - const ref = useRef({ - pageTitle: board.pageTitle, - logoImageUrl: board.logoImageUrl, - }); - const { updateBoard } = useUpdateBoard(); - - const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); - const form = useZodForm( - boardSavePartialSettingsSchema - .pick({ - pageTitle: true, - logoImageUrl: true, - metaTitle: true, - faviconImageUrl: true, - }) - .required(), - { - initialValues: { - pageTitle: board.pageTitle ?? "", - logoImageUrl: board.logoImageUrl ?? "", - metaTitle: board.metaTitle ?? "", - faviconImageUrl: board.faviconImageUrl ?? "", - }, - onValuesChange({ pageTitle }) { - updateBoard((previous) => ({ - ...previous, - pageTitle, - })); - }, - }, - ); useLogoPreview(form.values.logoImageUrl); const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle); - // Cleanup for not applied changes of the page title and logo image URL - useEffect(() => { - return () => { - updateBoard((previous) => ({ - ...previous, - pageTitle: ref.current.pageTitle, - logoImageUrl: ref.current.logoImageUrl, - })); - }; - }, [updateBoard]); - return ( -
{ - // Save the current values to the ref so that it does not reset if the form is submitted - ref.current = { - pageTitle: values.pageTitle, - logoImageUrl: values.logoImageUrl, - }; - updateFavicon(values.faviconImageUrl ?? homarrLogoPath); - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - - - - - - } - {...form.getInputProps("metaTitle")} - /> - - - - - - - - - - - - -
+ + + + + + + } + {...form.getInputProps("metaTitle")} + /> + + + + + + + + + ); }; @@ -146,14 +84,3 @@ const useMetaTitlePreview = (title: string | null) => { isPending: (title ?? "") !== metaTitleDebounced, }; }; - -// Previously we used the useFavicon hook from Mantine -// However if the user tried to click on a link to a different page -// it caused an error "Cannot read properties of null (reading 'removeChild')" -// Probably because the head element was null (https://github.com/mantinedev/mantine/blob/b90d9b81031f45f5d15e75f25138ed6477f65bce/packages/%40mantine/hooks/src/use-favicon/use-favicon.ts#L21) -// See https://github.com/homarr-labs/homarr/issues/4905 -const updateFavicon = (url: string) => { - const link: HTMLLinkElement | null = document.querySelector('link[rel="icon"]'); - if (!link) return; - link.href = url; -}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index 97a576a30..c592381ff 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -2,114 +2,79 @@ import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core"; -import { clientApi } from "@homarr/api/client"; import { createId } from "@homarr/common"; -import { useZodForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; -import { boardSaveLayoutsSchema } from "@homarr/validation/board"; -import type { Board } from "../../_types"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./_settings-form"; interface Props { - board: Board; + form: UseFormReturnType; } -export const LayoutSettingsContent = ({ board }: Props) => { + +export const LayoutSettingsContent = ({ form }: Props) => { const t = useI18n(); - const utils = clientApi.useUtils(); - const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({ - onSettled() { - void utils.board.getBoardByName.invalidate({ name: board.name }); - void utils.board.getHomeBoard.invalidate(); - }, - }); - const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), { - initialValues: { - layouts: board.layouts, - }, - }); return ( -
{ - saveLayouts({ - id: board.id, - ...values, - }); - })} - > - - - - {t("board.setting.section.layout.responsive.title")} - - - - {form.values.layouts.map((layout, index) => ( -
- - - - - - - - - - - - - - - - {form.values.layouts.length >= 2 && ( - - - - )} -
- ))} -
- - - + + {form.values.layouts.map((layout, index) => ( +
+ + + + + + + + + + + + + + + + {form.values.layouts.length >= 2 && ( + + + + )} +
+ ))}
-
+ ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx new file mode 100644 index 000000000..b8e66cf4b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Button, Stack } from "@mantine/core"; +import type { z } from "zod/v4"; + +import type { RouterOutputs } from "@homarr/api"; +import { useUpdateBoard } from "@homarr/boards/updater"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { env } from "@homarr/common/env"; +import { useZodForm } from "@homarr/form"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board"; + +import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; +import { SectionCard } from "~/components/manage/section-card"; +import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar"; + +import type { Board } from "../../_types"; +import { ColorSettingsContent } from "./_appereance"; +import { BackgroundSettingsContent } from "./_background"; +import { BehaviorSettingsContent } from "./_behavior"; +import { BoardAccessSettings } from "./_board-access"; +import { CustomCssSettingsContent } from "./_customCss"; +import { DangerZoneSettingsContent } from "./_danger"; +import { GeneralSettingsContent } from "./_general"; +import { LayoutSettingsContent } from "./_layout"; +import { useSaveLayoutsMutation, useSavePartialSettingsMutation } from "./_shared"; + +const boardSettingsFormSchema = boardSavePartialSettingsSchema + .extend({ + layouts: boardSaveLayoutsSchema.shape.layouts, + }) + .required(); + +export type FormValues = z.infer; + +const PARTIAL_FORM_KEYS = [ + "pageTitle", + "metaTitle", + "logoImageUrl", + "faviconImageUrl", + "backgroundImageUrl", + "backgroundImageAttachment", + "backgroundImageRepeat", + "backgroundImageSize", + "primaryColor", + "secondaryColor", + "opacity", + "iconColor", + "itemRadius", + "customCss", + "disableStatus", +] as const; + +const buildInitialValues = (board: Board): FormValues => ({ + pageTitle: board.pageTitle ?? "", + metaTitle: board.metaTitle ?? "", + logoImageUrl: board.logoImageUrl ?? "", + faviconImageUrl: board.faviconImageUrl ?? "", + backgroundImageUrl: board.backgroundImageUrl ?? "", + backgroundImageAttachment: board.backgroundImageAttachment, + backgroundImageRepeat: board.backgroundImageRepeat, + backgroundImageSize: board.backgroundImageSize, + primaryColor: board.primaryColor, + secondaryColor: board.secondaryColor, + opacity: board.opacity, + iconColor: board.iconColor ?? "", + itemRadius: board.itemRadius, + customCss: board.customCss ?? "", + disableStatus: board.disableStatus, + layouts: board.layouts, +}); + +interface BoardSettingsFormProps { + board: Board; + permissions: RouterOutputs["board"]["getBoardPermissions"]; + hasFullAccess: boolean; + hideVisibility: boolean; +} + +export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => { + const t = useI18n(); + const tSection = useScopedI18n("board.setting.section"); + const { updateBoard } = useUpdateBoard(); + const savePartialSettings = useSavePartialSettingsMutation(board); + const saveLayouts = useSaveLayoutsMutation(board); + + const form = useZodForm(boardSettingsFormSchema, { + initialValues: buildInitialValues(board), + onValuesChange({ pageTitle }) { + updateBoard((previous) => ({ + ...previous, + pageTitle, + })); + }, + }); + + const initialValuesRef = useRef(buildInitialValues(board)); + const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl }); + + const isDirtyRef = useRef(false); + isDirtyRef.current = form.isDirty(); + + useEffect(() => { + return () => { + updateBoard((previous) => ({ + ...previous, + pageTitle: lastSavedRef.current.pageTitle, + logoImageUrl: lastSavedRef.current.logoImageUrl, + })); + }; + }, [updateBoard]); + + useEffect(() => { + const handler = (event: BeforeUnloadEvent) => { + if (env.NODE_ENV === "development") return; + if (isDirtyRef.current) event.preventDefault(); + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, []); + + const handleSubmitAsync = async (values: FormValues) => { + const defaults = initialValuesRef.current; + const changed = (...fields: TKey[]) => + fields.some((field) => values[field] !== defaults[field]); + + const { layouts, ...partialSettings } = values; + + const saveActions: { when: boolean; action: () => Promise }[] = [ + { + when: changed(...PARTIAL_FORM_KEYS), + action: () => + savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => { + updateFavicon(values.faviconImageUrl ?? homarrLogoPath); + }), + }, + { + when: changed("layouts"), + action: () => saveLayouts.mutateAsync({ id: board.id, layouts }), + }, + ]; + + const promises = saveActions.filter((saveAction) => saveAction.when).map((saveAction) => saveAction.action()); + if (promises.length === 0) return; + + try { + await Promise.all(promises); + lastSavedRef.current = { pageTitle: values.pageTitle, logoImageUrl: values.logoImageUrl }; + initialValuesRef.current = values; + form.setInitialValues(values); + form.resetDirty(); + await revalidatePathActionAsync(`/boards/${board.name}/settings`); + showSuccessNotification({ + title: t("common.notification.update.success"), + message: t("common.notification.update.success"), + }); + } catch { + showErrorNotification({ + title: t("common.notification.update.error"), + message: t("common.notification.update.error"), + }); + } + }; + + const handleDiscard = () => { + form.setValues(initialValuesRef.current); + form.resetDirty(); + updateBoard((previous) => ({ + ...previous, + pageTitle: lastSavedRef.current.pageTitle, + logoImageUrl: lastSavedRef.current.logoImageUrl, + })); + }; + + const isPending = savePartialSettings.isPending || saveLayouts.isPending; + + return ( +
void handleSubmitAsync(values))}> + + + + + + + + + {hasFullAccess && ( + + + + )} + + {hasFullAccess && } + + {form.isDirty() && ( + + + + + )} + +
+ ); +}; + +// Previously part of the general settings section; applied on the unified save +// https://github.com/homarr-labs/homarr/issues/4905 +const updateFavicon = (url: string) => { + const link = document.querySelector('link[rel="icon"]'); + if (!link) return; + link.href = url; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx index 9fa888be2..d0fedf522 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx @@ -11,3 +11,13 @@ export const useSavePartialSettingsMutation = (board: Board) => { }, }); }; + +export const useSaveLayoutsMutation = (board: Board) => { + const utils = clientApi.useUtils(); + return clientApi.board.saveLayouts.useMutation({ + onSettled() { + void utils.board.getBoardByName.invalidate({ name: board.name }); + void utils.board.getHomeBoard.invalidate(); + }, + }); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css b/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css deleted file mode 100644 index 39a60b735..000000000 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css +++ /dev/null @@ -1,5 +0,0 @@ -@media (min-width: 36em) { - .dangerZoneGroup { - --group-wrap: nowrap !important; - } -} diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx index a728e8949..318d5196a 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -1,44 +1,20 @@ -import type { PropsWithChildren } from "react"; import { notFound } from "next/navigation"; -import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core"; -import { - IconAlertTriangle, - IconBrush, - IconClick, - IconFileTypeCss, - IconLayout, - IconPhoto, - IconSettings, - IconUser, -} from "@tabler/icons-react"; +import { Container, Stack, Title } from "@mantine/core"; import { TRPCError } from "@trpc/server"; import { api } from "@homarr/api/server"; import { capitalize } from "@homarr/common"; import { db } from "@homarr/db"; import { getServerSettingByKeyAsync } from "@homarr/db/queries"; -import type { TranslationObject } from "@homarr/translation"; import { getScopedI18n } from "@homarr/translation/server"; -import type { TablerIcon } from "@homarr/ui"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; -import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion"; -import { ColorSettingsContent } from "./_appereance"; -import { BackgroundSettingsContent } from "./_background"; -import { BehaviorSettingsContent } from "./_behavior"; -import { BoardAccessSettings } from "./_board-access"; -import { CustomCssSettingsContent } from "./_customCss"; -import { DangerZoneSettingsContent } from "./_danger"; -import { GeneralSettingsContent } from "./_general"; -import { LayoutSettingsContent } from "./_layout"; +import { BoardSettingsForm } from "./_settings-form"; interface Props { params: Promise<{ name: string; }>; - searchParams: Promise<{ - tab?: keyof TranslationObject["board"]["setting"]["section"]; - }>; } const getBoardAndPermissionsAsync = async (params: Awaited) => { @@ -57,11 +33,7 @@ const getBoardAndPermissionsAsync = async (params: Awaited) => } catch (error) { // Ignore not found errors and redirect to 404 // error is already logged in _layout-creator.tsx - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - notFound(); - } - - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { + if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) { notFound(); } @@ -70,7 +42,6 @@ const getBoardAndPermissionsAsync = async (params: Awaited) => }; export default async function BoardSettingsPage(props: Props) { - const searchParams = await props.searchParams; const params = await props.params; const { board, permissions } = await getBoardAndPermissionsAsync(params); const boardSettings = await getServerSettingByKeyAsync(db, "board"); @@ -85,76 +56,13 @@ export default async function BoardSettingsPage(props: Props) { {t("title", { boardName: capitalize(board.name) })} - - - - - - - - - - - - - - - - - - - - {hasFullAccess && ( - <> - - - - - - - - )} - + ); } - -type AccordionItemForProps = PropsWithChildren<{ - value: keyof TranslationObject["board"]["setting"]["section"]; - icon: TablerIcon; - danger?: boolean; - noPadding?: boolean; -}>; - -const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => { - const t = await getScopedI18n("board.setting.section"); - return ( - - }> - - {t(`${value}.title`)} - - - - {children} - - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx index 9ad50b432..b68e28e90 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx @@ -1,53 +1,22 @@ "use client"; -import React from "react"; -import { Card, LoadingOverlay, Stack, Title } from "@mantine/core"; - -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useForm } from "@homarr/form"; -import type { defaultServerSettings } from "@homarr/server-settings"; +import type { UseFormReturnType } from "@homarr/form"; import { useScopedI18n } from "@homarr/translation/client"; -import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; +import { SwitchSetting } from "./setting-switch"; interface AnalyticsSettingsProps { - initialData: typeof defaultServerSettings.analytics; + form: UseFormReturnType; } -export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => { +export const AnalyticsSettings = ({ form }: AnalyticsSettingsProps) => { const t = useScopedI18n("management.page.settings.section.analytics"); - const { instanceId: _, ...formDefaults } = initialData; - const form = useForm({ - initialValues: formDefaults, - onValuesChange: (updatedValues, _prev) => { - if (!form.isValid()) { - return; - } - - void mutateAsync({ - settingsKey: "analytics", - value: updatedValues, - }); - }, - }); - - const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({ - onSettled: async () => { - await revalidatePathActionAsync("/manage/settings"); - }, - }); return ( - <> - {t("title")} - - - - - - - - + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx index 900caf0e4..e33afe1b6 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx @@ -5,31 +5,32 @@ import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react"; import type { ColorScheme } from "@homarr/definitions"; import { colorSchemes } from "@homarr/definitions"; -import type { ServerSettings } from "@homarr/server-settings"; +import type { UseFormReturnType } from "@homarr/form"; import { useScopedI18n } from "@homarr/translation/client"; import { SelectWithCustomItems } from "@homarr/ui"; -import { CommonSettingsForm } from "./common-form"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; -export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["appearance"] }) => { - const tApperance = useScopedI18n("management.page.settings.section.appearance"); +interface AppearanceSettingsFormProps { + form: UseFormReturnType; +} + +export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) => { + const tAppearance = useScopedI18n("management.page.settings.section.appearance"); return ( - - {(form) => ( - <> - ({ - value: scheme, - label: tApperance(`defaultColorScheme.options.${scheme}`), - }))} - {...form.getInputProps("defaultColorScheme")} - SelectOption={ApperanceCustomOption} - /> - - )} - + + ({ + value: scheme, + label: tAppearance(`defaultColorScheme.options.${scheme}`), + }))} + {...form.getInputProps("defaultColorScheme")} + SelectOption={AppearanceCustomOption} + /> + ); }; @@ -39,7 +40,7 @@ const appearanceIcons = { dark: IconMoon, }; -const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => { +const AppearanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => { const Icon = appearanceIcons[value]; return ( diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx index 615c6ba38..801e0df9f 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx @@ -3,49 +3,50 @@ import { Switch, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; -import type { ServerSettings } from "@homarr/server-settings"; +import type { UseFormReturnType } from "@homarr/form"; import { useScopedI18n } from "@homarr/translation/client"; import { BoardSelect } from "~/components/board/board-select"; -import { CommonSettingsForm } from "./common-form"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; -export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => { +interface BoardSettingsFormProps { + form: UseFormReturnType; +} + +export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => { const tBoard = useScopedI18n("management.page.settings.section.board"); const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery(); return ( - - {(form) => ( - <> - + + - + - {tBoard("status.title")} - - - - )} - + {tBoard("status.title")} + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/common-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/common-form.tsx deleted file mode 100644 index 8a932dee1..000000000 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/common-form.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import { Button, Group, Stack } from "@mantine/core"; - -import { clientApi } from "@homarr/api/client"; -import { useForm } from "@homarr/form"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import type { ServerSettings } from "@homarr/server-settings"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; - -export const CommonSettingsForm = ({ - settingKey, - defaultValues, - children, -}: { - settingKey: TKey; - defaultValues: ServerSettings[TKey]; - children: (form: ReturnType>) => React.ReactNode; -}) => { - const t = useI18n(); - const tSettings = useScopedI18n("management.page.settings"); - const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({ - onSuccess() { - showSuccessNotification({ - message: tSettings("notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - message: tSettings("notification.error.message"), - }); - }, - }); - const form = useForm({ - initialValues: defaultValues, - }); - - const handleSubmitAsync = async (values: ServerSettings[TKey]) => { - await mutateAsync({ - settingsKey: settingKey, - value: values, - }); - }; - - return ( -
void handleSubmitAsync(values))}> - - {children(form)} - - - - -
- ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx index 29206a473..15852c274 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx @@ -1,68 +1,35 @@ "use client"; -import React from "react"; -import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core"; +import { Text } from "@mantine/core"; -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useForm } from "@homarr/form"; -import type { defaultServerSettings } from "@homarr/server-settings"; +import type { UseFormReturnType } from "@homarr/form"; import { useScopedI18n } from "@homarr/translation/client"; -import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; +import { SwitchSetting } from "./setting-switch"; interface CrawlingAndIndexingSettingsProps { - initialData: typeof defaultServerSettings.crawlingAndIndexing; + form: UseFormReturnType; } -export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexingSettingsProps) => { +export const CrawlingAndIndexingSettings = ({ form }: CrawlingAndIndexingSettingsProps) => { const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing"); - const form = useForm({ - initialValues: initialData, - onValuesChange: (updatedValues, _) => { - if (!form.isValid()) { - return; - } - - void mutateAsync({ - settingsKey: "crawlingAndIndexing", - value: updatedValues, - }); - }, - }); - - const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({ - onSettled: async () => { - await revalidatePathActionAsync("/manage/settings"); - }, - }); return ( - <> - {t("title")} - - - - {t("warning")} - - - - - - - - - - + + + {t("warning")} + + + + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx index 7d9dff99e..da34c7019 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx @@ -1,26 +1,28 @@ "use client"; -import type { ServerSettings } from "@homarr/server-settings"; +import type { UseFormReturnType } from "@homarr/form"; import type { SupportedLanguage } from "@homarr/translation"; import { useScopedI18n } from "@homarr/translation/client"; import { LanguageCombobox } from "~/components/language/language-combobox"; -import { CommonSettingsForm } from "./common-form"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; -export const CultureSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["culture"] }) => { +interface CultureSettingsFormProps { + form: UseFormReturnType; +} + +export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => { const tCulture = useScopedI18n("management.page.settings.section.culture"); + const localeInputProps = form.getInputProps("defaultLocale"); return ( - - {(form) => ( - <> - - - )} - + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx index a39cab70d..904dd4b08 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx @@ -3,27 +3,28 @@ import { Select } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; -import type { ServerSettings } from "@homarr/server-settings"; +import type { UseFormReturnType } from "@homarr/form"; import { useScopedI18n } from "@homarr/translation/client"; -import { CommonSettingsForm } from "./common-form"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; -export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => { +interface SearchSettingsFormProps { + form: UseFormReturnType; +} + +export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => { const tSearch = useScopedI18n("management.page.settings.section.search"); const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false }); return ( - - {(form) => ( - <> - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx index adec086ad..4e1194d42 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx @@ -3,10 +3,13 @@ import React from "react"; import type { MantineSpacing } from "@mantine/core"; import { Group, Stack, Switch, Text, UnstyledButton } from "@mantine/core"; -import type { Modify } from "@homarr/common/types"; import type { UseFormReturnType } from "@homarr/form"; -export const SwitchSetting = >({ +type BooleanKeys = { + [TKey in keyof TFormValue]: TFormValue[TKey] extends boolean ? TKey : never; +}[keyof TFormValue]; + +export const SwitchSetting = >({ form, ms, title, @@ -14,13 +17,8 @@ export const SwitchSetting = >({ formKey, disabled, }: { - form: Modify< - UseFormReturnType, - { - setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void; - } - >; - formKey: keyof TFormValue; + form: UseFormReturnType; + formKey: string & BooleanKeys; ms?: MantineSpacing; title: string; text: ReactNode; @@ -31,7 +29,7 @@ export const SwitchSetting = >({ return; } - form.setFieldValue(formKey, (previous) => !previous); + form.setFieldValue(formKey, (previous) => !previous as never); }, [form, formKey, disabled]); return ( @@ -44,7 +42,7 @@ export const SwitchSetting = >({ - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx new file mode 100644 index 000000000..ce64a3404 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Button, Stack } from "@mantine/core"; +import { z } from "zod/v4"; + +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { env } from "@homarr/common/env"; +import { colorSchemes } from "@homarr/definitions"; +import { useZodForm } from "@homarr/form"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; + +import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar"; +import { AnalyticsSettings } from "./analytics.settings"; +import { AppearanceSettingsForm } from "./appearance-settings-form"; +import { BoardSettingsForm } from "./board-settings-form"; +import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings"; +import { CultureSettingsForm } from "./culture-settings-form"; +import { SearchSettingsForm } from "./search-settings-form"; +import { UserSettingsForm } from "./user-settings-form"; + +const settingsFormSchema = z.object({ + enableGeneral: z.boolean(), + noIndex: z.boolean(), + noFollow: z.boolean(), + noTranslate: z.boolean(), + noSiteLinksSearchBox: z.boolean(), + homeBoardId: z.string().nullable(), + mobileHomeBoardId: z.string().nullable(), + enableStatusByDefault: z.boolean(), + forceDisableStatus: z.boolean(), + enableGravatar: z.boolean(), + defaultSearchEngineId: z.string().nullable(), + defaultColorScheme: z.enum(colorSchemes), + defaultLocale: z.string(), +}); + +export type FormValues = z.infer; + +const buildInitialValues = (initialData: ServerSettings): FormValues => ({ + enableGeneral: initialData.analytics.enableGeneral, + noIndex: initialData.crawlingAndIndexing.noIndex, + noFollow: initialData.crawlingAndIndexing.noFollow, + noTranslate: initialData.crawlingAndIndexing.noTranslate, + noSiteLinksSearchBox: initialData.crawlingAndIndexing.noSiteLinksSearchBox, + homeBoardId: initialData.board.homeBoardId, + mobileHomeBoardId: initialData.board.mobileHomeBoardId, + enableStatusByDefault: initialData.board.enableStatusByDefault, + forceDisableStatus: initialData.board.forceDisableStatus, + enableGravatar: initialData.user.enableGravatar, + defaultSearchEngineId: initialData.search.defaultSearchEngineId, + defaultColorScheme: initialData.appearance.defaultColorScheme, + defaultLocale: initialData.culture.defaultLocale, +}); + +interface SettingsFormProps { + initialData: ServerSettings; +} + +export const SettingsForm = ({ initialData }: SettingsFormProps) => { + const t = useI18n(); + const tSettings = useScopedI18n("management.page.settings"); + + const initialValues = buildInitialValues(initialData); + const initialValuesRef = useRef(initialValues); + + const form = useZodForm(settingsFormSchema, { + initialValues, + }); + + const isDirtyRef = useRef(false); + isDirtyRef.current = form.isDirty(); + + const [isSubmitting, setIsSubmitting] = useState(false); + + const saveSettingsMutation = clientApi.serverSettings.saveSettings.useMutation({ + onError(error) { + showErrorNotification({ title: t("common.notification.update.error"), message: error.message }); + }, + }); + + useEffect(() => { + const handler = (event: BeforeUnloadEvent) => { + if (env.NODE_ENV === "development") return; + if (isDirtyRef.current) event.preventDefault(); + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, []); + + const handleSubmitAsync = async (values: FormValues) => { + const defaults = initialValuesRef.current; + const changed = (...fields: TKey[]) => + fields.some((field) => values[field] !== defaults[field]); + + const groups: { + settingsKey: (typeof defaultServerSettingsKeys)[number]; + when: boolean; + value: Record; + }[] = [ + { settingsKey: "analytics", when: changed("enableGeneral"), value: { enableGeneral: values.enableGeneral } }, + { + settingsKey: "crawlingAndIndexing", + when: changed("noIndex", "noFollow", "noTranslate", "noSiteLinksSearchBox"), + value: { + noIndex: values.noIndex, + noFollow: values.noFollow, + noTranslate: values.noTranslate, + noSiteLinksSearchBox: values.noSiteLinksSearchBox, + }, + }, + { + settingsKey: "board", + when: changed("homeBoardId", "mobileHomeBoardId", "enableStatusByDefault", "forceDisableStatus"), + value: { + homeBoardId: values.homeBoardId, + mobileHomeBoardId: values.mobileHomeBoardId, + enableStatusByDefault: values.enableStatusByDefault, + forceDisableStatus: values.forceDisableStatus, + }, + }, + { settingsKey: "user", when: changed("enableGravatar"), value: { enableGravatar: values.enableGravatar } }, + { + settingsKey: "search", + when: changed("defaultSearchEngineId"), + value: { defaultSearchEngineId: values.defaultSearchEngineId }, + }, + { + settingsKey: "appearance", + when: changed("defaultColorScheme"), + value: { defaultColorScheme: values.defaultColorScheme }, + }, + { settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } }, + ]; + + const promises = groups + .filter((group) => group.when) + .map((group) => saveSettingsMutation.mutateAsync({ settingsKey: group.settingsKey, value: group.value })); + if (promises.length === 0) return; + + setIsSubmitting(true); + try { + const results = await Promise.allSettled(promises); + if (!results.every((result) => result.status === "fulfilled")) { + return; + } + + initialValuesRef.current = values; + form.setInitialValues(values); + form.resetDirty(); + await revalidatePathActionAsync("/manage/settings"); + showSuccessNotification({ + title: t("common.notification.update.success"), + message: tSettings("notification.success.message"), + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleDiscard = () => { + form.setValues(initialValuesRef.current); + form.resetDirty(); + }; + + return ( +
void handleSubmitAsync(values))}> + + + + + + + + + + {form.isDirty() && ( + + + + + )} + +
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx index 49b8f7219..41abb2882 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx @@ -2,25 +2,26 @@ import { Switch } from "@mantine/core"; -import type { ServerSettings } from "@homarr/server-settings"; +import type { UseFormReturnType } from "@homarr/form"; import { useScopedI18n } from "@homarr/translation/client"; -import { CommonSettingsForm } from "./common-form"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; -export const UserSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["user"] }) => { +interface UserSettingsFormProps { + form: UseFormReturnType; +} + +export const UserSettingsForm = ({ form }: UserSettingsFormProps) => { const tUser = useScopedI18n("management.page.settings.section.user"); return ( - - {(form) => ( - <> - - - )} - + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/loading.tsx b/apps/nextjs/src/app/[locale]/manage/settings/loading.tsx new file mode 100644 index 000000000..097406c3d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/settings/loading.tsx @@ -0,0 +1,9 @@ +import { Center, Loader } from "@mantine/core"; + +export default function SettingsLoading() { + return ( +
+ +
+ ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx index 4a7ea9bc0..eee563069 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx @@ -5,14 +5,8 @@ import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; -import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { AnalyticsSettings } from "./_components/analytics.settings"; -import { AppearanceSettingsForm } from "./_components/appearance-settings-form"; -import { BoardSettingsForm } from "./_components/board-settings-form"; -import { CultureSettingsForm } from "./_components/culture-settings-form"; -import { SearchSettingsForm } from "./_components/search-settings-form"; -import { UserSettingsForm } from "./_components/user-settings-form"; +import { SettingsForm } from "./_components/settings-form"; export async function generateMetadata() { const t = await getScopedI18n("management"); @@ -32,33 +26,13 @@ export default async function SettingsPage() { const serverSettings = await api.serverSettings.getAll(); const tSettings = await getScopedI18n("management.page.settings"); + return ( <> {tSettings("title")} - - - - {tSettings("section.board.title")} - - - - {tSettings("section.user.title")} - - - - {tSettings("section.search.title")} - - - - {tSettings("section.appearance.title")} - - - - {tSettings("section.culture.title")} - - + ); diff --git a/apps/nextjs/src/components/manage/danger-zone.tsx b/apps/nextjs/src/components/manage/danger-zone.tsx index 38f63185b..480d38a0c 100644 --- a/apps/nextjs/src/components/manage/danger-zone.tsx +++ b/apps/nextjs/src/components/manage/danger-zone.tsx @@ -1,19 +1,21 @@ +"use client"; + import { Fragment } from "react"; import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core"; -import { getI18n } from "@homarr/translation/server"; +import { useScopedI18n } from "@homarr/translation/client"; interface DangerZoneRootProps { children: React.ReactNode[] | React.ReactNode; } -export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => { - const t = await getI18n(); +export const DangerZoneRoot = ({ children }: DangerZoneRootProps) => { + const t = useScopedI18n("common"); return ( - {t("common.dangerZone")} + {t("dangerZone")} diff --git a/apps/nextjs/src/components/manage/section-card.tsx b/apps/nextjs/src/components/manage/section-card.tsx new file mode 100644 index 000000000..66eb396d4 --- /dev/null +++ b/apps/nextjs/src/components/manage/section-card.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import { Card, Divider, Stack, Title } from "@mantine/core"; + +interface SectionCardProps { + title: ReactNode; + children: ReactNode; +} + +export const SectionCard = ({ title, children }: SectionCardProps) => { + return ( + + + {title} + + {children} + + + ); +}; diff --git a/apps/nextjs/src/components/manage/unsaved-changes-bar.tsx b/apps/nextjs/src/components/manage/unsaved-changes-bar.tsx new file mode 100644 index 000000000..da0b91ad1 --- /dev/null +++ b/apps/nextjs/src/components/manage/unsaved-changes-bar.tsx @@ -0,0 +1,25 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Card, Group, Text } from "@mantine/core"; + +import { useI18n } from "@homarr/translation/client"; + +interface UnsavedChangesBarProps { + children: ReactNode; +} + +export const UnsavedChangesBar = ({ children }: UnsavedChangesBarProps) => { + const t = useI18n(); + + return ( +
+ + + {t("common.unsavedChanges")} + {children} + + +
+ ); +}; From 06c489bd48438641e767e44a7c49e2826623efd8 Mon Sep 17 00:00:00 2001 From: "homarr-crowdin[bot]" <190541745+homarr-crowdin[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:06:56 +0200 Subject: [PATCH 05/11] chore(lang): updated translations from crowdin (#6548) Co-authored-by: Thomas --- packages/translation/src/lang/cn.json | 1002 ++++++++++++------------- packages/translation/src/lang/pl.json | 72 +- 2 files changed, 537 insertions(+), 537 deletions(-) diff --git a/packages/translation/src/lang/cn.json b/packages/translation/src/lang/cn.json index 79145d7fd..003b6e648 100644 --- a/packages/translation/src/lang/cn.json +++ b/packages/translation/src/lang/cn.json @@ -9,7 +9,7 @@ "scratch": "从零开始", "importOldmarr": "从 1.0 之前的 Homarr 导入", "restoreSqlite": "从 SQLite 备份恢复", - "restoreSqliteDescription": "上传来自先前 Homarr 实例的完整 SQLite 数据库备份。这将恢复所有面板、用户、应用、集成和设置。", + "restoreSqliteDescription": "上传之前 Homarr 实例的完整 SQLite 数据库备份。这将恢复所有面板、用户、应用、集成和设置。", "backToOptions": "← 返回设置选项" } }, @@ -115,10 +115,10 @@ } }, "urlTemplate": { - "label": "基础 URL", + "label": "Base URL", "placeholder": "homelab.local 或 192.168.1.1", "hint": "输入您的基础域或 IP 以自动填充集成 URL", - "warning": "立即设置基础 URL 将自动填充所有集成 URL,从而节省设置时间", + "warning": "现在设置基础 URL,将自动填充所有集成的 URL,在配置过程中为您节省时间", "preview": "例如 {example}", "mode": { "subdomain": "子域名", @@ -199,8 +199,8 @@ "label": "为 ping 使用图标" }, "enableRightClickOnWidgets": { - "label": "", - "description": "" + "label": "启用右击小组件", + "description": "右键单击小组件时显示 Homarr 的上下文菜单。禁用后可使用浏览器的默认菜单(例如“在新标签页中打开链接”)。" }, "defaultSearchEngine": { "label": "默认搜索引擎" @@ -691,206 +691,206 @@ "customWidget": { "page": { "list": { - "title": "自定义小部件", - "noResults": "暂无自定义小部件。创建您的第一个!", + "title": "自定义小组件", + "noResults": "暂无自定义小组件,创建第一个吧!", "loading": "加载中……" }, "create": { - "title": "创建自定义小部件", - "subtitle": "定义 API URL、身份验证以及如何显示响应数据。" + "title": "创建自定义小组件", + "subtitle": "定义 API URL、认证方式以及响应数据的显示方式。" }, "edit": { - "title": "编辑自定义小部件" + "title": "编辑自定义小组件" } }, "action": { "create": "创建", - "import": "", - "save": "", - "delete": "", - "deleteConfirm": "", - "export": "", - "addMapping": "", - "addColumn": "", - "edit": "", - "removeMapping": "", - "removeColumn": "", - "addItem": "", - "removeItem": "", - "addBar": "", - "duplicate": "", - "copyAiPrompt": "", - "enable": "", - "disable": "", - "menu": "" + "import": "导入", + "save": "保存", + "delete": "删除自定义小组件", + "deleteConfirm": "确定要删除“{name}”吗?此操作不可撤销。", + "export": "导出", + "addMapping": "添加映射", + "addColumn": "添加列", + "edit": "编辑", + "removeMapping": "删除映射", + "removeColumn": "删除列", + "addItem": "添加项", + "removeItem": "删除项", + "addBar": "添加条", + "duplicate": "复制", + "copyAiPrompt": "复制 AI 提示词", + "enable": "启用", + "disable": "禁用", + "menu": "操作" }, "field": { - "name": "", - "description": "", - "iconUrl": "", - "url": "", - "authType": "", - "headerName": "", - "method": "", - "requestBody": "", - "displayType": "", - "jsonPath": "", - "jsonPathHint": "", - "label": "", - "unit": "", - "mappings": "", - "tablePath": "", - "tablePathHint": "", - "columns": "", - "header": "", - "items": "", - "gridColumns": "", - "cardStyle": "", - "bars": "", - "valuePath": "", - "maxPath": "", - "showPercentage": "", - "barSize": "", - "goodValues": "", - "dotSize": "", - "valueSize": "", - "labelPosition": "", - "kvLayout": "", - "striped": "", - "compact": "", - "maxHeight": "", - "color": "", - "buttonLabel": "", - "buttonColor": "", - "confirmText": "", - "confirmTextHint": "", - "successMessage": "", - "successMessageHint": "", - "enabled": "", + "name": "名称", + "description": "描述", + "iconUrl": "图标 URL", + "url": "URL", + "authType": "认证类型", + "headerName": "标头/查询参数名称", + "method": "方法", + "requestBody": "请求体", + "displayType": "显示类型", + "jsonPath": "JSON 路径", + "jsonPathHint": "使用 JSONPath 语法提取值,例如 $.data.temperature 或 $.items[0].name", + "label": "标签", + "unit": "单位", + "mappings": "映射", + "tablePath": "数组 JSON 路径", + "tablePathHint": "响应中数组的路径,例如 $.data.items", + "columns": "列", + "header": "标头", + "items": "项", + "gridColumns": "网格列数", + "cardStyle": "卡片样式", + "bars": "条形图", + "valuePath": "数值的 JSON 路径", + "maxPath": "最大值对应的 JSON 路径", + "showPercentage": "显示百分比", + "barSize": "条形图尺寸", + "goodValues": "良好值(逗号分隔)", + "dotSize": "点大小", + "valueSize": "数值大小", + "labelPosition": "标签位置", + "kvLayout": "布局", + "striped": "交替行颜色", + "compact": "紧凑模式", + "maxHeight": "最大高度 (px)", + "color": "颜色", + "buttonLabel": "按钮标签", + "buttonColor": "按钮颜色", + "confirmText": "确认文本", + "confirmTextHint": "如果设置,执行请求前将显示确认对话框", + "successMessage": "成功消息", + "successMessageHint": "成功执行后显示的消息", + "enabled": "启用", "template": { - "label": "", - "description": "" + "label": "JSX 模板", + "description": "Mantine JSX 模板。通过数据绑定(如 data.name)访问 API 响应字段。仅使用白名单组件。禁止使用:constructor、__proto__、eval、Function、window、document。" } }, "placeholder": { - "exampleLabel": "", - "exampleLabelShort": "", - "exampleHeader": "", - "exampleUnit": "", - "iconUrl": "", - "url": "", - "headerName": "", - "jsonPath": "", - "tablePath": "", - "columnJsonPath": "", - "valuePath": "", - "maxPath": "", - "goodValues": "", - "color": "" + "exampleLabel": "CPU 温度", + "exampleLabelShort": "CPU 温度", + "exampleHeader": "名称", + "exampleUnit": "°C", + "iconUrl": "https://...", + "url": "https://api.example.com/api/v1/status", + "headerName": "X-API-Key", + "jsonPath": "$.data.value", + "tablePath": "$.data.items", + "columnJsonPath": "$.name", + "valuePath": "$.data.used", + "maxPath": "$.data.total", + "goodValues": "online, true, up", + "color": "blue" }, "cardStyleOption": { - "filled": "", - "outline": "", - "subtle": "" + "filled": "填充", + "outline": "轮廓", + "subtle": "淡雅" }, "layoutOption": { - "list": "", - "grid": "" + "list": "列表", + "grid": "网格" }, "sizeOption": { - "sm": "", - "md": "", - "lg": "", - "xl": "" + "sm": "小", + "md": "中", + "lg": "大", + "xl": "特大" }, "labelPositionOption": { - "above": "", - "below": "" + "above": "上方", + "below": "下方" }, "fieldset": { - "general": "", - "connection": "", - "display": "" + "general": "常规", + "connection": "API 连接", + "display": "显示配置" }, "displayTypeDescription": { - "singleValue": "", - "keyValue": "", - "table": "", - "statGrid": "", - "progressBars": "", - "statusIndicator": "", - "countGrid": "", - "raw": "", - "actionButton": "", - "customJsx": "" + "singleValue": "显示一个带标签和单位的提取值", + "keyValue": "并排显示多个带标签的值", + "table": "以表格格式显示数据行", + "statGrid": "网格中带颜色的统计卡片 — 类似于 Pi-Hole 或 Speedtest 小组件", + "progressBars": "显示使用量或完成进度的进度条 — 用于磁盘、内存、配额", + "statusIndicator": "显示在线/离线或健康状态的彩色状态点", + "countGrid": "紧凑网格中的数字 + 标签计数器", + "raw": "用于调试的格式化 JSON 输出", + "actionButton": "点击时触发请求的按钮", + "customJsx": "使用白名单 Mantine 组件的自定义 JSX 布局,支持完整 API 数据绑定" }, "method": { - "GET": "", - "POST": "", - "PUT": "", - "DELETE": "", - "PATCH": "" + "GET": "GET", + "POST": "POST", + "PUT": "PUT", + "DELETE": "删除", + "PATCH": "PATCH" }, "authType": { - "none": "", - "bearer": "", - "basic": "", - "apiKeyHeader": "", - "apiKeyQuery": "" + "none": "空", + "bearer": "Bearer 令牌", + "basic": "基本认证", + "apiKeyHeader": "API 密钥(请求头)", + "apiKeyQuery": "API 密钥(查询参数)" }, "displayType": { - "singleValue": "", - "keyValue": "", - "table": "", - "statGrid": "", - "progressBars": "", - "statusIndicator": "", - "countGrid": "", - "raw": "", - "actionButton": "", - "customJsx": "" + "singleValue": "单个值", + "keyValue": "键值", + "table": "表格", + "statGrid": "统计网格", + "progressBars": "进度条", + "statusIndicator": "状态指示器", + "countGrid": "计数网格", + "raw": "原始 JSON", + "actionButton": "操作按钮", + "customJsx": "自定义 JSX" }, "secret": { - "apiKey": "", - "username": "", - "password": "", - "savedPlaceholder": "" + "apiKey": "API 密钥/令牌", + "username": "用户名", + "password": "密码", + "savedPlaceholder": "•••••••(已保存,留空则保持不变)" }, "preview": { - "title": "", - "test": "", - "hint": "", - "rawResponse": "" + "title": "预览", + "test": "测试", + "hint": "点击“测试”可使用当前配置预览 API 响应。", + "rawResponse": "原始 JSON 响应" }, "notification": { - "created": "", - "updated": "", - "deleted": "", - "imported": "", - "aiPromptCopied": "", - "aiPromptDescription": "", - "aiPromptCopy": "", - "aiPromptNoResponse": "", - "aiPromptCopyError": "", - "createError": "", - "updateError": "", - "importError": "", - "exportError": "", - "duplicated": "", - "duplicateError": "", - "toggleError": "", - "deleteError": "", - "previewError": "", - "executed": "", - "executeError": "" + "created": "小组件“{name}”创建成功。", + "updated": "小组件“{name}”更新成功。", + "deleted": "小组件“{name}”已删除。", + "imported": "小组件导入成功。", + "aiPromptCopied": "AI 提示词已复制到剪贴板", + "aiPromptDescription": "复制一个预置提示词,可粘贴到任何 AI 聊天机器人(ChatGPT、Claude、Gemini...)中。其中包含完整的小组件架构和您的 API 响应数据,AI 可据此为您生成可直接导入的 JSON 配置。", + "aiPromptCopy": "复制到剪贴板", + "aiPromptNoResponse": "提示:先运行测试,将 API 响应包含在提示词中,可获得更好效果。", + "aiPromptCopyError": "复制提示词到剪贴板失败,您的浏览器可能不支持此功能。", + "createError": "创建小组件失败。请检查您的 API 端点是否可访问。", + "updateError": "更新小组件失败,您的更改未能保存。", + "importError": "导入小组件失败。请确保文件为有效的自定义小组件 JSON 导出文件。", + "exportError": "导出小组件失败。", + "duplicated": "小组件“{name}”已复制。", + "duplicateError": "复制小组件失败。", + "toggleError": "切换小组件状态失败。", + "deleteError": "删除小组件失败。", + "previewError": "获取预览失败,请检查 URL 后重试。", + "executed": "操作执行成功。", + "executeError": "操作失败:{error}" }, "table": { - "name": "", - "url": "", - "display": "" + "name": "名称", + "url": "URL", + "display": "显示" }, "widget": { - "disabled": "" + "disabled": "此小组件已禁用" } }, "integration": { @@ -994,8 +994,8 @@ "create": "新建集成" }, "grid": { - "widgetCount": "{count} 个小部件", - "noWidgets": "无小部件", + "widgetCount": "{count} 个小组件", + "noWidgets": "暂无小组件", "detected": "通过 Docker 检测到" }, "category": { @@ -1008,10 +1008,10 @@ "downloadClient": "下载", "usenet": "Usenet", "torrent": "种子", - "miscellaneous": "杂项", + "miscellaneous": "其它", "smartHomeServer": "智能家居", "indexerManager": "索引器", - "healthMonitoring": "健康", + "healthMonitoring": "健康状态", "search": "搜索", "mediaTranscoding": "转码", "networkController": "网络", @@ -1025,7 +1025,7 @@ "mediaMonitoring": "监控", "speedtest": "速度测试", "analytics": "分析", - "vpn": "", + "vpn": "VPN", "mediaLibrary": "媒体库", "uptimeMonitoring": "运行时间监控" }, @@ -1290,12 +1290,12 @@ "newLabel": "新建API密钥(机密)" }, "patchmonApiKey": { - "label": "", - "newLabel": "" + "label": "API 密钥", + "newLabel": "新 API Key" }, "patchmonApiSecret": { - "label": "", - "newLabel": "" + "label": "API 机密", + "newLabel": "新 API Secret" }, "githubAppId": { "label": "应用 ID", @@ -1341,20 +1341,20 @@ } }, "traefik": { - "name": "", - "description": "" + "name": "Traefik", + "description": "云原生反向代理和负载均衡器" }, "paperlessNgx": { "name": "Paperless-ngx", "description": "用于归档和组织扫描文档的文档管理系统" }, "patchmon": { - "name": "", - "description": "" + "name": "PatchMon", + "description": "Linux 主机的补丁管理和监控" }, "bazarr": { - "name": "", - "description": "" + "name": "Bazarr", + "description": "Sonarr 和 Radarr 的配套应用,用于管理和下载字幕" } }, "media": { @@ -1421,7 +1421,7 @@ "saveChanges": "保存更改", "cancel": "取消", "delete": "删除", - "discard": "Discard", + "discard": "放弃", "close": "关闭", "confirm": "确认", "continue": "继续", @@ -1438,9 +1438,9 @@ "iconPicker": { "label": "图标 URL", "header": "输入名称或对象来筛选图标… Homarr 将为您搜索 {countIcons} 图标。", - "headerLoading": "", - "noResults": "", - "error": "" + "headerLoading": "输入以搜索图标…", + "noResults": "没有与“{search}”匹配的图标。", + "error": "无法加载图标。" }, "colorScheme": { "options": { @@ -1492,7 +1492,7 @@ "switchToDarkMode": "切换到暗黑模式", "switchToLightMode": "切换到日间模式", "management": "管理中心", - "docker": "", + "docker": "Docker", "preferences": "偏好设置", "logout": "退出登录", "login": "登录", @@ -1623,15 +1623,15 @@ "menu": { "label": { "settings": "设置", - "refresh": "", - "options": "", - "actions": "" + "refresh": "刷新", + "options": "选项", + "actions": "操作" }, "status": { - "success": "", - "error": "", - "loading": "", - "idle": "" + "success": "正常", + "error": "错误", + "loading": "加载中", + "idle": "空闲" } }, "create": { @@ -1659,7 +1659,7 @@ "edit": { "title": "编辑项目", "tab": { - "widget": "编辑小部件", + "widget": "编辑小组件", "app": "编辑应用" }, "app": { @@ -1691,11 +1691,11 @@ }, "widget": { "integrationSelect": { - "configureIntegrationFirst": "", - "loading": "", - "placeholder": "", - "noResults": "", - "loadError": "" + "configureIntegrationFirst": "请先连接一个集成", + "loading": "加载中...", + "placeholder": "选择一个选项", + "noResults": "未找到结果", + "loadError": "加载选项失败" }, "app": { "name": "应用", @@ -1726,7 +1726,7 @@ "label": "描述显示模式", "description": "选择应用描述的显示方式", "option": { - "normal": "窗口组件内", + "normal": "小组件内部", "tooltip": "在工具提示中显示", "hidden": "隐藏" } @@ -1785,11 +1785,11 @@ } }, "archiveTeamWarrior": { - "name": "", - "description": "", + "name": "ArchiveTeam Warrior", + "description": "监控 ArchiveTeam Warrior 状态、项目和活跃的归档任务。", "option": { "showBroadcastMessage": { - "label": "" + "label": "显示广播消息" } } }, @@ -1907,14 +1907,14 @@ "description": "使用 ISO 8601 格式化日期(这将覆盖其他选项)" }, "showWeather": { - "label": "", - "description": "" + "label": "显示天气", + "description": "在卡片角落显示一个小型动画天气图标和温度" }, "weatherLocation": { - "label": "" + "label": "天气位置" }, "isWeatherFormatFahrenheit": { - "label": "" + "label": "使用华氏度" } } }, @@ -1944,7 +1944,7 @@ "noStation": "未选择车站", "option": { "baseUrl": { - "label": "" + "label": "Search.ch URL" }, "station": { "label": "车站" @@ -1953,7 +1953,7 @@ }, "notebook": { "name": "笔记", - "description": "支持 Markdown 的简单笔记本小部件", + "description": "一个支持 Markdown 的简易笔记本小组件", "option": { "showToolbar": { "label": "显示帮助您写下 Markdown 的工具栏" @@ -1976,8 +1976,8 @@ "clear": "清除格式", "heading": "标题 {level}", "align": "对齐文本: {position}", - "directionLtr": "", - "directionRtl": "", + "directionLtr": "设置文字方向为从左到右", + "directionRtl": "设置文字方向为从右到左", "blockquote": "引用", "horizontalLine": "横线", "bulletList": "符号列表", @@ -2255,7 +2255,7 @@ }, "forecastDayCount": { "label": "预报天数", - "description": "当小部件不够宽时,显示的天数减少" + "description": "当小组件宽度不足时,显示的天数会减少" }, "dateFormat": { "label": "日期格式", @@ -2268,7 +2268,7 @@ "sunset": "日落", "maxWindSpeed": "最大风速:{maxWindSpeed} {unit}", "maxWindGusts": "最大阵风:{maxWindGusts} {unit}", - "humidity": "" + "humidity": "湿度:{humidity}%" }, "kind": { "clear": "晴朗", @@ -2328,9 +2328,9 @@ "label": "显示文件系统信息" }, "visibleStorageVolumes": { - "label": "", - "description": "", - "placeholder": "" + "label": "可见存储卷", + "description": "仅当所有选定的集成都是 Synology 时可用。留空以显示所有检测到的存储卷。", + "placeholder": "选择存储卷" }, "defaultTab": { "label": "默认标签" @@ -2409,10 +2409,10 @@ }, "dockerContainers": { "name": "Docker 状态", - "description": "您的容器状态 (这个小部件只能用管理员权限添加)", + "description": "容器的统计信息(此小组件仅管理员权限可添加)", "option": { "columns": { - "label": "" + "label": "要显示的列" }, "enableRowSorting": { "label": "启用项目排序" @@ -2430,19 +2430,19 @@ "label": "倒序" }, "columnOrder": { - "label": "" + "label": "列顺序" }, "columnWidths": { - "label": "" + "label": "列宽" } }, "error": { "internalServerError": "获取容器统计信息失败", - "layoutSaveTitle": "", - "layoutSaveMessage": "" + "layoutSaveTitle": "表格布局未保存", + "layoutSaveMessage": "Docker 表格布局无法保存。请再次尝试移动或调整列宽。" }, "empty": { - "noContainers": "" + "noContainers": "未找到容器" } }, "coolify": { @@ -2479,9 +2479,9 @@ } }, "vpn": { - "name": "", - "description": "", - "serviceUnavailable": "", + "name": "VPN", + "description": "您的 VPN 连接概览", + "serviceUnavailable": "VPN 不可用", "option": {} }, "ups": { @@ -2489,7 +2489,7 @@ "description": "通过 NUT 服务器监控 UPS 设备的状态", "option": { "showBattery": { - "label": "显示电池" + "label": "显示电量" }, "showLoad": { "label": "显示负载" @@ -2500,8 +2500,8 @@ }, "status": { "online": "在线", - "onBattery": "使用电池", - "lowBattery": "电池电量低", + "onBattery": "电池模式", + "lowBattery": "电量低", "charging": "充电中", "unknown": "未知" }, @@ -2627,7 +2627,7 @@ }, "viewMode": { "label": "视图", - "description": "组件主体中要显示的内容", + "description": "小组件主体中显示的内容", "option": { "chart": "图表", "events": "事件图表", @@ -2738,31 +2738,31 @@ "description": "禁用此功能对plex不起作用" }, "showBitrate": { - "label": "", - "description": "" + "label": "显示比特率", + "description": "在位置旁边显示当前播放比特率" }, "showLocation": { - "label": "", - "description": "" + "label": "显示位置", + "description": "显示流媒体是在局域网还是广域网" } }, "footer": { - "streams": "", - "totalBitrate": "" + "streams": "{count, plural, one {# 个活跃流} other {# 个活跃流}}", + "totalBitrate": "总计:{bitrate}" }, "items": { "currentlyPlaying": "正在播放", "user": "用户", "name": "名称", "id": "ID", - "status": "", - "directPlay": "", - "directStream": "", - "transcodeVideo": "", - "transcodeAudio": "", - "transcoding": "", - "paused": "", - "remaining": "", + "status": "状态", + "directPlay": "直接播放", + "directStream": "直接串流", + "transcodeVideo": "视频转码", + "transcodeAudio": "音频转码", + "transcoding": "转码中", + "paused": "已暂停", + "remaining": "剩余 {minutes} 分钟", "metadata": { "title": "技术统计", "video": { @@ -2788,40 +2788,40 @@ "description": "Paperless-ngx 文档存档概览", "documentsTotal": "文档总数", "documentsInbox": "收件箱", - "correspondents": "通讯员", + "correspondents": "发件人", "tags": "标签", "documentTypes": "文档类型", - "inboxRatio": "收件箱比率", + "inboxRatio": "收件箱比例", "inbox": "收件箱", "total": "总计", "option": { "showInboxRatio": { - "label": "显示收件箱比率", - "description": "在主要部分显示收件箱与总文档的比率" + "label": "显示收件箱比例", + "description": "在概览区显示收件箱文档与总文档的比例" }, "showInboxRing": { - "label": "显示收件箱圆环", - "description": "在收件箱比率旁边显示进度圆环" + "label": "显示收件箱环形图", + "description": "在收件箱比例旁显示进度环" }, "showDocumentsTotal": { "label": "显示文档总数", "description": "显示存档中的文档总数" }, "showDocumentsInbox": { - "label": "显示收件箱计数", - "description": "显示收件箱中未处理文档的数量" + "label": "显示收件箱数量", + "description": "显示收件箱中未处理的文档数量" }, "showCorrespondents": { - "label": "显示通讯员", - "description": "显示通讯员的总数" + "label": "显示发件人", + "description": "显示发件人总数" }, "showTags": { "label": "显示标签", - "description": "显示标签的总数" + "description": "显示标签总数" }, "showDocumentTypes": { "label": "显示文档类型", - "description": "显示文档类型的总数" + "description": "显示文档类型总数" } }, "error": { @@ -2829,190 +2829,190 @@ } }, "patchmon": { - "name": "", - "description": "", - "title": "", - "totalHosts": "", - "hostsNeedingUpdates": "", - "securityUpdates": "", - "upToDateHosts": "", - "hostsWithSecurityUpdates": "", - "recentUpdates24h": "", - "totalOutdatedPackages": "", - "totalRepos": "", - "osDistribution": "", - "compliance": "", - "complianceRatio": "", - "lastUpdated": "", + "name": "PatchMon", + "description": "来自 PatchMon 的主机补丁状态概览", + "title": "PatchMon 统计信息", + "totalHosts": "主机总数", + "hostsNeedingUpdates": "需要更新", + "securityUpdates": "安全更新", + "upToDateHosts": "已更新的主机", + "hostsWithSecurityUpdates": "有安全更新的主机", + "recentUpdates24h": "更新(24小时)", + "totalOutdatedPackages": "过时的软件包", + "totalRepos": "软件源", + "osDistribution": "操作系统", + "compliance": "补丁合规性", + "complianceRatio": "{upToDate} / {total}", + "lastUpdated": "更新于 {time}", "option": { "showTotalHosts": { - "label": "", - "description": "" + "label": "显示主机总数", + "description": "显示所监控主机的总数" }, "showHostsNeedingUpdates": { - "label": "", - "description": "" + "label": "显示需要更新的主机", + "description": "显示至少有一个过时软件包的主机" }, "showSecurityUpdates": { - "label": "", - "description": "" + "label": "显示安全更新", + "description": "显示可用安全更新的总数" }, "showUpToDateHosts": { - "label": "", - "description": "" + "label": "显示已更新的主机", + "description": "显示没有过时软件包的主机" }, "showHostsWithSecurityUpdates": { - "label": "", - "description": "" + "label": "显示有安全更新的主机", + "description": "显示需要安全补丁的主机" }, "showRecentUpdates24h": { - "label": "", - "description": "" + "label": "显示最近更新(24小时)", + "description": "显示过去 24 小时内应用的更新数量" }, "showTotalOutdatedPackages": { - "label": "", - "description": "" + "label": "显示过时的软件包", + "description": "显示所有主机上过时软件包的总数" }, "showTotalRepos": { - "label": "", - "description": "" + "label": "显示软件源", + "description": "显示正在监控的活跃软件源数量" }, "enableThresholdColors": { - "label": "", - "description": "" + "label": "启用阈值颜色", + "description": "根据严重程度预设,将统计值颜色标记为绿色、黄色或红色" }, "useCustomThresholds": { - "label": "", - "description": "" + "label": "高级阈值设置", + "description": "按统计项自定义警告和严重阈值,可使用绝对数量或占主机总数的百分比进行设置" }, "thresholdMode": { "option": { - "absolute": "", - "percent": "" + "absolute": "绝对数量", + "percent": "占主机总数的百分比" } }, "hostsNeedingUpdatesThresholdMode": { - "label": "", - "description": "" + "label": "需要更新阈值模式", + "description": "将“需要更新”的数量以绝对数值或占主机总数的百分比方式进行比较" }, "hostsNeedingUpdatesWarningAt": { - "label": "", - "description": "" + "label": "需要更新警告阈值", + "description": "当值达到此阈值时显示黄色" }, "hostsNeedingUpdatesCriticalAt": { - "label": "", - "description": "" + "label": "需要更新临界阈值", + "description": "当值达到此阈值时显示红色" }, "securityUpdatesThresholdMode": { - "label": "", - "description": "" + "label": "安全更新阈值模式", + "description": "将安全更新计数作为绝对值或总主机数的百分比进行比较" }, "securityUpdatesWarningAt": { - "label": "", - "description": "" + "label": "安全更新警告阈值", + "description": "当值达到此阈值时显示黄色" }, "securityUpdatesCriticalAt": { - "label": "", - "description": "" + "label": "安全更新临界阈值", + "description": "当值达到此阈值时显示红色" }, "hostsWithSecurityUpdatesThresholdMode": { - "label": "", - "description": "" + "label": "有安全更新的主机阈值模式", + "description": "将主机计数作为绝对值或总主机数的百分比进行比较" }, "hostsWithSecurityUpdatesWarningAt": { - "label": "", - "description": "" + "label": "有安全更新的主机警告阈值", + "description": "当值达到此阈值时显示黄色" }, "hostsWithSecurityUpdatesCriticalAt": { - "label": "", - "description": "" + "label": "有安全更新的主机临界阈值", + "description": "当值达到此阈值时显示红色" }, "upToDateHostsThresholdMode": { - "label": "", - "description": "" + "label": "最新主机阈值模式", + "description": "将最新主机计数作为绝对值或总主机数的百分比进行比较" }, "upToDateHostsWarningAt": { - "label": "", - "description": "" + "label": "最新主机警告阈值", + "description": "当值达到或超过此阈值但低于全覆盖时显示黄色" }, "upToDateHostsCriticalAt": { - "label": "", - "description": "" + "label": "最新主机临界阈值", + "description": "当值低于此阈值时显示红色" }, "totalOutdatedPackagesThresholdMode": { - "label": "", - "description": "" + "label": "过期包阈值模式", + "description": "将过期包计数作为绝对值或总主机数的百分比进行比较" }, "totalOutdatedPackagesWarningAt": { - "label": "", - "description": "" + "label": "过期包警告阈值", + "description": "当值达到此阈值时显示黄色" }, "totalOutdatedPackagesCriticalAt": { - "label": "", - "description": "" + "label": "过期包临界阈值", + "description": "当值达到此阈值时显示红色" }, "showOsDistribution": { - "label": "", - "description": "" + "label": "显示操作系统分布", + "description": "按操作系统显示受监控主机的分类" }, "osDistributionLimit": { - "label": "", - "description": "", + "label": "操作系统分布限制", + "description": "显示的最大操作系统数量", "option": { - "3": "", - "5": "", - "10": "", - "all": "" + "3": "前 3 个", + "5": "前 5 个", + "10": "前 10 个", + "all": "全部" } }, "showOsVersion": { - "label": "", - "description": "" + "label": "显示操作系统版本", + "description": "可用时将操作系统版本附加到每个分布标签" }, "showComplianceHero": { - "label": "", - "description": "" + "label": "显示合规概要", + "description": "显示合规环,展示最新主机的百分比" }, "osDisplayMode": { - "label": "", - "description": "", + "label": "操作系统显示模式", + "description": "选择如何显示操作系统分布", "option": { - "bars": "", - "donut": "" + "bars": "水平条形图", + "donut": "环形图" } } }, "error": { - "internalServerError": "" + "internalServerError": "获取 PatchMon 数据失败" } }, "bazarr": { - "name": "", - "description": "", - "episodes": "", - "movies": "", - "providers": "", - "status": "", + "name": "Bazarr", + "description": "来自您 Bazarr 实例的缺失字幕和健康状态概览", + "episodes": "缺失剧集", + "movies": "缺失电影", + "providers": "提供商问题", + "status": "健康问题", "option": { "showMissingEpisodes": { - "label": "", - "description": "" + "label": "显示缺失剧集", + "description": "显示缺失字幕的剧集数量" }, "showMissingMovies": { - "label": "", - "description": "" + "label": "显示缺失电影", + "description": "显示缺失字幕的电影数量" }, "showProviderIssues": { - "label": "", - "description": "" + "label": "显示提供商问题", + "description": "显示有问题的字幕提供商数量" }, "showHealthIssues": { - "label": "", - "description": "" + "label": "显示健康问题", + "description": "显示 Bazarr 健康问题的数量" } }, "error": { - "internalServerError": "" + "internalServerError": "获取 Bazarr 数据失败" } }, "immich-serverStats": { @@ -3043,11 +3043,11 @@ }, "immich-albumCarousel": { "name": "Immich 相册", - "allPhotos": "", + "allPhotos": "所有照片", "option": { "albumId": { - "label": "相册ID", - "description": "这是一个唯一标识符,用于指向您的 Immich 相册。您可以通过打开相册并从 URL 中复制那段唯一字符串来找到该 ID,例如:'a21aca09-9f05-47dc-b1a1-7b53c547226e'" + "label": "相册", + "description": "选择一个 Immich 相册或选择“所有照片”。若留空,则会显示您整个图库中的随机照片。" }, "rotationIntervalSeconds": { "label": "间隔(秒)", @@ -3058,12 +3058,12 @@ "description": "在幻灯片底部显示图片日期和当前索引" }, "randomizePhotos": { - "label": "", - "description": "" + "label": "随机排列照片", + "description": "以随机顺序显示照片,而不是始终以相同的相册顺序开始。" } }, - "description": "Immich 相册图片幻灯片", - "noPhotos": "此相册中没有照片" + "description": "来自 Immich 相册或整个图库的照片幻灯片展示", + "noPhotos": "未找到照片" }, "downloads": { "name": "下载客户端", @@ -3107,18 +3107,18 @@ "description": "这将限制每个集成显示的项目数量,而不是全局项目" }, "columnOrder": { - "label": "" + "label": "列顺序" }, "columnWidths": { - "label": "" + "label": "列宽" } }, "errors": { "noColumns": "在项目中选择列", "noCommunications": "无法从集成中加载数据", - "noItems": "", - "actionFailed": "", - "actionFailedMessage": "" + "noItems": "未找到下载", + "actionFailed": "操作失败", + "actionFailedMessage": "操作无法完成。请重试。" }, "items": { "actions": { @@ -3142,7 +3142,7 @@ }, "id": { "columnTitle": "ID", - "detailsTitle": "" + "detailsTitle": "项目 ID" }, "integration": { "columnTitle": "集成" @@ -3177,7 +3177,7 @@ "time": { "columnTitle": "完成时间", "detailsTitle": "完成时间", - "eta": "" + "eta": "预计完成时间" }, "type": { "columnTitle": "类型", @@ -3223,21 +3223,21 @@ }, "globalRatio": "全局比率", "contextMenu": { - "copyName": "", - "copyId": "", - "clearFilters": "" + "copyName": "复制名称", + "copyId": "复制 ID", + "clearFilters": "清除筛选" }, "footer": { - "filter": "" + "filter": "筛选下载" }, "stats": { - "show": "", - "hide": "", - "overallProgress": "", - "totalSize": "", - "clients": "" + "show": "显示全局统计", + "hide": "隐藏全局统计", + "overallProgress": "整体队列进度", + "totalSize": "队列总大小", + "clients": "{count, plural, one {# 个客户端} other {# 个客户端}}" }, - "disconnected": "" + "disconnected": "(已断开)" }, "mediaReleases": { "name": "媒体发布", @@ -3276,10 +3276,10 @@ "label": "在新标签页中打开链接" }, "statusFilter": { - "label": "" + "label": "要显示的状态" }, "recentDays": { - "label": "" + "label": "仅显示最近的请求" } }, "pending": { @@ -3388,33 +3388,33 @@ } }, "mediaMissing": { - "name": "", - "description": "", + "name": "缺失和排队媒体", + "description": "显示来自 Radarr 和 Sonarr 的缺失和排队的电影和剧集", "option": { "showMissing": { - "label": "" + "label": "显示缺失项目" }, "showQueued": { - "label": "" + "label": "显示排队项目" }, "pageSize": { - "label": "" + "label": "每页项目数" } }, "tab": { - "missing": "", - "queued": "" + "missing": "缺失", + "queued": "排队中" }, "type": { - "movie": "", - "episode": "" + "movie": "电影", + "episode": "剧集" }, "empty": { - "missing": "", - "queued": "", - "noTabsEnabled": "" + "missing": "无缺失项目", + "queued": "无排队项目", + "noTabsEnabled": "请在小组件设置中至少启用一个标签" }, - "timeLeft": "" + "timeLeft": "剩余时间" }, "rssFeed": { "name": "RSS 订阅", @@ -3486,7 +3486,7 @@ "label": "提供者" }, "providerUrl": { - "label": "" + "label": "提供商 URL" }, "identifier": { "label": "标识符", @@ -3537,11 +3537,11 @@ "tooltip": "无法解析该提供者,请在导入镜像后手动设置" }, "tokens": { - "label": "", - "token": "", - "configured": "", - "notConfigured": "", - "save": "" + "label": "提供商令牌", + "token": "令牌", + "configured": "令牌已配置", + "notConfigured": "未设置令牌", + "save": "保存" } } }, @@ -3564,9 +3564,9 @@ "invalidIdentifier": "无效标识符", "noMatchingVersion": "没有找到匹配的版本", "noReleasesFound": "没有发现发布信息", - "noProviderSelected": "", + "noProviderSelected": "未选择提供商", "noProviderResponse": "提供者没有响应", - "unexpected": "" + "unexpected": "意外的提供商错误" } } }, @@ -3579,7 +3579,7 @@ }, "error": { "integrationsDisconnected": "没有可用的数据,所有集成已断开", - "unknownContentOption": "网络控制器摘要小部件的未知内容选项: " + "unknownContentOption": "网络控制器摘要小组件存在未知的内容选项: " }, "name": "网络控制器概述", "description": "显示网络控制器摘要(如Unifi Controller)" @@ -3611,12 +3611,12 @@ "label": "有线" } }, - "label": "小部件内容" + "label": "小组件内容" } }, "error": { "integrationsDisconnected": "没有可用的数据,所有集成已断开", - "unknownContentOption": "网络状态小部件的未知内容选项: " + "unknownContentOption": "网络状态小组件存在未知的内容选项: " }, "name": "网络状态", "description": "在网络上显示已连接的设备" @@ -3661,7 +3661,7 @@ "noItems": "没有可显示的通知。", "option": { "hideLogos": { - "label": "" + "label": "隐藏标志" } } }, @@ -3699,88 +3699,88 @@ "unhealthy": "不健康", "ok": "正常", "fail": "失败", - "noSectionsEnabled": "请在组件选项中至少启用一个模块。", + "noSectionsEnabled": "请至少选择一个小组件选项。", "error": { "internalServerError": "获取测速数据失败" } }, "uptimeKuma": { "name": "Uptime Kuma", - "description": "监控来自 Uptime Kuma 状态页的运行时间统计信息", + "description": "来自 Uptime Kuma 状态页面的运行时间监控统计", "option": { "showAverageUptime": { "label": "显示平均运行时间", - "description": "显示 24 小时平均运行时间百分比" + "description": "显示过去 24 小时的平均可用率" }, "showUptimeRing": { - "label": "显示运行时间圆环", - "description": "在平均运行时间值旁边显示进度圆环" + "label": "显示运行时间环形图", + "description": "在平均运行时间值旁显示进度环" }, "showTotalMonitors": { - "label": "显示监视器总数", - "description": "显示已配置的监视器总数" + "label": "显示监控项总数", + "description": "显示已配置的监控项总数" }, "showUpCount": { "label": "显示正常数量", - "description": "显示当前正常的监视器数量" + "description": "显示当前正常在线的监控项数量" }, "showDownCount": { - "label": "显示故障数量", - "description": "显示当前故障的监视器数量" + "label": "显示异常数量", + "description": "显示当前异常的监控项数量" }, "showPausedCount": { - "label": "显示暂停数量", - "description": "显示当前暂停的监视器数量" + "label": "显示已暂停数量", + "description": "显示当前已暂停的监控项数量" } }, - "averageUptime": "平均运行时间(24 小时)", + "averageUptime": "平均可用率(24小时)", "stats": { - "totalMonitors": "监视器总数", + "totalMonitors": "监控项总数", "upCount": "正常", - "downCount": "故障", - "pausedCount": "暂停" + "downCount": "异常", + "pausedCount": "已暂停" }, "error": { "internalServerError": "获取运行时间数据失败" } }, "traefik": { - "name": "", - "description": "", + "name": "Traefik", + "description": "Traefik 路由、服务、中间件和入口点概览", "option": { "showTcp": { - "label": "" + "label": "显示 TCP" }, "showUdp": { - "label": "" + "label": "显示 UDP" }, "showEntryPoints": { - "label": "" + "label": "显示入口点" } }, - "instances": "", - "versionUnknown": "", + "instances": "Traefik 实例", + "versionUnknown": "版本未知", "summary": { - "routers": "", - "entryPoints": "" + "routers": "路由", + "entryPoints": "入口点" }, "resource": { - "routers": "", - "services": "", - "middlewares": "" + "routers": "路由", + "services": "服务", + "middlewares": "中间件" }, "status": { - "healthy": "", - "errors": "", - "warnings": "" + "healthy": "健康", + "errors": "{count} 个错误", + "warnings": "{count} 个警告" }, "error": { - "internalServerError": "" + "internalServerError": "获取 Traefik 数据失败" } }, "audioStats": { - "name": "音频统计", - "description": "来自 Navidrome 或 Audiobookshelf 的库和播放统计信息", + "name": "音频统计信息", + "description": "来自 Navidrome 或 Audiobookshelf 的媒体库和播放统计信息", "option": { "showArtists": { "label": "显示艺术家", @@ -3816,7 +3816,7 @@ }, "compactMode": { "label": "紧凑模式", - "description": "使用更密集的布局和较小的磁贴,适合小尺寸小部件" + "description": "使用更紧凑的布局,磁贴更小,适合小尺寸小组件" } }, "artists": "艺术家", @@ -3892,9 +3892,9 @@ "label": "显示背景使用栏" }, "visibleStorageVolumes": { - "label": "", - "description": "", - "placeholder": "" + "label": "可见存储卷", + "description": "仅当所有选定的集成都是 Synology 时可用。留空以显示所有检测到的存储卷。", + "placeholder": "选择存储卷" } }, "status": { @@ -3902,24 +3902,24 @@ } }, "customApi": { - "name": "", - "description": "", + "name": "自定义 API", + "description": "通过自定义配置显示来自任何外部 API 的数据。", "option": { "definitionId": { - "label": "" + "label": "定义 ID" }, "refreshInterval": { - "label": "", - "description": "" + "label": "刷新间隔", + "description": "从 API 重新获取数据的频率(秒)" } }, - "noDefinition": "", - "definitionNotFound": "", - "noDisplayOutput": "", - "executeConfirm": "", - "executing": "", - "executeSuccess": "", - "executeFailed": "" + "noDefinition": "未选择定义", + "definitionNotFound": "找不到该小组件,可能已被删除。", + "noDisplayOutput": "无显示输出", + "executeConfirm": "您确定要执行此操作吗?", + "executing": "执行中...", + "executeSuccess": "操作执行成功", + "executeFailed": "操作失败,请检查小组件配置。" }, "beszelSystemTable": { "name": "Beszel 系统(表格)", @@ -3956,7 +3956,7 @@ "label": "显示温度" }, "showBattery": { - "label": "显示电池" + "label": "显示电量" }, "showServices": { "label": "显示服务" @@ -3968,17 +3968,17 @@ "label": "显示代理版本" }, "columnOrder": { - "label": "" + "label": "列顺序" }, "columnWidths": { - "label": "" + "label": "列宽" } }, "noRecords": "未找到系统", "error": { - "internalServerError": "", - "layoutSaveTitle": "", - "layoutSaveMessage": "" + "internalServerError": "无法连接到 Beszel。请检查您的集成设置。", + "layoutSaveTitle": "无法保存表格布局", + "layoutSaveMessage": "您的列顺序或宽度无法保存。请重试。" }, "column": { "system": "系统", @@ -3989,7 +3989,7 @@ "loadAvg": "平均负载", "net": "网络", "temp": "温度", - "battery": "电池", + "battery": "电量", "services": "服务", "uptime": "运行时间", "agent": "代理" @@ -3997,10 +3997,10 @@ }, "beszelSystemGrid": { "name": "Beszel 系统(网格)", - "description": "所有 Beszel 监控系统的卡片网格视图,具有实时指标", + "description": "所有 Beszel 监控系统的卡片网格视图,包含实时指标", "option": { "statusFilter": { - "label": "状态过滤器" + "label": "状态筛选" }, "showCpu": { "label": "显示 CPU" @@ -4024,7 +4024,7 @@ "label": "显示温度" }, "showBattery": { - "label": "显示电池" + "label": "显示电量" }, "showServices": { "label": "显示服务" @@ -4037,10 +4037,10 @@ } }, "error": { - "internalServerError": "" + "internalServerError": "无法连接到 Beszel。请检查您的集成设置。" }, "empty": { - "noSystems": "" + "noSystems": "未找到系统" }, "metric": { "cpu": "CPU", @@ -4050,7 +4050,7 @@ "loadAvg": "平均负载", "net": "网络", "temp": "温度", - "battery": "电池", + "battery": "电量", "services": "服务", "uptime": "运行时间", "agent": "代理" @@ -4069,7 +4069,7 @@ }, "empty": "未配置警报", "error": { - "internalServerError": "" + "internalServerError": "无法连接到 Beszel。请检查您的集成设置。" }, "history": "历史记录", "resolved": "已解决", @@ -4083,8 +4083,8 @@ "description": "CPU、内存、磁盘、网络和 Docker 容器指标的时间序列图表", "option": { "systemId": { - "label": "", - "description": "" + "label": "系统", + "description": "选择要显示统计信息的 Beszel 系统" }, "timePeriod": { "label": "时间段" @@ -4116,13 +4116,13 @@ }, "selectSystem": "选择系统", "empty": { - "noSystems": "" + "noSystems": "未找到系统" }, "error": { - "internalServerError": "", - "liveConnectionFailed": "", - "switchToHistorical": "", - "systemNotFound": "" + "internalServerError": "无法连接到 Beszel。请检查您的集成设置。", + "liveConnectionFailed": "实时连接失败。您的 Beszel 实例可能不支持实时流。", + "switchToHistorical": "切换到历史视图", + "systemNotFound": "未找到所选系统。请选择其他系统。" }, "chart": { "cpu": { @@ -4138,8 +4138,8 @@ }, "disk": { "title": "磁盘使用率", - "subtitle": "", - "series": "" + "subtitle": "按文件系统使用量", + "series": "根目录" }, "diskIO": { "title": "磁盘 I/O", @@ -4148,9 +4148,9 @@ "write": "写入" }, "storage": { - "title": "", - "subtitle": "", - "series": "" + "title": "存储", + "subtitle": "每个文件系统的磁盘使用量", + "series": "已用" }, "network": { "title": "带宽", @@ -4186,7 +4186,7 @@ "action": { "duplicate": { "title": "复制面板", - "message": "这将复制面板{name}及其所有内容。如果组件引用了不允许使用的集成,它们将被删除。", + "message": "这将复制面板 {name} 及其全部内容。如果小组件引用了您无权使用的集成,这些小组件将被移除。", "notification": { "success": { "title": "面板已复制", @@ -4545,9 +4545,9 @@ "text": "找不到请求的资源" }, "error": { - "title": "", - "text": "", - "digest": "" + "title": "此页面无法加载", + "text": "加载此页面时发生意外错误。您的导航仍然可用 — 请尝试重新加载,或使用侧边栏中的其他部分。", + "digest": "错误引用:{digest}" }, "navbar": { "items": { @@ -4555,7 +4555,7 @@ "boards": "面板", "apps": "应用", "integrations": "集成", - "customWidgets": "", + "customWidgets": "自定义小组件", "searchEngies": "搜索", "medias": "媒体", "users": { @@ -4830,7 +4830,7 @@ "title": "分析", "general": { "title": "发送匿名分析", - "text": "Homarr 将使用开源软件 Umami 发送匿名分析数据。启用后,我们会收集:Homarr 版本、数据库类型(MySQL/PostgreSQL/SQLite)、Docker/Kubernetes 状态、已启用的身份验证方法以及各项功能的数量。所有数据均为匿名且汇总收集——绝不收集任何个人信息、URL、名称或可识别数据。这有助于我们的开源团队了解使用模式并确定开发优先级。" + "text": "这有助于我们了解用户实际使用哪些功能,从而决定将开发精力投入到哪里。我们会收集诸如您拥有多少小组件或集成、Homarr 版本以及使用的数据库类型等信息。仅此而已——不包含个人信息、URL、名称,也不含任何可能识别您本人或您个人配置的内容。" } }, "crawlingAndIndexing": { @@ -5167,7 +5167,7 @@ "user": "用户", "app": "应用", "integration": "集成", - "item": "小部件", + "item": "小组件", "media": "媒体", "search_engine": "搜索引擎" }, @@ -5196,7 +5196,7 @@ "user": "用户", "app": "应用", "integration": "集成", - "item": "小部件", + "item": "小组件", "media": "媒体", "search_engine": "搜索引擎" }, @@ -5271,7 +5271,7 @@ "action": { "toggleBoardEdit": "切换面板编辑模式", "toggleColorScheme": "切换 亮色/暗色 模式", - "saveNotebook": "保存笔记(仅在笔记小工具内有效)", + "saveNotebook": "保存笔记本(仅在笔记本小组件内)", "openSpotlight": "打开搜索" }, "note": "提示:Mod 键在 macOS 上指 Ctrl 键和 ⌘ 键" @@ -5285,13 +5285,13 @@ "table": { "updated": "已更新 {when}", "refresh": { - "lastUpdated": "" + "lastUpdated": "上次刷新 {when}" }, "search": "搜索 {count} 容器", "selected": "{totalCount} 容器的 {selectCount}", "footer": "总计 {count} 容器", - "totalCpu": "", - "totalMemory": "" + "totalCpu": "总 CPU:{cpu}%", + "totalMemory": "总内存:{memory}" }, "field": { "name": { @@ -5376,7 +5376,7 @@ }, "remove": { "label": "删除", - "confirm": "", + "confirm": "再次点击以永久删除", "notification": { "success": { "title": "容器已删除", @@ -5420,7 +5420,7 @@ }, "error": { "internalServerError": "获取 Docker 容器失败", - "noColumns": "" + "noColumns": "请在小组件设置中至少选择一列" } }, "kubernetes": { @@ -5729,14 +5729,14 @@ } }, "custom-widgets": { - "label": "", + "label": "自定义小组件", "new": { - "label": "" + "label": "新建" }, "edit": { - "label": "", + "label": "编辑", "flow": { - "label": "" + "label": "流程" } } }, @@ -6017,10 +6017,10 @@ } }, "enableRightClickOnWidgets": { - "label": "", + "label": "启用小组件右键菜单", "children": { "detail": { - "title": "" + "title": "启用小组件右键功能" } } }, @@ -6045,7 +6045,7 @@ "description": "开始输入以搜索您的媒体请求集成", "disabled": { "noIntegration": "添加 Overseerr、Jellyseerr 或 Seerr 集成以启用媒体搜索。", - "scoped": "此小部件没有可访问的媒体请求集成。" + "scoped": "此小组件没有可用的媒体请求集成。" } } }, @@ -6494,11 +6494,11 @@ "board": { "welcome": { "title": "这是您的面板", - "description": "一个拖放式面板,您可以在其中排列应用和小部件。随时按 Ctrl+K 搜索应用、面板和操作。" + "description": "一个拖拽式面板,您可以在其中自由排列应用和小组件。随时按 Ctrl+K 搜索应用、面板和操作。" }, "editMode": { "title": "编辑模式", - "description": "点击此处进入编辑模式。然后您可以添加小部件、应用和分区——拖放调整位置,用角手柄调整大小。再次点击保存更改。" + "description": "点击此处进入编辑模式。您随后可添加小组件、应用和分区——拖动可调整位置,通过角控点可调整大小。再次点击即可保存更改。" }, "settings": { "title": "面板设置", diff --git a/packages/translation/src/lang/pl.json b/packages/translation/src/lang/pl.json index e447bf7f9..1d3ef1b91 100644 --- a/packages/translation/src/lang/pl.json +++ b/packages/translation/src/lang/pl.json @@ -2430,19 +2430,19 @@ "label": "Odwróć sortowanie" }, "columnOrder": { - "label": "" + "label": "Kolejność kolumn" }, "columnWidths": { - "label": "" + "label": "Szerokości kolumn" } }, "error": { "internalServerError": "Nie udało się pobrać statystyk kontenerów", - "layoutSaveTitle": "", - "layoutSaveMessage": "" + "layoutSaveTitle": "Nie zapisano układu tabeli", + "layoutSaveMessage": "Nie można zapisać układu tabeli Dockera. Spróbuj ponownie przesunąć lub zmienić rozmiar kolumny." }, "empty": { - "noContainers": "" + "noContainers": "Nie znaleziono kontenerów" } }, "coolify": { @@ -2738,17 +2738,17 @@ "description": "Wyłączenie tej opcji nie zadziała w przypadku Plex" }, "showBitrate": { - "label": "", - "description": "" + "label": "Pokaż bitrate", + "description": "Pokaż bieżącą przepływność odtwarzania obok lokalizacji" }, "showLocation": { - "label": "", - "description": "" + "label": "Pokaż lokalizację", + "description": "Pokaż, czy strumień jest w sieci LAN, czy WAN" } }, "footer": { - "streams": "", - "totalBitrate": "" + "streams": "{count, plural, one {# aktywny strumień} few {# aktywne strumienie} many {# aktywnych strumieni} other {# aktywnych strumieni}}", + "totalBitrate": "Łącznie: {bitrate}" }, "items": { "currentlyPlaying": "Obecnie odtwarzane", @@ -2757,9 +2757,9 @@ "id": "ID", "status": "Status", "directPlay": "Odtwarzanie bezpośrednie", - "directStream": "", - "transcodeVideo": "", - "transcodeAudio": "", + "directStream": "Bezpośredni strumień", + "transcodeVideo": "Transkodowanie wideo", + "transcodeAudio": "Transkodowanie audio", "transcoding": "Transkodowanie", "paused": "Zatrzymane", "remaining": "{minutes} min pozostało", @@ -3107,18 +3107,18 @@ "description": "To ograniczy liczbę wyświetlanych elementów dla każdej integracji, a nie globalnie." }, "columnOrder": { - "label": "" + "label": "Kolejność kolumn" }, "columnWidths": { - "label": "" + "label": "Szerokości kolumn" } }, "errors": { "noColumns": "Wybierz kolumny w elementach", "noCommunications": "Nie można załadować danych z integracji", - "noItems": "", - "actionFailed": "", - "actionFailedMessage": "" + "noItems": "Nie znaleziono pobrań", + "actionFailed": "Akcja nie powiodła się", + "actionFailedMessage": "Nie można ukończyć działania. Spróbuj ponownie." }, "items": { "actions": { @@ -3142,7 +3142,7 @@ }, "id": { "columnTitle": "ID", - "detailsTitle": "" + "detailsTitle": "Identyfikator elementu" }, "integration": { "columnTitle": "Integracja" @@ -3177,7 +3177,7 @@ "time": { "columnTitle": "Czas zakończenia", "detailsTitle": "Czas od/do zakończenia", - "eta": "" + "eta": "Przewidywany czas" }, "type": { "columnTitle": "Typ", @@ -3223,21 +3223,21 @@ }, "globalRatio": "Wskaźnik globalny", "contextMenu": { - "copyName": "", - "copyId": "", - "clearFilters": "" + "copyName": "Kopiuj nazwę", + "copyId": "Kopiuj ID", + "clearFilters": "Wyczyść filtry" }, "footer": { - "filter": "" + "filter": "Filtruj pobierania" }, "stats": { - "show": "", - "hide": "", - "overallProgress": "", - "totalSize": "", - "clients": "" + "show": "Pokaż globalne statystyki", + "hide": "Ukryj globalne statystyki", + "overallProgress": "Ogólny postęp kolejki", + "totalSize": "Całkowity rozmiar kolejki", + "clients": "{count, plural, one {# klient} few {# klientów} many {# klientów} other {# klientów}}" }, - "disconnected": "" + "disconnected": "(rozłączono)" }, "mediaReleases": { "name": "Wydania mediów", @@ -3968,17 +3968,17 @@ "label": "Pokaż wersję agenta" }, "columnOrder": { - "label": "" + "label": "Kolejność kolumn" }, "columnWidths": { - "label": "" + "label": "Szerokości kolumn" } }, "noRecords": "Nie znaleziono systemów", "error": { "internalServerError": "Nie można połączyć się z Beszel. Sprawdź ustawienia integracji.", - "layoutSaveTitle": "", - "layoutSaveMessage": "" + "layoutSaveTitle": "Nie można zapisać układu tabeli", + "layoutSaveMessage": "Nie można zapisać kolejności kolumn ani ich szerokości. Spróbuj ponownie." }, "column": { "system": "System", @@ -5376,7 +5376,7 @@ }, "remove": { "label": "Usuń", - "confirm": "", + "confirm": "Kliknij ponownie, aby trwale usunąć", "notification": { "success": { "title": "Usunięto kontenery", From 98bb1aecf8fd4b65b1cd4cad4a6ae24c362f6246 Mon Sep 17 00:00:00 2001 From: Timon <64960609+tiimonn@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:42:23 +0200 Subject: [PATCH 06/11] feat(integrations): add What's Up Docker (WUD) integration and widget (#6523) Co-authored-by: Thomas --- CODEOWNERS | 8 + apps/docs/README.md | 4 +- .../integrations/whats-up-docker/index.mdx | 48 ++++ .../integrations/whats-up-docker/index.ts | 9 + .../docs/widgets/whats-up-docker/index.mdx | 35 +++ .../docs/widgets/whats-up-docker/index.ts | 37 +++ docs/README.md | 7 + packages/api/src/router/widgets/index.ts | 1 + packages/api/src/router/widgets/wud.ts | 25 ++ .../src/docs/homarr-docs-sitemap.ts | 4 +- .../src/docs/integration-doc-slugs.ts | 1 + .../definitions/src/docs/widget-doc-slugs.ts | 1 + packages/definitions/src/integration.ts | 8 + .../definitions/src/widget-integration-map.ts | 5 +- packages/definitions/src/widget.ts | 1 + packages/integrations/src/base/creator.ts | 2 + packages/integrations/src/index.ts | 2 + .../src/wud/test/wud-integration.spec.ts | 269 ++++++++++++++++++ .../integrations/src/wud/wud-integration.ts | 68 +++++ packages/integrations/src/wud/wud-types.ts | 81 ++++++ .../request-handler/src/health-monitoring.ts | 2 +- packages/request-handler/src/wud.ts | 11 + packages/translation/src/lang/en.json | 36 +++ packages/widgets/src/catalog.ts | 1 + .../widgets/src/health-monitoring/index.ts | 4 +- packages/widgets/src/index.tsx | 2 + packages/widgets/src/manifest.ts | 2 + packages/widgets/src/refetch-intervals.ts | 1 + packages/widgets/src/wud/component.module.css | 7 + packages/widgets/src/wud/component.tsx | 222 +++++++++++++++ packages/widgets/src/wud/index.ts | 24 ++ 31 files changed, 922 insertions(+), 6 deletions(-) create mode 100644 apps/docs/docs/integrations/whats-up-docker/index.mdx create mode 100644 apps/docs/docs/integrations/whats-up-docker/index.ts create mode 100644 apps/docs/docs/widgets/whats-up-docker/index.mdx create mode 100644 apps/docs/docs/widgets/whats-up-docker/index.ts create mode 100644 packages/api/src/router/widgets/wud.ts create mode 100644 packages/integrations/src/wud/test/wud-integration.spec.ts create mode 100644 packages/integrations/src/wud/wud-integration.ts create mode 100644 packages/integrations/src/wud/wud-types.ts create mode 100644 packages/request-handler/src/wud.ts create mode 100644 packages/widgets/src/wud/component.module.css create mode 100644 packages/widgets/src/wud/component.tsx create mode 100644 packages/widgets/src/wud/index.ts diff --git a/CODEOWNERS b/CODEOWNERS index 7082b9bac..5565a85fc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -206,6 +206,14 @@ packages/integrations/src/unraid @manuel-rw apps/docs/docs/integrations/uptime-kuma @ajnart packages/integrations/src/uptime-kuma @ajnart +# What's Up Docker (WUD) - introduced in https://github.com/homarr-labs/homarr/pull/6523 by @tiimonn +apps/docs/docs/integrations/whats-up-docker @tiimonn +apps/docs/docs/widgets/whats-up-docker @tiimonn +packages/api/src/router/widgets/wud.ts @tiimonn +packages/integrations/src/wud @tiimonn +packages/request-handler/src/wud.ts @tiimonn +packages/widgets/src/wud @tiimonn + # Traefik - by @Nerdy-Technician apps/docs/docs/integrations/traefik @Nerdy-Technician apps/docs/docs/widgets/traefik @Nerdy-Technician diff --git a/apps/docs/README.md b/apps/docs/README.md index b66530488..118c04aef 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -55,8 +55,8 @@ pnpm typecheck docs/ ├── getting-started/ # Installation guides, glossary, prerequisites ├── management/ # Boards, apps, users, integrations, settings, tasks, API -├── integrations/ # 55 integration guides (one folder each) -├── widgets/ # 42 widget guides (one folder each) +├── integrations/ # 56 integration guides (one folder each) +├── widgets/ # 43 widget guides (one folder each) ├── advanced/ # SSO, env vars, proxy, styling, CLI, development └── community/ # FAQ, donate, license, translations, get-in-touch ``` diff --git a/apps/docs/docs/integrations/whats-up-docker/index.mdx b/apps/docs/docs/integrations/whats-up-docker/index.mdx new file mode 100644 index 000000000..31a0127f5 --- /dev/null +++ b/apps/docs/docs/integrations/whats-up-docker/index.mdx @@ -0,0 +1,48 @@ +--- +title: "What's Up Docker" +description: "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available." +hide_title: true +--- + +import { IntegrationHeader } from "@site/src/components/integrations/header"; +import { IntegrationCapabilites } from "@site/src/components/integrations/widgets"; +import { AddingIntegration } from "@site/src/components/integrations/adding"; +import { IntegrationSecrets } from "@site/src/components/integrations/secrets"; +import { wudIntegration } from "."; +import { wudWidget } from "@site/docs/widgets/whats-up-docker"; + + + +The What's Up Docker integration lets Homarr connect to your [WUD](https://github.com/getwud/wud) instance so you can see how many of your watched containers have image updates available directly from your dashboard. + +### Widgets & Capabilities + + + +### Adding the integration + + + +:::info URL configuration +Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically. +::: + +### Secrets + +WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password. + + diff --git a/apps/docs/docs/integrations/whats-up-docker/index.ts b/apps/docs/docs/integrations/whats-up-docker/index.ts new file mode 100644 index 000000000..a69dcafa4 --- /dev/null +++ b/apps/docs/docs/integrations/whats-up-docker/index.ts @@ -0,0 +1,9 @@ +import { IntegrationDefinition } from "@site/src/types"; + +export const wudIntegration: IntegrationDefinition = { + name: "What's Up Docker", + description: + "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available.", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg", + path: "../../integrations/whats-up-docker", +}; diff --git a/apps/docs/docs/widgets/whats-up-docker/index.mdx b/apps/docs/docs/widgets/whats-up-docker/index.mdx new file mode 100644 index 000000000..0786be36b --- /dev/null +++ b/apps/docs/docs/widgets/whats-up-docker/index.mdx @@ -0,0 +1,35 @@ +--- +title: "What's Up Docker" +description: "Displays how many watched containers have image updates available." +hide_title: true +--- + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { wudWidget } from "."; +import { wudIntegration } from "@site/docs/integrations/whats-up-docker"; + + + +This widget shows two stats from your WUD instance: the total number of monitored containers, and how many of them currently have an image update available. Optionally, it can also list each outdated container with its current and new version, linking to the release notes when WUD provides one. + +### Supported Integrations + + + +### Adding the widget + + + +### Configuration + + diff --git a/apps/docs/docs/widgets/whats-up-docker/index.ts b/apps/docs/docs/widgets/whats-up-docker/index.ts new file mode 100644 index 000000000..80a516328 --- /dev/null +++ b/apps/docs/docs/widgets/whats-up-docker/index.ts @@ -0,0 +1,37 @@ +import { WidgetDefinition } from "@site/src/types"; +import { IconBrandDocker } from "@tabler/icons-react"; + +export const wudWidget: WidgetDefinition = { + icon: IconBrandDocker, + name: "What's Up Docker", + description: "Displays how many watched containers have image updates available.", + path: "../../widgets/whats-up-docker", + configuration: { + items: [ + { + name: "Show title", + description: "Displays the icon and \"What's Up Docker\" label above the stats", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show update ring", + description: "Displays a ring showing the percentage of monitored containers with a pending update", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Layout", + description: "Arrange the two stats side by side (Horizontal) or stacked (Vertical)", + values: { type: "select", options: ["Horizontal", "Vertical"] }, + defaultValue: "Horizontal", + }, + { + name: "Show container update list", + description: "Lists each monitored container that has an image update available, linking to its release notes when known", + values: { type: "boolean" }, + defaultValue: "no", + }, + ], + }, +}; diff --git a/docs/README.md b/docs/README.md index 6f281b0f3..6dc6b6ff5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -429,6 +429,13 @@

Uptime
Kuma

+ + + + What's Up Docker +
+

What's
Up
Docker

+
diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 5db62a93c..ba5987463 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -44,4 +44,5 @@ export const widgetRouter = createTRPCRouter({ traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)), customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)), secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)), + wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)), }); diff --git a/packages/api/src/router/widgets/wud.ts b/packages/api/src/router/widgets/wud.ts new file mode 100644 index 000000000..4b510e660 --- /dev/null +++ b/packages/api/src/router/widgets/wud.ts @@ -0,0 +1,25 @@ +import { wudStatsRequestHandler } from "@homarr/request-handler/wud"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const wudRouter = createTRPCRouter({ + getStats: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.", + }, + }) + .concat(createOneIntegrationMiddleware("query", "wud")) + .query(async ({ ctx }) => { + const handler = wudStatsRequestHandler.handler(ctx.integration, {}); + const { data, timestamp } = await handler.getDataAsync(); + + return { + stats: data, + updatedAt: timestamp, + }; + }), +}); diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 7185a2fdd..292a6cd90 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -330,6 +330,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/peanut" | "/docs/integrations/beszel" | "/docs/integrations/traefik" + | "/docs/integrations/whats-up-docker" | "/docs/widgets/clock" | "/docs/widgets/weather" | "/docs/widgets/app" @@ -382,4 +383,5 @@ export type HomarrDocumentationPath = | "/docs/widgets/beszel-alerts" | "/docs/widgets/beszel-system-stats" | "/docs/widgets/traefik" - | "/docs/widgets/custom-api"; + | "/docs/widgets/custom-api" + | "/docs/widgets/whats-up-docker"; diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index 41ea8eb33..e03c20034 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -54,5 +54,6 @@ export const integrationDocSlugs: Record = { beszel: "beszel", traefik: "traefik", archiveTeamWarrior: null, + wud: "whats-up-docker", mock: null, }; diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index 8e51bbddb..cf72dbda4 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -55,4 +55,5 @@ export const widgetDocSlugs: Record = { beszelSystemStats: "beszel-system-stats", traefik: "traefik", customApi: "custom-api", + wud: "whats-up-docker", }; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index e2772fb94..5450c9bda 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -468,6 +468,14 @@ export const integrationDefs = { category: ["archiving"], documentationUrl: null, }, + wud: { + name: "What's Up Docker", + secretKinds: [[], ["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg", + category: ["healthMonitoring"], + documentationUrl: createDocumentationLink("/docs/integrations/whats-up-docker"), + defaultPort: 3000, + }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) mock: { name: "Mock", diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index 6a6fb32c6..beb6d0f02 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -24,7 +24,9 @@ export const widgetIntegrationSupport: Partial kind !== "patchmon"), + healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter( + (kind) => kind !== "patchmon" && kind !== "wud", + ), firewall: getIntegrationKindsByCategory("firewall"), notifications: getIntegrationKindsByCategory("notifications"), mediaReleases: ["mock", "emby", "jellyfin", "plex"], @@ -50,6 +52,7 @@ export const widgetIntegrationSupport: Partial { diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index c5c050b83..e95dbca77 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -53,6 +53,7 @@ export const widgetKinds = [ "beszelSystemStats", "traefik", "customApi", + "wud", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 8a9dc8b5f..9421e043d 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -54,6 +54,7 @@ import { UmamiIntegration } from "../umami/umami-integration"; import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration"; import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; import { UnraidIntegration } from "../unraid/unraid-integration"; +import { WudIntegration } from "../wud/wud-integration"; import type { Integration, IntegrationInput } from "./integration"; export const createIntegrationAsync = async ( @@ -133,6 +134,7 @@ export const integrationCreators = { beszel: BeszelIntegration, bazarr: BazarrIntegration, traefik: TraefikIntegration, + wud: WudIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index f172cec36..96482c908 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -1,5 +1,6 @@ // General integrations export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration"; +export { WudIntegration } from "./wud/wud-integration"; export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; export { TechnitiumDnsIntegration } from "./technitium/technitium-integration"; export { AnchorIntegration } from "./anchor/anchor-integration"; @@ -91,6 +92,7 @@ export type { ArchiveTeamWarriorItem, ArchiveTeamWarriorStatus, } from "./archive-team-warrior/archive-team-warrior-types"; +export type { WudStats, WudContainerUpdate } from "./wud/wud-types"; // Schemas export { anchorNotesListInputSchema } from "./anchor/anchor-types"; diff --git a/packages/integrations/src/wud/test/wud-integration.spec.ts b/packages/integrations/src/wud/test/wud-integration.spec.ts new file mode 100644 index 000000000..902707fd8 --- /dev/null +++ b/packages/integrations/src/wud/test/wud-integration.spec.ts @@ -0,0 +1,269 @@ +// @vitest-environment node +import { ParseError } from "@homarr/common/server"; +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; +}); + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../../base/integration"; +import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error"; +import type { IntegrationSecret } from "../../base/types"; +import { WudIntegration } from "../wud-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const TEST_URL = "https://wud.example.com"; +const TEST_USERNAME = "user"; +const TEST_PASSWORD = "p@ss"; + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const sampleContainersResponse = [ + { + id: "1", + name: "homeassistant", + displayName: "Home Assistant", + updateAvailable: true, + link: "https://github.com/home-assistant/core/releases/tag/2024.1.0", + image: { tag: { value: "2023.12.0" } }, + updateKind: { remoteValue: "2024.1.0" }, + }, + { id: "2", name: "traefik", updateAvailable: false }, + { + id: "3", + name: "grafana", + updateAvailable: true, + image: { tag: { value: "10.0.0" } }, + result: { tag: "10.1.0" }, + }, +]; + +const sampleUpdates = [ + { + id: "1", + name: "Home Assistant", + currentVersion: "2023.12.0", + newVersion: "2024.1.0", + link: "https://github.com/home-assistant/core/releases/tag/2024.1.0", + }, + { + id: "3", + name: "grafana", + currentVersion: "10.0.0", + newVersion: "10.1.0", + link: null, + }, +]; + +const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) => + new WudIntegration({ + id: "test-wud", + name: "Test WUD", + url: TEST_URL, + externalUrl: null, + decryptedSecrets, + }); + +const createIntegrationWithBasicAuth = () => + createIntegration([ + { kind: "username", value: TEST_USERNAME }, + { kind: "password", value: TEST_PASSWORD }, + ]); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("WudIntegration getStatsAsync", () => { + test("computes total and available-update counts from the containers list", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(sampleContainersResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + const stats = await createIntegration().getStatsAsync(); + + expect(stats).toStrictEqual({ totalContainers: 3, updatesAvailable: 2, updates: sampleUpdates }); + }); + + test("returns zero counts for an empty containers list", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify([]), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + const stats = await createIntegration().getStatsAsync(); + + expect(stats).toStrictEqual({ totalContainers: 0, updatesAvailable: 0, updates: [] }); + }); + + test("maps container updates using displayName, falling back to result.tag and null link", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(sampleContainersResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + const stats = await createIntegration().getStatsAsync(); + + expect(stats.updates).toStrictEqual(sampleUpdates); + }); + + test("throws ParseError when API response is not valid JSON", async () => { + mockFetch.mockResolvedValue( + new Response("not-json", { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => { + if (!(error instanceof IntegrationParseError)) return false; + + const cause = error.cause; + return cause instanceof ParseError && cause.message.includes("Invalid WUD containers response"); + }); + }); + + test("throws ParseError when API response does not match schema", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ invalid: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => { + if (!(error instanceof IntegrationParseError)) return false; + + const cause = error.cause; + return cause instanceof ParseError && cause.message.includes("Invalid WUD containers response"); + }); + }); + + test("throws when API returns an error", async () => { + mockFetch.mockResolvedValue( + new Response("Unauthorized", { + status: 401, + headers: { "content-type": "text/plain" }, + }) as unknown as Awaited>, + ); + + await expect(createIntegration().getStatsAsync()).rejects.toThrow(); + }); +}); + +describe("WudIntegration authentication", () => { + test("sends no Authorization header when no credentials are configured", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(sampleContainersResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + await createIntegration().getStatsAsync(); + + const [, requestInit] = mockFetch.mock.calls[0] ?? []; + const headers = (requestInit?.headers ?? {}) as Record; + expect(headers.Authorization).toBeUndefined(); + }); + + test("sends a Basic auth header when username and password are configured", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(sampleContainersResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + await createIntegrationWithBasicAuth().getStatsAsync(); + + const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`; + const [url, requestInit] = mockFetch.mock.calls[0] ?? []; + expect(String(url)).toContain("/api/containers"); + expect(requestInit?.headers).toMatchObject({ Authorization: expected }); + expect(requestInit?.timeout).toBe(10_000); + }); + + test("does not send credentials over a non-HTTPS URL", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(sampleContainersResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + const integration = new WudIntegration({ + id: "test-wud-http", + name: "Test WUD HTTP", + url: "http://wud.example.com", + externalUrl: null, + decryptedSecrets: [ + { kind: "username", value: TEST_USERNAME }, + { kind: "password", value: TEST_PASSWORD }, + ], + }); + + await integration.getStatsAsync(); + + const [, requestInit] = mockFetch.mock.calls[0] ?? []; + const headers = (requestInit?.headers ?? {}) as Record; + expect(headers.Authorization).toBeUndefined(); + }); +}); + +describe("WudIntegration testing endpoint", () => { + type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>; + + const invokeTesting = (integration: WudIntegration, input: IntegrationTestingInput) => { + const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration); + return testing(input); + }; + + const makeInput = (response: Response): IntegrationTestingInput => + ({ + fetchAsync: vi.fn().mockResolvedValue(response), + }) as unknown as IntegrationTestingInput; + + test("returns success when /api/app responds 200", async () => { + const input = makeInput(new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 })); + + const result = await invokeTesting(createIntegration(), input); + + expect(result.success).toBe(true); + }); + + test("returns a status-code failure when /api/app responds non-OK", async () => { + const input = makeInput(new Response("unauthorized", { status: 401 })); + + const result = await invokeTesting(createIntegration(), input); + + expect(result.success).toBe(false); + }); + + test("passes the Basic auth header through to fetchAsync when configured", async () => { + const response = new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 }); + const input = makeInput(response); + + await invokeTesting(createIntegrationWithBasicAuth(), input); + + const fetchAsyncMock = vi.mocked(input.fetchAsync); + const init = fetchAsyncMock.mock.calls[0]?.[1]; + const headers = (init?.headers ?? {}) as Record; + const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`; + expect(headers.Authorization).toBe(expected); + }); +}); diff --git a/packages/integrations/src/wud/wud-integration.ts b/packages/integrations/src/wud/wud-integration.ts new file mode 100644 index 000000000..d7bdb49eb --- /dev/null +++ b/packages/integrations/src/wud/wud-integration.ts @@ -0,0 +1,68 @@ +import { Buffer } from "node:buffer"; + +import { ParseError, ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { WudStats } from "./wud-types"; +import { mapWudStats, parseWudContainersResponseAsync } from "./wud-types"; + +const CONTAINERS_REQUEST_TIMEOUT_MS = 10_000; + +export class WudIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api/app"), { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + try { + await response.json(); + } catch (error) { + return TestConnectionError.ParseResult( + new ParseError("Invalid WUD app response", { + cause: error instanceof Error ? error : new Error(String(error)), + }), + ); + } + + return { success: true }; + } + + public async getStatsAsync(): Promise { + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/containers"), { + headers: this.getAuthHeaders(), + timeout: CONTAINERS_REQUEST_TIMEOUT_MS, + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + const containers = await parseWudContainersResponseAsync(response); + + return mapWudStats(containers); + } + + private getAuthHeaders(): Record | undefined { + if (!this.hasSecretValue("username") || !this.hasSecretValue("password")) { + return undefined; + } + + if (this.url("/api/app").protocol !== "https:") { + return undefined; + } + + const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString( + "base64", + ); + + return { Authorization: `Basic ${credentials}` }; + } +} diff --git a/packages/integrations/src/wud/wud-types.ts b/packages/integrations/src/wud/wud-types.ts new file mode 100644 index 000000000..df6a71a90 --- /dev/null +++ b/packages/integrations/src/wud/wud-types.ts @@ -0,0 +1,81 @@ +import { ParseError } from "@homarr/common/server"; +import { z } from "zod/v4"; + +const WUD_CONTAINERS_PARSE_ERROR_MESSAGE = "Invalid WUD containers response"; + +export const wudContainerSchema = z.object({ + id: z.string(), + name: z.string(), + displayName: z.string().nullable().optional(), + updateAvailable: z.boolean(), + link: z.string().nullable().optional(), + image: z + .object({ + tag: z + .object({ + value: z.string().nullable().optional(), + }) + .optional(), + }) + .optional(), + updateKind: z + .object({ + remoteValue: z.string().nullable().optional(), + }) + .optional(), + result: z + .object({ + tag: z.string().nullable().optional(), + }) + .optional(), +}); + +export const wudContainersResponseSchema = z.array(wudContainerSchema); + +export interface WudContainerUpdate { + id: string; + name: string; + currentVersion: string | null; + newVersion: string | null; + link: string | null; +} + +export interface WudStats { + totalContainers: number; + updatesAvailable: number; + updates: WudContainerUpdate[]; +} + +export const parseWudContainersResponseAsync = async (response: { + json: () => Promise; +}): Promise> => { + let json: unknown; + try { + json = await response.json(); + } catch (error) { + throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } + + const parseResult = await wudContainersResponseSchema.safeParseAsync(json); + if (!parseResult.success) { + throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, { cause: parseResult.error }); + } + + return parseResult.data; +}; + +const mapContainerUpdate = (container: z.infer): WudContainerUpdate => ({ + id: container.id, + name: container.displayName ?? container.name, + currentVersion: container.image?.tag?.value ?? null, + newVersion: container.updateKind?.remoteValue ?? container.result?.tag ?? null, + link: container.link ?? null, +}); + +export const mapWudStats = (containers: z.infer): WudStats => ({ + totalContainers: containers.length, + updatesAvailable: containers.filter((container) => container.updateAvailable).length, + updates: containers.filter((container) => container.updateAvailable).map(mapContainerUpdate), +}); diff --git a/packages/request-handler/src/health-monitoring.ts b/packages/request-handler/src/health-monitoring.ts index 959d3e0ed..463a0b217 100644 --- a/packages/request-handler/src/health-monitoring.ts +++ b/packages/request-handler/src/health-monitoring.ts @@ -6,7 +6,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl export const systemInfoRequestHandler = createIntegrationRequestHandler< SystemHealthMonitoring, - Exclude, "proxmox" | "coolify" | "beszel" | "patchmon">, + Exclude, "proxmox" | "coolify" | "beszel" | "patchmon" | "wud">, Record >({ async requestAsync(integration, _input) { diff --git a/packages/request-handler/src/wud.ts b/packages/request-handler/src/wud.ts new file mode 100644 index 000000000..03ab11f07 --- /dev/null +++ b/packages/request-handler/src/wud.ts @@ -0,0 +1,11 @@ +import { createIntegrationAsync } from "@homarr/integrations"; +import type { WudStats } from "@homarr/integrations"; + +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; + +export const wudStatsRequestHandler = createIntegrationRequestHandler>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getStatsAsync(); + }, +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 0c7b91dd9..84cb28036 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1344,6 +1344,10 @@ "name": "Traefik", "description": "Cloud-native reverse proxy and load balancer" }, + "wud": { + "name": "What's Up Docker", + "description": "Monitor which of your Docker containers have image updates available" + }, "paperlessNgx": { "name": "Paperless-ngx", "description": "Document management system for archiving and organizing scanned documents" @@ -1793,6 +1797,38 @@ } } }, + "wud": { + "name": "What's Up Docker", + "description": "Show how many watched containers have updates available.", + "title": "What's Up Docker", + "monitored": "Monitored", + "updatesAvailable": "{count, plural, one {# update available} other {# updates available}}", + "updateAvailable": "Update available", + "openReleaseNotes": "Open release notes", + "option": { + "showTitle": { + "label": "Show title" + }, + "showRing": { + "label": "Show update ring" + }, + "layout": { + "label": "Layout", + "option": { + "horizontal": { + "label": "Horizontal" + }, + "vertical": { + "label": "Vertical" + } + } + }, + "showUpdateList": { + "label": "Show container update list", + "description": "Lists each monitored container that has an image update available" + } + } + }, "dnsHoleSummary": { "name": "DNS Hole Summary", "description": "Displays the summary of your DNS Hole", diff --git a/packages/widgets/src/catalog.ts b/packages/widgets/src/catalog.ts index 7951dbf7d..a8a185310 100644 --- a/packages/widgets/src/catalog.ts +++ b/packages/widgets/src/catalog.ts @@ -107,4 +107,5 @@ export const widgetCatalogIcons: Record = { beszelSystemStats: IconChartAreaLine, traefik: IconRoute, customApi: IconApi, + wud: IconBrandDocker, }; diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts index a8dedc5c3..022afa60d 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -113,7 +113,9 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon }, ); }, - supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"), + supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter( + (kind) => kind !== "patchmon" && kind !== "wud", + ), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 061fc4b2d..36a9cbbfc 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -64,6 +64,7 @@ import * as beszelSystemStats from "./beszel-system-stats"; import * as video from "./video"; import * as weather from "./weather"; import * as customApi from "./custom-api"; +import * as wud from "./wud"; export type { WidgetDefinition, @@ -129,6 +130,7 @@ export const widgetImports = { beszelSystemStats, traefik, customApi, + wud, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts index 4bb2cdf2d..3fd61a0df 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -76,6 +76,7 @@ const moduleLoaders: Record Promise> = { beszelSystemStats: () => import("./beszel-system-stats") as Promise, traefik: () => import("./traefik") as Promise, customApi: () => import("./custom-api") as Promise, + wud: () => import("./wud") as Promise, }; // Keep these imports explicit so Turbopack can create one discoverable chunk per @@ -137,6 +138,7 @@ const componentLoaders: Record Promise> beszelSystemStats: () => import("./beszel-system-stats/component") as Promise, traefik: () => import("./traefik/component") as Promise, customApi: () => import("./custom-api/component") as Promise, + wud: () => import("./wud/component") as Promise, }; const definitionPromises = new Map>(); diff --git a/packages/widgets/src/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts index fcb1ffcd4..b1bf0e410 100644 --- a/packages/widgets/src/refetch-intervals.ts +++ b/packages/widgets/src/refetch-intervals.ts @@ -26,4 +26,5 @@ export const widgetQueryRefetchIntervals = [ { queryKey: [["widget", "stockPrice"]], intervalSeconds: null }, { queryKey: [["widget", "umami"]], intervalSeconds: null }, { queryKey: [["widget", "vpn"]], intervalSeconds: null }, + { queryKey: [["widget", "wud"]], intervalSeconds: null }, ] as const; diff --git a/packages/widgets/src/wud/component.module.css b/packages/widgets/src/wud/component.module.css new file mode 100644 index 000000000..1ab18a656 --- /dev/null +++ b/packages/widgets/src/wud/component.module.css @@ -0,0 +1,7 @@ +[data-mantine-color-scheme="light"] .card { + background-color: var(--mantine-color-gray-1); +} + +[data-mantine-color-scheme="dark"] .card { + background-color: var(--mantine-color-dark-7); +} diff --git a/packages/widgets/src/wud/component.tsx b/packages/widgets/src/wud/component.tsx new file mode 100644 index 000000000..0d2f6744a --- /dev/null +++ b/packages/widgets/src/wud/component.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { ActionIcon, Avatar, Badge, Card, Center, Group, RingProgress, Stack, Text, Tooltip } from "@mantine/core"; +import { IconBrandDocker, IconExternalLink, IconServer } from "@tabler/icons-react"; +import combineClasses from "clsx"; + +import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { getIconUrl } from "@homarr/definitions"; +import type { WudContainerUpdate } from "@homarr/integrations"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { WidgetEmptyState } from "../common/empty-state"; +import type { WidgetComponentProps } from "../definition"; +import classes from "./component.module.css"; + +export default function WudWidget({ integrationIds, options, width }: WidgetComponentProps<"wud">) { + const integrationId = integrationIds[0]; + if (!integrationId) return null; + return ; +} + +const WudWidgetContent = ({ + integrationId, + options, + width, +}: { + integrationId: string; + options: WidgetComponentProps<"wud">["options"]; + width: number; +}) => { + const t = useScopedI18n("widget.wud"); + const [data] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId }); + const board = useRequiredBoard(); + + const isTiny = width < 256; + const stats = data.stats; + + if (stats.totalContainers === 0) return ; + + const updatePercentage = Math.round((stats.updatesAvailable / stats.totalContainers) * 100); + const badgeColor = stats.updatesAvailable === 0 ? "green" : progressColor(updatePercentage); + const showUpdateList = options.showUpdateList && stats.updates.length > 0; + + const ring = ( + + + {updatePercentage}% + + + + } + sections={[{ value: updatePercentage, color: progressColor(updatePercentage) }]} + /> + ); + + const summary = ( + + + + + {stats.totalContainers} {t("monitored")} + + + + {t("updatesAvailable", { count: stats.updatesAvailable })} + + + ); + + const tinyContent = options.showRing ? ( + + + {stats.totalContainers} {t("monitored")} + + {t("updatesAvailable", { count: stats.updatesAvailable })} +
+ } + events={{ hover: true, focus: true, touch: false }} + > +
+ {ring} +
+ + ) : ( +
+ + {t("updatesAvailable", { count: stats.updatesAvailable })} + +
+ ); + + return ( + + {options.showTitle && !isTiny && ( + + + + + {t("title")} + + + {stats.updatesAvailable > 0 && ( + + {stats.updatesAvailable} + + )} + + )} + + {isTiny ? ( + tinyContent + ) : options.layout === "horizontal" ? ( + + {options.showRing && ring} + {summary} + + ) : ( + + {options.showRing && ring} + {summary} + + )} + + {showUpdateList && ( + + {stats.updates.map((update) => ( + + ))} + + )} + + ); +}; + +const progressColor = (percentage: number) => { + if (percentage < 40) return "green"; + else if (percentage < 60) return "yellow"; + else if (percentage < 90) return "orange"; + else return "red"; +}; + +const SHA256_DIGEST_PREFIX = "sha256:"; +const SHA256_DIGEST_LENGTH = SHA256_DIGEST_PREFIX.length + 64; +const GENERIC_MAX_LENGTH = 20; +const GENERIC_DISPLAY_LENGTH = 17; + +const isDigestVersion = (value: string | null): boolean => + value !== null && value.startsWith(SHA256_DIGEST_PREFIX) && value.length === SHA256_DIGEST_LENGTH; + +const truncateVersion = (value: string | null): string | null => { + if (!value) return value; + + if (value.length > GENERIC_MAX_LENGTH) { + return `${value.slice(0, GENERIC_DISPLAY_LENGTH)}…`; + } + + return value; +}; + +const buildVersionText = (currentVersion: string | null, newVersion: string | null) => + currentVersion && newVersion ? `${currentVersion} → ${newVersion}` : newVersion; + +const UpdateCard = ({ + update, + radius, + className, +}: { + update: WudContainerUpdate; + radius: string | undefined; + className: string | undefined; +}) => { + const t = useScopedI18n("widget.wud"); + const isDigestUpdate = isDigestVersion(update.newVersion); + const fullVersionText = buildVersionText(update.currentVersion, update.newVersion); + const versionText = isDigestUpdate + ? t("updateAvailable") + : buildVersionText(truncateVersion(update.currentVersion), truncateVersion(update.newVersion)); + + return ( + + + + {update.name} + + + {versionText && ( + + + {versionText} + + + )} + {update.link && ( + + + + )} + + + + ); +}; diff --git a/packages/widgets/src/wud/index.ts b/packages/widgets/src/wud/index.ts new file mode 100644 index 000000000..f7052adba --- /dev/null +++ b/packages/widgets/src/wud/index.ts @@ -0,0 +1,24 @@ +import { IconBrandDocker } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("wud", { + icon: IconBrandDocker, + refetchInterval: null, + supportedIntegrations: ["wud"], + createOptions() { + return optionsBuilder.from((factory) => ({ + showTitle: factory.switch({ defaultValue: true }), + showRing: factory.switch({ defaultValue: true }), + layout: factory.select({ + options: (["horizontal", "vertical"] as const).map((value) => ({ + value, + label: (t) => t(`widget.wud.option.layout.option.${value}.label`), + })), + defaultValue: "horizontal", + }), + showUpdateList: factory.switch({ defaultValue: false, withDescription: true }), + })); + }, +}).withDynamicImport(() => import("./component")); From 64febbce128e9b6572b36cb45c8558799de922b0 Mon Sep 17 00:00:00 2001 From: "homarr-update-contributors[bot]" <210161987+homarr-update-contributors[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:44:39 +0000 Subject: [PATCH 07/11] chore: update integrations list readme (#6551) Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com> --- packages/definitions/src/docs/homarr-docs-sitemap.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 292a6cd90..1a3550063 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -210,6 +210,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/unifi-controller" | "/docs/integrations/unraid" | "/docs/integrations/uptime-kuma" + | "/docs/integrations/whats-up-docker" | "/docs/management/api" | "/docs/management/apps" | "/docs/management/backup" @@ -276,6 +277,7 @@ export type HomarrDocumentationPath = | "/docs/widgets/video" | "/docs/widgets/vpn" | "/docs/widgets/weather" + | "/docs/widgets/whats-up-docker" | "" | "/sitemap.xml" | "/docs/integrations/sabnzbd" From 742bde1d272354cd353392779799d02015cf8ec7 Mon Sep 17 00:00:00 2001 From: Sven Date: Tue, 11 Aug 2026 10:59:28 +0200 Subject: [PATCH 08/11] fix(synology): support vol_info in storage_v2 responses (#6554) --- .../src/synology/synology-client.ts | 5 ++ .../src/synology/synology-types.ts | 1 + .../test/synology-integration.spec.ts | 54 +++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/packages/integrations/src/synology/synology-client.ts b/packages/integrations/src/synology/synology-client.ts index cc853e2c9..87450cc2f 100644 --- a/packages/integrations/src/synology/synology-client.ts +++ b/packages/integrations/src/synology/synology-client.ts @@ -85,6 +85,11 @@ export class SynologyClient { if (volumesFromV2.length > 0) { return volumesFromV2; } + + const legacyVolumesFromV2 = this.mapLegacyVolumes(storageV2.vol_info); + if (legacyVolumesFromV2.length > 0) { + return legacyVolumesFromV2; + } } catch (error) { logger.debug("storage_v2 unavailable, falling back to legacy storage info", { integrationId: this.integrationId, diff --git a/packages/integrations/src/synology/synology-types.ts b/packages/integrations/src/synology/synology-types.ts index c1f73b52e..0d3a13163 100644 --- a/packages/integrations/src/synology/synology-types.ts +++ b/packages/integrations/src/synology/synology-types.ts @@ -78,6 +78,7 @@ export const synologyStorageV2VolumeSchema = z export const synologyStorageV2DataSchema = z.object({ volumes: z.array(synologyStorageV2VolumeSchema).optional(), + vol_info: z.array(synologyVolumeInfoSchema).optional(), }); export const synologyUtilizationCpuSchema = z.object({ diff --git a/packages/integrations/src/synology/test/synology-integration.spec.ts b/packages/integrations/src/synology/test/synology-integration.spec.ts index e8a13f015..60e334fe4 100644 --- a/packages/integrations/src/synology/test/synology-integration.spec.ts +++ b/packages/integrations/src/synology/test/synology-integration.spec.ts @@ -426,6 +426,60 @@ describe("SynologyIntegration.getSystemInfoAsync", () => { expect(result.fileSystem[0]?.deviceName).toBe("volume_1"); }); + test("uses vol_info from storage_v2 without falling back to legacy storage", async () => { + mockSuccessfulFlow({ + storageV2: { + success: true, + data: { + vol_info: [ + { + name: "volume_1", + used_size: "4000000000", + total_size: "10000000000", + status: "normal", + }, + { + name: "volume_2", + used_size: "5000000000", + total_size: "20000000000", + status: "attention", + }, + ], + }, + }, + }); + + const integration = createIntegration(); + const result = await integration.getSystemInfoAsync(); + + expect(result.fileSystem).toEqual([ + { + deviceName: "volume_1", + used: "4000000000", + available: "6000000000", + percentage: 40, + }, + { + deviceName: "volume_2", + used: "5000000000", + available: "15000000000", + percentage: 25, + }, + ]); + + const legacyStorageRequests = mockFetch.mock.calls.filter(([url]) => { + const urlString = toUrlString(url); + + return ( + getApiFromUrl(urlString) === "SYNO.Core.System" && + getMethodFromUrl(urlString) === "info" && + getTypeFromUrl(urlString) === "storage" + ); + }); + + expect(legacyStorageRequests).toHaveLength(0); + }); + test("continues when optional tier-2 calls fail", async () => { mockSuccessfulFlow({ skipOptional: true }); const integration = createIntegration(); From ff2cdecc863349cfe6d62640aa6d751ef09084a9 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 11 Aug 2026 14:49:52 +0200 Subject: [PATCH 09/11] feat: rewrite image picker (#6556) * feat: rewrite image picker * style: auto-fix lint and format * fix: address image picker review * fix: preserve unicode icon searches * test: stabilize custom widget login * fix: suggest image searches only on create --- apps/docs/docs/advanced/icons/index.mdx | 27 +- apps/docs/docs/management/apps/index.mdx | 2 +- .../custom-widgets/_custom-widget-form.tsx | 6 +- .../[locale]/manage/search-engines/_form.tsx | 5 +- e2e/custom-widgets-custom-jsx.spec.ts | 5 +- packages/api/src/router/icons.ts | 65 ++- packages/api/src/router/test/icons.spec.ts | 44 ++ packages/common/src/string.ts | 31 + packages/common/src/test/string.spec.ts | 26 +- .../src/icon-picker/icon-picker.module.css | 90 ++- .../src/icon-picker/icon-picker.spec.ts | 267 +++++++-- .../src/icon-picker/icon-picker.tsx | 534 +++++++++++++----- .../src/icon-picker/icon-picker.utils.spec.ts | 50 ++ .../src/icon-picker/icon-picker.utils.ts | 67 +++ packages/forms-collection/src/index.tsx | 1 - .../forms-collection/src/new-app/_form.tsx | 31 +- .../src/new-app/icon-matcher.ts | 30 - packages/translation/src/lang/en.json | 14 +- ...widget-multiReleasesRepositories-input.tsx | 41 +- 19 files changed, 1005 insertions(+), 331 deletions(-) create mode 100644 packages/api/src/router/test/icons.spec.ts create mode 100644 packages/forms-collection/src/icon-picker/icon-picker.utils.spec.ts create mode 100644 packages/forms-collection/src/icon-picker/icon-picker.utils.ts delete mode 100644 packages/forms-collection/src/new-app/icon-matcher.ts diff --git a/apps/docs/docs/advanced/icons/index.mdx b/apps/docs/docs/advanced/icons/index.mdx index b73d49e02..0931c6e43 100644 --- a/apps/docs/docs/advanced/icons/index.mdx +++ b/apps/docs/docs/advanced/icons/index.mdx @@ -9,17 +9,19 @@ tags: --- ## Icon picker + Icons in Homarr are automatically requested from multiple sources: - - [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons - - [selfh.st icons](https://github.com/selfhst/icons) - - [Simple icons](https://github.com/simple-icons/simple-icons) - - [Tabler icons](https://tabler-icons.io/) - - [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme) - - [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets) - - **Local icons**: automatically fetched from the [medias feature](/docs/management/media/) + +- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons +- [selfh.st icons](https://github.com/selfhst/icons) +- [Simple icons](https://github.com/simple-icons/simple-icons) +- [Tabler icons](https://tabler-icons.io/) +- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme) +- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets) +- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/) Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips. -The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term. +The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats. ### Finding Missing Icons @@ -29,6 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right. +You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image. + ### Using external icons + If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon. -Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it. +Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it. + +### Uploading a local image + +Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches. diff --git a/apps/docs/docs/management/apps/index.mdx b/apps/docs/docs/management/apps/index.mdx index 7c84df588..efd42277d 100644 --- a/apps/docs/docs/management/apps/index.mdx +++ b/apps/docs/docs/management/apps/index.mdx @@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely. ### Icon URL This is the URL to the icon that will be displayed on the app widget. -You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories. +You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories. ![Icon URL](./img/app-icon-url.png) diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx index c2820757f..446658f80 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx @@ -697,7 +697,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi