Compare commits

..
Author SHA1 Message Date
Thomas 99e013c1fb Merge branch 'dev' into feat/widget-overlay 2026-06-10 16:54:25 +02:00
homarr-renovate[bot] b3cdab579b chore(deps): update dependency isomorphic-dompurify to ^3.16.0 (#5913)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-10 14:31:02 +00:00
homarr-renovate[bot] 44a0c6ce54 chore(deps): update dependency semver-parser to ^4.2.1 (#5868)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 12:47:00 +00:00
homarr-renovate[bot] 7f05e0325d chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.61.1 (#5906)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 09:53:16 +00:00
homarr-renovate[bot] f71b145403 chore(deps): update tiptap monorepo to v3.25.0 (#5898)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 05:17:18 +00:00
homarr-renovate[bot] 607fd498f4 chore(deps): update tanstack-query monorepo to ^5.101.0 (#5894)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 00:17:40 +00:00
homarr-renovate[bot] 3f2fb54cdf chore(deps): update mantine monorepo to ^9.3.0 (#5890)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 22:39:48 +00:00
Thomasandhomarr-releases[bot] 69412f2d10 chore(release): 1.64.0 [skip ci] (#5902)
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-08 22:25:40 +02:00
Thomas Camlong 786ad347a0 style(board): round overlay on all sides and hide on mobile
Apply full border-radius to hover overlay panel instead of bottom-only,
and add media query to hide the overlay on screens <= 768px since hover
is not available on touch devices.
2026-06-08 22:23:41 +02:00
Afonso Jorge Ramos f6d5f97933 fix(integrations): support truenas json-rpc api, certificate trust, and api key auth (#5861)
* chore(integrations): add ws dependency for the truenas client

* feat(definitions): allow api key authentication for truenas

* fix(integrations): connect to truenas over trusted-certificate websocket with protocol autodetection

* test(integrations): cover truenas protocol detection and authentication

* refactor(integrations): avoid shorthand index variable in truenas mapping

* feat(integrations): add a websocket url helper to the integration base

* refactor(integrations): extract truenas connection handling into a client
2026-06-08 22:20:42 +02:00
homarr-renovate[bot] a447a9f615 chore(deps): update dependency axios@>=1.0.0 <1.8.2 to >=1.17.0 (#5897)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 14:15:50 +00:00
homarr-renovate[bot] 87d3f6a0a1 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.4 (#5896)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 09:30:40 +00:00
Thomas Camlong 93ce4e4a1b perf(board): lift integration query to SectionContent
Avoid N identical useQuery hooks by fetching integrations once
at the parent and passing them as a prop to each WidgetHoverOverlay.
2026-06-07 10:40:35 +02:00
ajnart 4cf41f2c4c style: auto-fix lint and format 2026-06-07 00:06:03 +00:00
Thomas Camlong ab3c8b4d1f feat(board): mount widget hover overlay on dashboard grid items 2026-06-07 02:05:01 +02:00
Thomas Camlong 78ddc81b15 feat(board): add widget hover overlay component 2026-06-07 02:04:57 +02:00
Thomas Camlong ad98f2d262 feat(widgets): export widget option types for hover metadata 2026-06-07 02:04:52 +02:00
homarr-renovate[bot] f2705eeb57 chore(deps): update dependency oxfmt to ^0.53.0 (#5883)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 23:12:23 +00:00
homarr-renovate[bot] ac37d2c73f chore(deps): update react monorepo (#5886)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 21:13:36 +00:00
homarr-renovate[bot] e00ea624cd chore(deps): update nextjs monorepo to v16.2.7 (#5885)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 19:32:30 +00:00
homarr-renovate[bot] 20b62c42e3 chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.61.0 (#5878)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 13:54:21 +00:00
homarr-renovate[bot] 59971991c7 chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.16 (#5880)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 11:36:28 +00:00
homarr-renovate[bot] a629681ae5 chore(deps): update vitest monorepo to ^4.1.8 (#5879)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 09:34:48 +00:00
homarr-renovate[bot] 572d5fb5ec chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.15 (#5877)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 07:01:54 +00:00
Thomasandhomarr-releases[bot] 91a1858743 chore: merge main into dev
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-06 07:47:59 +02:00
homarr-renovate[bot] f00d3627c9 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.3 (#5873)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-05 18:04:10 +00:00
homarr-releases[bot] 7760e01ccb chore: update bug report template (#5821)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-05 17:47:57 +00:00
Releases Homarr 8a1ead3674 chore(release): 1.64.0 [skip ci] 2026-06-05 17:35:36 +00:00
homarr-releases[bot] 9c05f44916 chore(release): automatic release v1.64.0 2026-06-05 17:34:47 +00:00
Thomas Camlong efd37dca93 fix(db): log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding
Warn on startup seed when mock integration env flag is set, matching demo mode visibility.
2026-06-05 18:43:33 +02:00
Thomas Camlong 796c679140 chore: merge main into dev 2026-06-05 18:42:31 +02:00
homarr-update-contributors[bot] 76fd4c5fb1 chore: update contributors (#5871) 2026-06-05 18:08:47 +02:00
Thomas Camlong 68f24e34d5 fix: disable onboarding tours when demo mode is enabled
Return completed tour status in demo mode so board and manage tours do not start for demo users.
2026-06-05 18:02:08 +02:00
homarr-update-contributors[bot] 22d7ebab50 chore: update integrations list readme (#5872)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-06-05 15:45:46 +00:00
Thomas Camlong 4f5f3e14a1 fix: sync docs sitemap and remove stale coolify ts-expect-error 2026-06-05 17:39:03 +02:00
Thomasandcopilot-swe-agent[bot] dbf021e04a feat(docker): add container logs modal and stats columns (#4807)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-05 16:57:26 +02:00
CopilotandThomas cc24f070db fix: redirect to login after credentials logout instead of crashing on current page (#5819)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-06-05 16:56:24 +02:00
Thomas 0b663a7fc6 feat(onboarding): guided management and board tours (#5756) 2026-06-05 16:54:50 +02:00
Afonso Jorge Ramos 0dc2558faf feat: add PeaNUT integration and UPS monitoring widget (#5862) 2026-06-05 16:51:09 +02:00
Thomas dd7a4908a3 feat(onboarding): enhance integration setup with Docker discovery and URL templating (#5607) 2026-06-05 16:50:35 +02:00
Thomas 0a6467979a feat: add comprehensive demo mode with mock integrations (#5870) 2026-06-05 16:36:45 +02:00
ThomasandMeier Lukas 25d964a197 refactor(spotlight): registry-driven CMDK preferences (#5817)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-06-05 16:26:48 +02:00
Thomas 3d3e3059a9 feat: media request search as first-class spotlight mode (#5823) 2026-06-05 16:09:14 +02:00
homarr-crowdin[bot]andMeier Lukas 9ae8cb2768 chore(lang): updated translations from crowdin (#5863)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-06-05 13:48:42 +02:00
Thomas 99346f288b feat(widgets): add in-place app editing in widget edit modal (#5809) 2026-06-05 12:31:50 +02:00
Releases Homarr ff1e4c1381 chore(release): 1.63.0 [skip ci] 2026-05-29 21:07:15 +00:00
homarr-releases[bot] 21bb20ce0c chore(release): automatic release v1.63.0 2026-05-29 21:06:26 +00:00
Releases Homarr 92b75cb924 chore(release): 1.62.0 [skip ci] 2026-05-22 20:48:36 +00:00
Meier Lukas 1ea008b71c chore(release): automatic release v1.62.0 2026-05-22 22:47:05 +02:00
162 changed files with 4512 additions and 2928 deletions

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.64.0
- 1.62.0
- 1.61.0
- 1.60.1-beta.1
-3
View File
@@ -1,3 +0,0 @@
[tools]
node = "24.16.0"
pnpm = "10.33.4"
+64
View File
@@ -1,3 +1,67 @@
## [1.64.0](https://github.com/homarr-labs/homarr/compare/v1.63.0...v1.64.0) (2026-06-05)
### Features
* add comprehensive demo mode with mock integrations ([#5870](https://github.com/homarr-labs/homarr/issues/5870)) ([0a64679](https://github.com/homarr-labs/homarr/commit/0a6467979a5e3b3e3eae6252c31fca1d9af54dc1))
* add PeaNUT integration and UPS monitoring widget ([#5862](https://github.com/homarr-labs/homarr/issues/5862)) ([0dc2558](https://github.com/homarr-labs/homarr/commit/0dc2558fafedcbba246c2e968d00cc541ab9f84e))
* **docker:** add container logs modal and stats columns ([#4807](https://github.com/homarr-labs/homarr/issues/4807)) ([dbf021e](https://github.com/homarr-labs/homarr/commit/dbf021e04a785b44ad0e2ce45634de371d76de6d))
* media request search as first-class spotlight mode ([#5823](https://github.com/homarr-labs/homarr/issues/5823)) ([3d3e305](https://github.com/homarr-labs/homarr/commit/3d3e3059a98b36b6e64e3aad90f715e334baff3e))
* **onboarding:** enhance integration setup with Docker discovery and URL templating ([#5607](https://github.com/homarr-labs/homarr/issues/5607)) ([dd7a490](https://github.com/homarr-labs/homarr/commit/dd7a4908a36a71c1d0d1e41524eb8fd9bf53ca4d))
* **onboarding:** guided management and board tours ([#5756](https://github.com/homarr-labs/homarr/issues/5756)) ([0b663a7](https://github.com/homarr-labs/homarr/commit/0b663a7fc6c017b9fbe78c019f06d46ee95f165f))
* **widgets:** add in-place app editing in widget edit modal ([#5809](https://github.com/homarr-labs/homarr/issues/5809)) ([99346f2](https://github.com/homarr-labs/homarr/commit/99346f288bcfc701c41fecaab6f7bc0438d80563))
### Bug Fixes
* add bookmark widget option to toggle card borders ([#5854](https://github.com/homarr-labs/homarr/issues/5854)) ([41fe592](https://github.com/homarr-labs/homarr/commit/41fe5928f19207d02b04e64c2caac49d896f72ce))
* **auth:** relax password complexity requirements to suggestions-only ([#5764](https://github.com/homarr-labs/homarr/issues/5764)) ([fd10788](https://github.com/homarr-labs/homarr/commit/fd107888385557ededcf51576c6320a1960690f2))
* **db:** log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding ([efd37dc](https://github.com/homarr-labs/homarr/commit/efd37dca93be7f5f362331e9b5c2a95a87e24379))
* disable onboarding tours when demo mode is enabled ([68f24e3](https://github.com/homarr-labs/homarr/commit/68f24e34d580e3a155c37ca37c3ffd61f296bd0c))
* **docker:** restore container selection toolbar broken by Mantine v9 ([#5855](https://github.com/homarr-labs/homarr/issues/5855)) ([fe4598f](https://github.com/homarr-labs/homarr/commit/fe4598f7221a2861b8c5ae0cfd267e8c4fbf48df))
* **immich:** respect app color scheme in server-stats widget ([#5859](https://github.com/homarr-labs/homarr/issues/5859)) ([2c6b7a8](https://github.com/homarr-labs/homarr/commit/2c6b7a818e9d32b414b024d711a3b64533e61cb9))
* redirect to login after credentials logout instead of crashing on current page ([#5819](https://github.com/homarr-labs/homarr/issues/5819)) ([cc24f07](https://github.com/homarr-labs/homarr/commit/cc24f070db26337a4a2d87d68c7f142ed1118584))
* sync docs sitemap and remove stale coolify ts-expect-error ([4f5f3e1](https://github.com/homarr-labs/homarr/commit/4f5f3e14a129a94295d4b8ffb7d3702fdb4f6a74))
* **ui:** restore pre-v9 light variant colors ([#5856](https://github.com/homarr-labs/homarr/issues/5856)) ([a755692](https://github.com/homarr-labs/homarr/commit/a755692d81b80b5ff83051a3b7261c01ac10321b))
## [1.63.0](https://github.com/homarr-labs/homarr/compare/v1.62.0...v1.63.0) (2026-05-29)
### Features
* **auth:** support custom token endpoint auth methods ([#5806](https://github.com/homarr-labs/homarr/issues/5806)) ([be61602](https://github.com/homarr-labs/homarr/commit/be61602576a8bec4bb7e4928d36e0854e926264d))
* auto-open widget settings on add with all integrations pre-selected ([#5767](https://github.com/homarr-labs/homarr/issues/5767)) ([f10bb3a](https://github.com/homarr-labs/homarr/commit/f10bb3ae23fefc9779332733706aa567cc40ba48))
* **board:** persist category collapse state in localStorage for guests ([#5714](https://github.com/homarr-labs/homarr/issues/5714)) ([66212b4](https://github.com/homarr-labs/homarr/commit/66212b4e475738e61d2ea78f3a7e72d7a610bf12)), closes [#3355](https://github.com/homarr-labs/homarr/issues/3355)
* **manage:** unified management page layout ([#5758](https://github.com/homarr-labs/homarr/issues/5758)) ([c252b4f](https://github.com/homarr-labs/homarr/commit/c252b4f9accc4523f222354c73f687714640701f))
* **users:** rework general settings ([#4915](https://github.com/homarr-labs/homarr/issues/4915)) ([2991777](https://github.com/homarr-labs/homarr/commit/2991777b0a0ae72522d0769a86a91250d618dbe6))
### Bug Fixes
* matine ui fixes for concistency ([#5733](https://github.com/homarr-labs/homarr/issues/5733)) ([4454e3f](https://github.com/homarr-labs/homarr/commit/4454e3fb7afa624ee2f1eaec015f2725e664ac46))
* parse Speedtest Tracker created_at as local time ([#5751](https://github.com/homarr-labs/homarr/issues/5751)) ([b19549b](https://github.com/homarr-labs/homarr/commit/b19549b601636bcbc2fadaa270a77a9fdb48b77a))
* prevent GitHub releases check from blocking app on API failure ([#5807](https://github.com/homarr-labs/homarr/issues/5807)) ([6cc2ac5](https://github.com/homarr-labs/homarr/commit/6cc2ac56a920563c47aaaab1b33b19a9b6d5ecee))
* **ui:** use imported theme instead of undefined createTheme ([4a67bf0](https://github.com/homarr-labs/homarr/commit/4a67bf0ef30ff8d10b06d2d3719ccab792395669))
### Performance Improvements
* **boards:** parallelize board page data fetching ([#5736](https://github.com/homarr-labs/homarr/issues/5736)) ([8f03a38](https://github.com/homarr-labs/homarr/commit/8f03a38de5b9b385da7ffcc9a963be00710acb5e))
## [1.62.0](https://github.com/homarr-labs/homarr/compare/v1.61.1...v1.62.0) (2026-05-22)
### Features
* **seed:** add demo mode with pre-seeded user ([#5739](https://github.com/homarr-labs/homarr/issues/5739)) ([dade477](https://github.com/homarr-labs/homarr/commit/dade477b3226765eb614e66588ebee84c45cb688))
### Bug Fixes
* **deps:** update dependency better-sqlite3 to ^12.10.0 ([#5727](https://github.com/homarr-labs/homarr/issues/5727)) ([044ad9e](https://github.com/homarr-labs/homarr/commit/044ad9edf9c1dc9b50b9591dea0b5f0cce4be01a))
* **docker:** improve icon matching ([#5693](https://github.com/homarr-labs/homarr/issues/5693)) ([7cd30b6](https://github.com/homarr-labs/homarr/commit/7cd30b640cbf47e4678abda18f83c0924f039b1b))
* exclude .env files from Docker build context ([#5738](https://github.com/homarr-labs/homarr/issues/5738)) ([fa73763](https://github.com/homarr-labs/homarr/commit/fa737638a812372d904207fe7af5bd7c6f53d159))
* **tracearr:** support absolute image urls ([#5654](https://github.com/homarr-labs/homarr/issues/5654)) ([7a12721](https://github.com/homarr-labs/homarr/commit/7a1272146c597c1f9df89d74b8389f70e7cdd61a))
### Performance Improvements
* (next branch) reduce idle memory on startup ([#5637](https://github.com/homarr-labs/homarr/issues/5637)) ([5ee84f8](https://github.com/homarr-labs/homarr/commit/5ee84f8cf464c308729281cb4db572950ba85a82))
* **boards:** remove loading overlay and show board immediately ([#5735](https://github.com/homarr-labs/homarr/issues/5735)) ([df4b57b](https://github.com/homarr-labs/homarr/commit/df4b57bd414d4402522f9210b1470d0561c37274))
* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e))
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
### Bug Fixes
-1
View File
@@ -57,7 +57,6 @@
"@mantine/modals": "catalog:",
"@mantine/tiptap": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/query-persist-client-core": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:",
@@ -1,49 +0,0 @@
import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core";
import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core";
import { parse, stringify } from "superjson";
import { fetchApi } from "@homarr/api/client";
import {
getActiveQueryCacheBoardId,
isPersistableWidgetQueryKey,
queryCacheStoragePrefix,
} from "@homarr/api/query-cache";
const queryCacheStorage = {
async getItem(_key) {
return null;
},
async setItem(key, value) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.setItem.mutate({ boardId, key, value });
} catch {
// Query persistence is best-effort; failed writes should not affect widget rendering.
}
},
async removeItem(key) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.removeItem.mutate({ boardId, key });
} catch {
// Query persistence is best-effort; failed removals should not affect widget rendering.
}
},
} satisfies AsyncStorage<string>;
export const createWidgetQueryPersister = () =>
experimental_createQueryPersister<string>({
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
maxAge: Number.POSITIVE_INFINITY,
prefix: queryCacheStoragePrefix,
refetchOnRestore: true,
serialize: (query) => stringify(query),
deserialize: (value) => parse<PersistedQuery>(value),
filters: {
predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
},
});
@@ -17,13 +17,13 @@ import {
import superjson from "superjson";
import type { SuperJSONResult } from "superjson";
import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { createWidgetQueryPersister } from "./query-cache-persister";
import { showWarningNotification } from "@homarr/notifications";
const getWebSocketProtocol = () => {
// window is not defined on server side
@@ -52,14 +52,26 @@ const wsClient = createWSClient({
});
export function TRPCReactProvider(props: PropsWithChildren) {
const [queryPersister] = useState(() => createWidgetQueryPersister());
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
persister: queryPersister.persisterFn,
staleTime: queryCacheDefaultStaleTimeMs,
staleTime: 5 * 1000,
},
mutations: {
onError(error) {
if (
error instanceof TRPCClientError &&
error.data?.code === "FORBIDDEN" &&
error.message === "Mutations are disabled in demo mode"
) {
showWarningNotification({
title: "Demo mode",
message: "This action is disabled in demo mode.",
});
}
},
},
},
}),
@@ -1,11 +1,14 @@
import { redirect } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
import { IconLogin } from "@tabler/icons-react";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
@@ -37,6 +40,13 @@ export default async function Login(props: LoginProps) {
{t("subtitle")}
</Text>
</Stack>
{appEnv.DEMO_MODE && (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
<Text size="sm" fw={500}>
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
</Text>
</Alert>
)}
<Card w={64 * 6} maw="90vw">
<LoginForm
providers={env.AUTH_PROVIDERS}
@@ -1,14 +1,10 @@
import { cache } from "react";
import type { Metadata } from "next";
import { Suspense } from "react";
import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss";
import type { DehydratedState } from "@tanstack/react-query";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { parse } from "superjson";
import { getQueryClient } from "@homarr/api/server";
import { IntegrationProvider } from "@homarr/auth/client";
@@ -18,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
import { getAllQueryCacheAsync } from "@homarr/redis";
import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -39,13 +34,10 @@ interface Props<TParams extends Params> {
export const createBoardContentPage = <TParams extends Record<string, unknown>>({
getInitialBoardAsync: getInitialBoard,
}: Props<TParams>) => {
const getCachedBoard = cache((_paramsKey: string, params: TParams) => getInitialBoard(params));
const getBoardForParams = (params: TParams) => getCachedBoard(JSON.stringify(params), params);
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getBoardForParams,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
// eslint-disable-next-line no-restricted-syntax
@@ -53,7 +45,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
const resolvedParams = await params;
const queryClient = getQueryClient();
const [board, session] = await Promise.all([getBoardForParams(resolvedParams), auth()]);
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
const itemsMap = board.items.reduce((acc, item) => {
const existing = acc.get(item.kind);
@@ -80,14 +72,9 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
),
]);
const userId = session?.user.id ?? "anonymous";
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
@@ -95,7 +82,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
try {
const board = await getBoardForParams(await params);
const board = await getInitialBoard(await params);
const t = await getI18n();
return {
@@ -111,6 +98,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
};
} catch (error) {
// Ignore not found errors and return empty metadata
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
return {};
}
@@ -120,37 +108,3 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
};
};
interface PersistedEntry {
state: { data: unknown; dataUpdatedAt: number; status: string };
queryKey: unknown[];
queryHash: string;
}
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
try {
const entries = await getAllQueryCacheAsync(userId, boardId);
const queries: DehydratedState["queries"] = [];
for (const serialized of Object.values(entries)) {
try {
const persisted = parse<PersistedEntry>(serialized);
if (persisted.state.status !== "success") continue;
queries.push({
state: persisted.state as DehydratedState["queries"][number]["state"],
queryKey: persisted.queryKey,
queryHash: persisted.queryHash,
});
} catch {
// skip malformed entries
}
}
if (queries.length === 0) return null;
return <HydrationBoundary state={{ mutations: [], queries }} />;
} catch {
return null;
}
}
@@ -45,8 +45,9 @@ export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
if (!hasChangeAccess) {
if (!hasChangeAccess || isLoading) {
return <SelectBoardsMenu />;
}
@@ -54,13 +55,15 @@ export const BoardContentHeaderActions = () => {
<>
{isEditMode && <AddMenu />}
<EditModeMenu />
<EditModeMenu demoMode={demoMode ?? false} />
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</OnboardingTour.Target>
{!demoMode && (
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</OnboardingTour.Target>
)}
<SelectBoardsMenu />
</>
@@ -155,7 +158,7 @@ const AddMenu = () => {
);
};
const EditModeMenu = () => {
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard();
const utils = clientApi.useUtils();
@@ -178,10 +181,18 @@ const EditModeMenu = () => {
},
});
const discardDemoChanges = useCallback(() => {
void utils.board.getBoardByName.invalidate({ name: board.name });
close();
}, [utils, board.name, close]);
const toggle = useCallback(() => {
if (isEditMode) return saveBoard(board);
if (isEditMode) {
if (demoMode) return discardDemoChanges();
return saveBoard(board);
}
open();
}, [board, isEditMode, saveBoard, open]);
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode);
@@ -78,10 +78,7 @@ export const InitIntegrations = () => {
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
const totalDetected = dockerIntegrations.length + dockerApps.length;
const detectedKinds = useMemo(
() => new Set(dockerIntegrations.map((i) => i.kind)),
[dockerIntegrations],
);
const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]);
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
const [appsExpanded, setAppsExpanded] = useState(false);
+1
View File
@@ -26,6 +26,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
@@ -1,85 +0,0 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userChangeHomeBoardsSchema } from "@homarr/validation/user";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardSelect } from "~/components/board/board-select";
interface ChangeHomeBoardFormProps {
user: RouterOutputs["user"]["getById"];
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
}
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
homeBoardId: variables.homeBoardId,
mobileHomeBoardId: variables.mobileHomeBoardId,
});
showSuccessNotification({
message: t("user.action.changeHomeBoard.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeHomeBoard.notification.error.message"),
});
},
});
const form = useZodForm(userChangeHomeBoardsSchema, {
initialValues: {
homeBoardId: user.homeBoardId,
mobileHomeBoardId: user.mobileHomeBoardId,
},
});
const handleSubmit = (values: FormType) => {
mutate({
userId: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<BoardSelect
label={t("management.page.user.setting.general.item.board.type.general")}
clearable
boards={boardsData}
w="100%"
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={t("management.page.user.setting.general.item.board.type.mobile")}
clearable
boards={boardsData}
w="100%"
{...form.getInputProps("mobileHomeBoardId")}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userChangeHomeBoardsSchema>;
@@ -1,84 +0,0 @@
"use client";
import { Button, Group, Select, Stack, Switch } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userChangeSearchPreferencesSchema } from "@homarr/validation/user";
interface ChangeSearchPreferencesFormProps {
user: RouterOutputs["user"]["getById"];
searchEnginesData: { value: string; label: string }[];
}
export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeSearchPreferencesFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeSearchPreferences.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
defaultSearchEngineId: variables.defaultSearchEngineId,
openInNewTab: variables.openInNewTab,
ddgBangsEnabled: variables.ddgBangsEnabled,
});
showSuccessNotification({
message: t("user.action.changeSearchPreferences.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeSearchPreferences.notification.error.message"),
});
},
});
const form = useZodForm(userChangeSearchPreferencesSchema, {
initialValues: {
defaultSearchEngineId: user.defaultSearchEngineId,
openInNewTab: user.openSearchInNewTab,
ddgBangsEnabled: user.ddgBangs,
},
});
const handleSubmit = (values: FormType) => {
mutate({
userId: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label={t("user.field.defaultSearchEngine.label")}
w="100%"
data={searchEnginesData}
{...form.getInputProps("defaultSearchEngineId")}
/>
<Switch
label={t("user.field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Switch
label={t("user.field.ddgBangs.label")}
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userChangeSearchPreferencesSchema>;
@@ -1,83 +0,0 @@
"use client";
import { Button, Group, Radio, Stack } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userFirstDayOfWeekSchema } from "@homarr/validation/user";
dayjs.extend(localeData);
interface FirstDayOfWeekProps {
user: RouterOutputs["user"]["getById"];
}
const weekDays = dayjs.weekdays(false);
export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeFirstDayOfWeek.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
firstDayOfWeek: variables.firstDayOfWeek as DayOfWeek,
});
showSuccessNotification({
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeFirstDayOfWeek.notification.error.message"),
});
},
});
const form = useZodForm(userFirstDayOfWeekSchema, {
initialValues: {
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
},
});
const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};
const inputProps = form.getInputProps("firstDayOfWeek");
const onChange = inputProps.onChange as (value: number) => void;
const value = (inputProps.value as number).toString();
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Radio.Group {...inputProps} value={value} onChange={(nextValue: string) => onChange(parseInt(nextValue, 10))}>
<Group mt="xs">
<Radio value="1" label={weekDays[1]} />
<Radio value="6" label={weekDays[6]} />
<Radio value="0" label={weekDays[0]} />
</Group>
</Radio.Group>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userFirstDayOfWeekSchema>;
@@ -1,69 +0,0 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userPingIconsEnabledSchema } from "@homarr/validation/user";
interface PingIconsEnabledProps {
user: RouterOutputs["user"]["getById"];
}
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
pingIconsEnabled: variables.pingIconsEnabled,
});
showSuccessNotification({
message: t("user.action.changePingIconsEnabled.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changePingIconsEnabled.notification.error.message"),
});
},
});
const form = useZodForm(userPingIconsEnabledSchema, {
initialValues: {
pingIconsEnabled: user.pingIconsEnabled,
},
});
const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Switch
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
label={t("user.field.pingIconsEnabled.label")}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userPingIconsEnabledSchema>;
@@ -1,94 +0,0 @@
"use client";
import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userEditProfileSchema } from "@homarr/validation/user";
interface UserProfileFormProps {
user: RouterOutputs["user"]["getById"];
}
export const UserProfileForm = ({ user }: UserProfileFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
async onSettled() {
await revalidatePathActionAsync("/manage/users");
},
onSuccess(_, variables) {
// Reset form initial values to reset dirty state
form.setInitialValues({
name: variables.name,
email: variables.email ?? "",
});
showSuccessNotification({
title: t("common.notification.update.success"),
message: t("user.action.editProfile.notification.success.message"),
});
},
onError(error) {
const message =
error.data?.code === "CONFLICT"
? t("user.error.usernameTaken")
: t("user.action.editProfile.notification.error.message");
showErrorNotification({
title: t("common.notification.update.error"),
message,
});
},
});
const form = useZodForm(userEditProfileSchema.omit({ id: true }), {
initialValues: {
name: user.name ?? "",
email: user.email ?? "",
},
});
// Only credentials users can edit their profile
const isProviderCredentials = user.provider === "credentials";
const handleSubmit = useCallback(
(values: FormType) => {
if (!isProviderCredentials) return;
mutate({
...values,
id: user.id,
});
},
[isProviderCredentials, mutate, user.id],
);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
disabled={!isProviderCredentials}
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
disabled={!isProviderCredentials}
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
{isProviderCredentials && (
<Group justify="end">
<Button type="submit" disabled={!form.isDirty()} loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
)}
</Stack>
</form>
);
};
type FormType = Omit<RouterInputs["user"]["editProfile"], "id">;
@@ -6,18 +6,13 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
import { ResetTours } from "./_components/_reset-tours";
interface Props {
@@ -64,7 +59,7 @@ export default async function EditUserPage(props: Props) {
const boards = await api.board.getAllBoards();
const searchEngines = await api.searchEngine.getSelectable();
const isSelf = session?.user.id === user.id;
const isCredentialsUser = user.provider === "credentials";
return (
@@ -74,51 +69,25 @@ export default async function EditUserPage(props: Props) {
{t("management.page.user.fieldsDisabledExternalProvider")}
</Alert>
)}
<Title>{tGeneral("title")}</Title>
<Group gap="xl">
<Box flex={1}>
<UserProfileForm user={user} />
<Group gap="xl" align="flex-start" wrap="wrap">
<Box flex={1} miw={{ base: "100%", md: 540 }}>
<UserGeneralSettingsForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
}))}
searchEnginesData={searchEngines}
showLanguageSelector={isSelf}
/>
</Box>
<Box w={{ base: "100%", lg: 200 }}>
<Box w={{ base: "100%", lg: 260 }}>
<UserProfileAvatarForm user={user} />
</Box>
</Group>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.language")}</Title>
<CurrentLanguageCombobox />
</Stack>
)}
<Stack mb="lg">
<Title order={2}>{tGeneral("item.board.title")}</Title>
<ChangeHomeBoardForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
}))}
/>
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.search")}</Title>
<ChangeSearchPreferencesForm user={user} searchEnginesData={searchEngines} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
<FirstDayOfWeek user={user} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.accessibility")}</Title>
<PingIconsEnabled user={user} />
</Stack>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
import { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access";
@@ -26,7 +26,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
const session = await auth();
const t = await getI18n();
const tUser = await getScopedI18n("management.page.user");
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound).catch(catchTrpcUnauthorized);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
import { canAccessUserEditPage } from "../access";
import { ChangePasswordForm } from "./_components/_change-password-form";
@@ -23,7 +23,8 @@ export default async function UserSecurityPage(props: Props) {
.getById({
userId: params.userId,
})
.catch(catchTrpcNotFound);
.catch(catchTrpcNotFound)
.catch(catchTrpcUnauthorized);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
@@ -0,0 +1,155 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
export type Translator = {
(key: string): string;
};
export interface HoverMetadataItem {
label: string;
value: string;
}
const maxMetadataItems = 3;
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
app: true,
anchorNote: true,
dynamicSelect: true,
sortableItemList: true,
multiReleasesRepositories: true,
umamiEventName: true,
umamiEventNames: true,
umamiWebsite: true,
};
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
type OptionDefinitionWithRules = WidgetOptionDefinition & {
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
options?: readonly { value: unknown; label: unknown }[];
};
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
const match = definition.options?.find((option) => option.value === value);
const label = match?.label;
const fallbackLabel = () => String(value);
const labelResolvers: Record<string, () => string> = {
function: () => (label as (translator: Translator) => string)(t),
string: () => label as string,
missing: fallbackLabel,
};
const labelType = typeof label;
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
return resolveLabel();
};
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
switch: (value, _, t) => {
const switchLabels: Record<string, string> = {
true: t("common.action.show"),
false: t("common.action.hide"),
};
return switchLabels[String(Boolean(value))] ?? null;
},
text: (value) => {
const text = String(value).trim();
return text.length > 0 ? text : null;
},
number: (value) => String(value),
slider: (value) => String(value),
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
multiSelect: (value) => {
const items = value as unknown[];
return items.length > 0 ? String(items.length) : null;
},
multiText: (value) => {
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
return items.length > 0 ? items.join(", ") : null;
},
location: (value) => {
const location = value as { name?: string };
const name = location.name?.trim() ?? "";
return name.length > 0 ? name : null;
},
};
const formatOptionValue = (
type: WidgetOptionType,
value: unknown,
definition: OptionDefinitionWithRules,
t: Translator,
) => {
const formatter = optionTypeFormatters[type];
return formatter?.(value, definition, t) ?? null;
};
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
const labelKey = `widget.${kind}.option.${key}.label`;
const translated = t(labelKey);
return translated === labelKey ? key : translated;
};
export const collectWidgetHoverMetadata = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
integrationKinds: IntegrationKind[],
t: Translator,
): HoverMetadataItem[] => {
const { definition } = widgetImports[item.kind];
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
return objectEntries(optionDefinitions)
.filter(([key, optionDefinition]) => {
const skip = skippedOptionTypes[optionDefinition.type];
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
return !skip && !hidden && !unchanged;
})
.map(([key, optionDefinition]) => {
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
return {
label: getOptionLabel(item.kind, key, t),
value: formattedValue ?? "",
};
})
.filter((entry) => entry.value.length > 0)
.slice(0, maxMetadataItems);
};
interface DisplayNameSource {
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
}
const displayNameSources: DisplayNameSource[] = [
{
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
},
{
matches: (item, options) =>
item.kind === "clock" &&
Boolean(options.customTitleToggle) &&
String(options.customTitle ?? "").trim().length > 0,
resolve: (_, options) => String(options.customTitle).trim(),
},
];
export const resolveWidgetDisplayName = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
t: Translator,
) => {
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
};
@@ -0,0 +1,116 @@
.wrapper {
position: absolute;
left: 10px;
right: 10px;
bottom: calc(100% - 10px);
margin-bottom: 1px;
z-index: 200;
pointer-events: auto;
opacity: 0;
transform: translateY(6px);
transition:
opacity 150ms ease,
transform 150ms ease;
}
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}
.panel {
@mixin dark {
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-dark-4) r g b / 0.9);
}
@mixin light {
--background-color: rgb(from var(--mantine-color-white) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-gray-3) r g b / 0.9);
}
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--mantine-radius-md);
background-color: var(--background-color);
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
backdrop-filter: blur(8px);
overflow: hidden;
}
.nameSection {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-shrink: 0;
}
.widgetIcon {
flex-shrink: 0;
color: var(--mantine-color-dimmed);
}
.name {
font-size: 11px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.integrations {
flex-shrink: 0;
}
.integrationAvatar {
border: none;
}
.metadata {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
margin-left: auto;
}
.metadataItem {
@mixin dark {
--chip-background: rgb(from var(--mantine-color-dark-5) r g b / 0.9);
}
@mixin light {
--chip-background: rgb(from var(--mantine-color-gray-1) r g b / 0.95);
}
display: flex;
align-items: center;
gap: 4px;
max-width: 140px;
padding: 2px 6px;
border-radius: var(--mantine-radius-md);
background-color: var(--chip-background);
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
}
.metadataLabel {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
}
.metadataValue {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.wrapper {
display: none;
}
}
@@ -0,0 +1,86 @@
"use client";
import { Avatar, Group, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getIconUrl } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import type { Translator } from "./widget-hover-metadata";
import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata";
import classes from "./widget-hover-overlay.module.css";
interface WidgetHoverOverlayProps {
item: SectionItem;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
const [isEditMode] = useEditMode();
const settings = useSettings();
const t = useI18n();
const { definition } = widgetImports[item.kind];
const WidgetIcon = definition.icon;
const translate = ((key: string) => t(key as never)) as Translator;
const displayName = resolveWidgetDisplayName(item, settings, translate);
const connectedIntegrations = (integrations ?? []).filter((integration) =>
item.integrationIds.includes(integration.id),
);
const metadataItems = collectWidgetHoverMetadata(
item,
settings,
connectedIntegrations.map((integration) => integration.kind),
translate,
);
const hasIntegrations = connectedIntegrations.length > 0;
const hasMetadata = metadataItems.length > 0;
if (isEditMode) {
return null;
}
return (
<div className={classes.wrapper}>
<div className={classes.panel}>
<div className={classes.nameSection}>
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
<Text className={classes.name} title={displayName}>
{displayName}
</Text>
</div>
{hasIntegrations && (
<Avatar.Group spacing="xs" className={classes.integrations}>
{connectedIntegrations.map((integration) => (
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
<Avatar
src={getIconUrl(integration.kind)}
size={20}
radius="sm"
className={classes.integrationAvatar}
/>
</Tooltip>
))}
</Avatar.Group>
)}
{hasMetadata && (
<Group className={classes.metadata} gap={4}>
{metadataItems.map((metadataItem) => (
<div key={`${metadataItem.label}-${metadataItem.value}`} className={classes.metadataItem}>
<span className={classes.metadataLabel}>{metadataItem.label}</span>
<span className={classes.metadataValue}>{metadataItem.value}</span>
</div>
))}
</Group>
)}
</div>
</div>
);
};
@@ -2,8 +2,12 @@ import { useMemo } from "react";
import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item";
import { useSectionContext } from "./section-context";
@@ -11,6 +15,7 @@ import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext();
const { data: integrations } = clientApi.integration.all.useQuery();
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
@@ -18,7 +23,7 @@ export const SectionContent = () => {
* @see https://github.com/homarr-labs/homarr/pull/1770
*/
const sortedItems = useMemo(() => {
return [...items, ...innerSections].sort((itemA, itemB) => {
return [...items, ...innerSections].toSorted((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) {
return itemA.xOffset - itemB.xOffset;
}
@@ -30,7 +35,7 @@ export const SectionContent = () => {
return (
<>
{sortedItems.map((item) => (
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
))}
</>
);
@@ -39,12 +44,12 @@ export const SectionContent = () => {
interface ItemProps {
item: DynamicSectionItem | SectionItem;
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
const Item = ({ item, innerRef }: ItemProps) => {
const Item = ({ item, innerRef, integrations }: ItemProps) => {
const minWidth = useMinSize(item, "x");
const minHeight = useMinSize(item, "y");
return (
<GridStackItem
key={item.id}
@@ -59,6 +64,7 @@ const Item = ({ item, innerRef }: ItemProps) => {
minWidth={minWidth}
minHeight={minHeight}
>
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
</GridStackItem>
);
@@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
window.location.assign(logoutUrl);
return;
}
router.refresh();
router.push("/auth/login");
},
});
}, [logoutUrl, openModal, router]);
+1
View File
@@ -3,6 +3,7 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
},
experimental__runtimeEnv: process.env,
});
+4
View File
@@ -102,6 +102,10 @@
overflow-y: auto;
}
.grid-stack > .grid-stack-item[data-type="item"] {
overflow: visible;
}
.grid-stack.grid-stack-animate {
transition: none;
}
+20 -10
View File
@@ -19,6 +19,9 @@
<!-- Links -->
<p align="center">
<a href="https://demo.homarr.dev">
<strong>Live Demo 🎮</strong>
</a> •
<a href="https://homarr.dev/docs/category/installation-1/">
<strong>Install 💻</strong>
</a> •
@@ -246,13 +249,20 @@
</a>
</td>
<td align="center">
<a href="null" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg" alt="PeaNUT" width="90" height="90" />
<br/>
<p align="center">PeaNUT</p>
</a>
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
<br/>
<p align="center">Pi-hole</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
<br/>
@@ -293,15 +303,15 @@
<br/>
<p align="center">Radarr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/readarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg" alt="Readarr" width="90" height="90" />
<br/>
<p align="center">Readarr</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
<br/>
@@ -342,15 +352,15 @@
<br/>
<p align="center">Speedtest<br/>Tracker</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
<br/>
<p align="center">Tdarr</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
<br/>
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.61.1",
"version": "1.64.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -78,7 +78,7 @@
"overrides": {
"@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.7",
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
"axios@>=1.0.0 <1.8.2": ">=1.17.0",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
"esbuild@<=0.24.2": ">=0.28.0",
@@ -88,13 +88,13 @@
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.24.6",
"react-is": "^19.2.6",
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
"react-is": "^19.2.7",
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
"sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
"tmp@<=0.2.3": ">=0.2.7",
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
"vite@>=5.0.0 <=5.4.18": ">=8.0.16"
},
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch",
-1
View File
@@ -9,7 +9,6 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./query-cache": "./src/query-cache.ts",
"./server": "./src/server.ts",
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
+3 -1
View File
@@ -1,12 +1,14 @@
import { z } from "zod/v4";
import { createEnv } from "@homarr/core/infrastructure/env";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE,
},
});
+2 -2
View File
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
import { env } from "@homarr/docker/env";
import { publicProcedure } from "../trpc";
import { isDemoMode, publicProcedure } from "../trpc";
export const dockerMiddleware = () => {
return publicProcedure.use(async ({ next }) => {
if (env.ENABLE_DOCKER) {
if (env.ENABLE_DOCKER || isDemoMode) {
return await next();
}
throw new TRPCError({
-34
View File
@@ -1,34 +0,0 @@
import type { QueryKey } from "@tanstack/react-query";
export const queryCacheRetentionMs = 1000 * 60 * 60 * 24;
export const queryCacheDefaultStaleTimeMs = 1000 * 60 * 5;
export const queryCacheMaxValueBytes = 1024 * 1024;
export const queryCacheStoragePrefix = "homarr-widget-query";
let activeQueryCacheBoardId: string | null = null;
export const setActiveQueryCacheBoardId = (boardId: string | null) => {
activeQueryCacheBoardId = boardId;
};
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping"]);
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
if (!Array.isArray(first)) return null;
const parts = first.filter((part): part is string => typeof part === "string");
return parts.length > 0 ? parts : null;
};
export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
const path = getTrpcPathFromQueryKey(queryKey);
if (!path) return false;
if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
return persistableQueryPaths.has(path.join("."));
};
-1
View File
@@ -24,7 +24,6 @@ export const appRouter = createTRPCRouter({
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
queryCache: lazy(() => import("./router/query-cache").then((mod) => mod.queryCacheRouter)),
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
+2 -1
View File
@@ -1,7 +1,7 @@
import z from "zod/v4";
import packageJson from "../../../../package.json";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure
@@ -13,4 +13,5 @@ export const infoRouter = createTRPCRouter({
version: packageJson.version,
};
}),
isDemoMode: publicProcedure.query(() => isDemoMode),
});
-54
View File
@@ -1,54 +0,0 @@
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { createQueryCacheChannel } from "@homarr/redis";
import { queryCacheMaxValueBytes, queryCacheRetentionMs } from "../query-cache";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
const queryCacheKeyInput = z.object({
boardId: z.string().min(1),
key: z.string().min(1).max(4096),
});
const queryCacheSetInput = queryCacheKeyInput.extend({
value: z.string().max(queryCacheMaxValueBytes * 2),
});
const createChannel = (input: z.infer<typeof queryCacheKeyInput>, userId: string) =>
createQueryCacheChannel({
userId,
boardId: input.boardId,
key: input.key,
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
};
export const queryCacheRouter = createTRPCRouter({
getItem: publicProcedure.input(queryCacheKeyInput).query(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
return await channel.getAsync();
}),
setItem: publicProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
return {
stored: await channel.setAsync(input.value),
};
}),
removeItem: publicProcedure.input(queryCacheKeyInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
await channel.removeAsync();
}),
});
+8
View File
@@ -32,6 +32,7 @@ import {
import { convertIntersectionToZodObject } from "../schema-merger";
import {
createTRPCRouter,
isDemoMode,
onboardingProcedure,
permissionRequiredProcedure,
protectedProcedure,
@@ -629,6 +630,13 @@ export const userRouter = createTRPCRouter({
.where(eq(users.id, ctx.session.user.id));
}),
getTourStatus: protectedProcedure.query(async ({ ctx }) => {
if (isDemoMode) {
return {
completedManageTour: true,
completedBoardTour: true,
};
}
const user = await ctx.db.query.users.findFirst({
columns: {
completedManageTour: true,
+1
View File
@@ -31,4 +31,5 @@ export const widgetRouter = createTRPCRouter({
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
});
+51
View File
@@ -0,0 +1,51 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { UpsSummary } from "@homarr/integrations/types";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({
getSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; summaries: UpsSummary[]; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
return innerHandler.subscribe((summaries) => {
emit.next({
integrationId: integration.id,
summaries,
timestamp: new Date(),
});
});
});
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
}),
});
+1 -3
View File
@@ -1,7 +1,5 @@
import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
import { queryCacheDefaultStaleTimeMs } from "./query-cache";
/**
* Creates a headers callback for a given source
* It will set the x-trpc-source header and cookies if needed
@@ -60,7 +58,7 @@ export const makeQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: queryCacheDefaultStaleTimeMs,
staleTime: 5 * 1000,
},
dehydrate: {
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending",
-224
View File
@@ -1,224 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { boards, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import {
isPersistableWidgetQueryKey,
queryCacheDefaultStaleTimeMs,
queryCacheMaxValueBytes,
queryCacheRetentionMs,
} from "@homarr/api/query-cache";
import { queryCacheRouter } from "../router/query-cache";
const redisMock = vi.hoisted(() => {
const values = new Map<string, { value: string; expiresAt: number }>();
const calls: {
userId: string;
boardId: string;
key: string;
ttlMs: number;
maxValueBytes: number;
}[] = [];
return {
values,
calls,
createQueryCacheChannel: vi.fn(
(options: { userId: string; boardId: string; key: string; ttlMs: number; maxValueBytes: number }) => {
calls.push(options);
const storageKey = `${options.userId}:${options.boardId}:${options.key}`;
return {
getAsync: async () => {
const item = values.get(storageKey);
if (!item) return null;
if (Date.now() > item.expiresAt) {
values.delete(storageKey);
return null;
}
return item.value;
},
setAsync: async (value: string) => {
if (Buffer.byteLength(value, "utf8") > options.maxValueBytes) {
return false;
}
values.set(storageKey, { value, expiresAt: Date.now() + options.ttlMs });
return true;
},
removeAsync: async () => {
values.delete(storageKey);
},
};
},
),
};
});
vi.mock("@homarr/redis", () => ({
createQueryCacheChannel: redisMock.createQueryCacheChannel,
}));
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
afterEach(() => {
vi.useRealTimers();
redisMock.values.clear();
redisMock.calls.length = 0;
redisMock.createQueryCacheChannel.mockClear();
});
const createSession = (userId = createId()): Session => ({
user: {
id: userId,
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
});
const insertUserAsync = async (db: Database, id: string) => {
await db.insert(users).values({ id });
};
describe("isPersistableWidgetQueryKey", () => {
test.each([
[[["widget", "calendar", "findAllEvents"], { type: "query" }], true],
[[["app", "byId"], { type: "query" }], true],
[[["app", "byIds"], { type: "query" }], true],
[[["docker", "getContainers"], { type: "query" }], true],
[[["integration", "byIds"], { type: "query" }], true],
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
])("matches persist filter for %j", (queryKey, expected) => {
expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected);
});
});
describe("queryCacheRouter", () => {
test("stores, reads, and removes data for a board viewer", async () => {
const db = createDb();
const session = createSession();
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" })).resolves.toEqual({
stored: true,
});
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
await caller.removeItem({ boardId: "board-1", key: "query-key" });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
});
test("does not touch Redis when board access is denied", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
redisMock.createQueryCacheChannel.mockClear();
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).rejects.toThrow("Board not found");
expect(redisMock.createQueryCacheChannel).not.toHaveBeenCalled();
});
test("scopes anonymous public-board cache entries by anonymous user and board", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
expect(redisMock.calls.at(-1)).toMatchObject({
userId: "anonymous",
boardId: "board-1",
key: "query-key",
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
});
test("scopes signed-in cache entries by user and rejects oversized values", async () => {
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
const value = "x".repeat(queryCacheMaxValueBytes + 1);
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value })).resolves.toEqual({
stored: false,
});
expect(redisMock.calls.at(-1)).toMatchObject({
userId: "user-1",
boardId: "board-1",
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
});
test("expires values through the Redis TTL path", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
await vi.advanceTimersByTimeAsync(queryCacheRetentionMs + 1);
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
});
});
describe("query cache stale handling", () => {
test("uses TanStack Query dataUpdatedAt and staleTime to decide when cached data refetches", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z"));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: queryCacheDefaultStaleTimeMs,
},
},
});
const queryKey = [["widget", "example"], { input: { integrationIds: ["integration-1"] }, type: "query" }];
const queryFn = vi.fn().mockResolvedValue("fresh");
queryClient.setQueryData(queryKey, "cached", {
updatedAt: Date.now() - queryCacheDefaultStaleTimeMs - 1,
});
await expect(
queryClient.fetchQuery({
queryKey,
queryFn,
}),
).resolves.toBe("fresh");
expect(queryFn).toHaveBeenCalledOnce();
});
});
+17 -7
View File
@@ -18,6 +18,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { env } from "./env";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
const logger = createLogger({ module: "trpc" });
@@ -87,6 +88,20 @@ export const createCallerFactory = t.createCallerFactory;
*/
export const createTRPCRouter = t.router;
export const isDemoMode = env.DEMO_MODE;
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
if (env.DEMO_MODE && type === "mutation") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Mutations are disabled in demo mode",
});
}
return next({ ctx });
});
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
/**
* Public (unauthed) procedure
*
@@ -94,19 +109,14 @@ export const createTRPCRouter = t.router;
* tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in
*/
export const publicProcedure = t.procedure;
export const publicProcedure = baseProcedure;
/**
* Reusable middleware that enforces users are logged in before running the
* procedure
*/
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
@@ -121,7 +131,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
export const protectedProcedure = baseProcedure.use(enforceUserIsAuthed);
/**
* Procedure that requires a specific permission
+1 -14
View File
@@ -6,7 +6,6 @@ import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache";
import { updateBoardName } from "./updater";
@@ -29,8 +28,6 @@ export const BoardProvider = ({
},
);
setActiveQueryCacheBoardId(data.id);
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
@@ -45,16 +42,6 @@ export const BoardProvider = ({
};
}, [pathname, utils, initialBoard.name]);
useEffect(() => {
setActiveQueryCacheBoardId(data.id);
return () => {
if (getActiveQueryCacheBoardId() === data.id) {
setActiveQueryCacheBoardId(null);
}
};
}, [data.id]);
return (
<BoardContext.Provider
value={{
@@ -86,7 +73,7 @@ export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (typeof window === "undefined") return board.layouts.at(0)!.id;
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
// Fallback to smallest if none exists with breakpoint smaller than window width
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+229 -6
View File
@@ -1,3 +1,5 @@
import SuperJSON from "superjson";
import { createId, objectKeys } from "@homarr/common";
import {
createDocumentationLink,
@@ -9,6 +11,7 @@ import {
integrationDefs,
integrationKinds,
} from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
@@ -19,13 +22,16 @@ import {
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
import {
apps,
boards,
groupMembers,
groupPermissions,
groups,
integrationItems,
integrations,
itemLayouts,
items,
layouts,
onboarding,
@@ -35,7 +41,16 @@ import {
} from "../schema";
import type { Integration } from "../schema";
const isTruthyEnv = (value: string | undefined) =>
["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
export const seedDataAsync = async (db: Database) => {
if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) {
console.warn(
"UNSAFE_ENABLE_MOCK_INTEGRATION is enabled: mock integration is available in the UI. Disable by setting UNSAFE_ENABLE_MOCK_INTEGRATION=false.",
);
}
await seedEveryoneGroupAsync(db);
await seedOnboardingAsync(db);
await seedServerSettingsAsync(db);
@@ -45,7 +60,7 @@ export const seedDataAsync = async (db: Database) => {
await seedDefaultBoardAsync(db);
await seedBoardWidgetsAsync(db);
if (process.env.DEMO_MODE === "true") {
if (isTruthyEnv(process.env.DEMO_MODE)) {
await seedDemoUserAsync(db);
}
};
@@ -273,6 +288,134 @@ const seedDefaultBoardAsync = async (db: Database) => {
console.log("Created default board 'dashboard' through seed");
};
interface DemoWidget {
kind: WidgetKind;
width: number;
height: number;
needsIntegration: boolean;
options?: Record<string, unknown>;
}
const demoApps = [
{
name: "Sonarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
href: "https://sonarr.tv",
},
{
name: "Radarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
href: "https://radarr.video",
},
{
name: "Plex",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
href: "https://plex.tv",
},
{
name: "Jellyfin",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg",
href: "https://jellyfin.org",
},
{
name: "Proxmox",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg",
href: "https://proxmox.com",
},
{
name: "Pi-hole",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
href: "https://pi-hole.net",
},
{
name: "Grafana",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grafana.svg",
href: "https://grafana.com",
},
{
name: "Portainer",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer.svg",
href: "https://portainer.io",
},
{
name: "Home Assistant",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
href: "https://home-assistant.io",
},
{
name: "Nextcloud",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg",
href: "https://nextcloud.com",
},
{
name: "qBittorrent",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
href: "https://qbittorrent.org",
},
{
name: "Overseerr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
href: "https://overseerr.dev",
},
] as const;
const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
// Row 1: calendar + downloads + clock = 12
{ kind: "calendar", width: 5, height: 3, needsIntegration: true },
{ kind: "downloads", width: 5, height: 3, needsIntegration: true },
{ kind: "clock", width: 2, height: 1, needsIntegration: false },
// Row 2: healthMonitoring + bookmarks = 12
{ kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true },
{
kind: "bookmarks",
width: 6,
height: 3,
needsIntegration: false,
options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
},
// Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
{ kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
{ kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
{ kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
// Row 4: dnsHoleControls + mediaReleases + notifications = 12
{ kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
{ kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
{ kind: "notifications", width: 6, height: 2, needsIntegration: true },
// Row 5: requestList + requestStats + indexerManager = 12
{ kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true },
{ kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true },
{ kind: "indexerManager", width: 4, height: 2, needsIntegration: true },
// Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12
{ kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true },
{ kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true },
{
kind: "rssFeed",
width: 6,
height: 2,
needsIntegration: false,
options: {
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
maximumAmountPosts: 20,
textLinesClamp: 2,
hideDescription: false,
},
},
// Row 7-8: app widgets (2x1 each, 6 per row = 12)
...appIds.map(
(appId): DemoWidget => ({
kind: "app",
width: 2,
height: 1,
needsIntegration: false,
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
}),
),
// Row 9: notebook + dockerContainers + weather = 12
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
];
const seedDemoUserAsync = async (db: Database) => {
const existingUser = await db.query.users.findFirst({
where: eq(users.name, "demo"),
@@ -289,7 +432,7 @@ const seedDemoUserAsync = async (db: Database) => {
id: userId,
name: "demo",
email: "demo@example.com",
password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2",
password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
});
const maxPosition = await getMaxGroupPositionAsync(db);
@@ -314,13 +457,93 @@ const seedDemoUserAsync = async (db: Database) => {
previousStep: "settings",
});
const board = await db.query.boards.findFirst();
if (board) {
await db.update(users).set({ homeBoardId: board.id }).where(eq(users.id, userId));
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "Demo Integration",
url: "https://demo.homarr.dev",
kind: "mock",
appId: null,
});
const appIds: string[] = [];
for (const app of demoApps) {
const appId = createId();
appIds.push(appId);
await db.insert(apps).values({
id: appId,
name: app.name,
iconUrl: app.iconUrl,
href: app.href,
});
}
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "default",
isPublic: false,
creatorId: userId,
});
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
});
const layoutId = createId();
await db.insert(layouts).values({
id: layoutId,
name: "Base",
columnCount: 12,
breakpoint: 0,
boardId,
});
const demoWidgets = buildDemoWidgets(appIds);
let xOffset = 0;
let yOffset = 0;
let rowMaxHeight = 0;
for (const widget of demoWidgets) {
if (xOffset + widget.width > 12) {
xOffset = 0;
yOffset += rowMaxHeight;
rowMaxHeight = 0;
}
rowMaxHeight = Math.max(rowMaxHeight, widget.height);
const itemId = createId();
await db.insert(items).values({
id: itemId,
boardId,
kind: widget.kind,
...(widget.options ? { options: SuperJSON.stringify(widget.options) } : {}),
});
await db.insert(itemLayouts).values({
itemId,
sectionId,
layoutId,
xOffset,
yOffset,
width: widget.width,
height: widget.height,
});
if (widget.needsIntegration) {
await db.insert(integrationItems).values({
itemId,
integrationId,
});
}
xOffset += widget.width;
}
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
console.log(
"Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.",
"Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.",
);
};
@@ -157,6 +157,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/anchor"
| "/docs/integrations/aria2"
| "/docs/integrations/codeberg"
| "/docs/integrations/coolify"
| "/docs/integrations/dash-dot"
| "/docs/integrations/deluge"
| "/docs/integrations/docker-hub"
@@ -181,6 +182,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/open-media-vault"
| "/docs/integrations/opnsense"
| "/docs/integrations/overseerr"
| "/docs/integrations/peanut"
| "/docs/integrations/pi-hole"
| "/docs/integrations/plex"
| "/docs/integrations/prowlarr"
@@ -217,6 +219,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/bookmarks"
| "/docs/widgets/calendar"
| "/docs/widgets/clock"
| "/docs/widgets/coolify"
| "/docs/widgets/dns-hole-controls"
| "/docs/widgets/dns-hole-summary"
| "/docs/widgets/docker-containers"
@@ -247,6 +250,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/timetable"
| "/docs/widgets/tracearr"
| "/docs/widgets/umami"
| "/docs/widgets/ups"
| "/docs/widgets/video"
| "/docs/widgets/weather"
| ""
+10 -2
View File
@@ -364,7 +364,7 @@ export const integrationDefs = {
},
truenas: {
name: "TrueNAS",
secretKinds: [["username", "password"]],
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
@@ -383,7 +383,6 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg",
category: ["healthMonitoring"],
// @ts-expect-error - docs page will be created when integration is merged
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
defaultPort: 8000,
},
@@ -427,6 +426,13 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
defaultPort: 3000,
},
peaNut: {
name: "PeaNUT",
secretKinds: [["username", "password"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg",
category: ["ups"],
documentationUrl: null,
},
// 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",
@@ -445,6 +451,7 @@ export const integrationDefs = {
"networkController",
"notifications",
"smartHomeServer",
"ups",
],
documentationUrl: null,
},
@@ -532,6 +539,7 @@ export const integrationCategories = [
"mediaMonitoring",
"speedtest",
"analytics",
"ups",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
+1
View File
@@ -38,5 +38,6 @@ export const widgetKinds = [
"tracearr",
"speedtestTracker",
"umami",
"ups",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
+2
View File
@@ -47,12 +47,14 @@
"timezones-ical-library": "catalog:",
"tsdav": "catalog:",
"undici": "catalog:",
"ws": "catalog:",
"xml2js": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-unifi": "catalog:",
"@types/ws": "catalog:",
"@types/xml2js": "catalog:",
"typescript": "catalog:"
}
@@ -36,6 +36,7 @@ import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PeaNutIntegration } from "../peanut/peanut-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
@@ -123,6 +124,7 @@ export const integrationCreators = {
immich: ImmichIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
umami: UmamiIntegration,
peaNut: PeaNutIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
@@ -85,6 +85,16 @@ export abstract class Integration {
return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams);
}
protected webSocketUrl(
path: `/${string}`,
queryParams?: Record<string, string | Date | number | boolean | null | undefined>,
) {
const url = this.url(path, queryParams);
// http -> ws, https -> wss
url.protocol = url.protocol.replace("http", "ws");
return url;
}
public async testConnectionAsync(): Promise<TestingResult> {
try {
const url = new URL(this.integration.url);
+1
View File
@@ -34,6 +34,7 @@ export { ImmichIntegration } from "./immich/immich-integration";
export { TracearrIntegration } from "./tracearr/tracearr-integration";
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -0,0 +1,5 @@
import type { UpsSummary } from "./ups-summary-types";
export interface IUpsSummaryIntegration {
getUpsSummariesAsync(): Promise<UpsSummary[]>;
}
@@ -0,0 +1,32 @@
/**
* Normalized UPS status, resolved from the raw NUT status flags by the integration so that
* widgets and future UPS integrations share a single, typed vocabulary.
*/
export const upsStatuses = ["online", "charging", "onBattery", "lowBattery", "unknown"] as const;
export type UpsStatus = (typeof upsStatuses)[number];
export interface UpsSummary {
/** Stable identifier of the UPS device on the NUT server */
id: string;
/** Human readable name, derived from the manufacturer and model */
name: string;
manufacturer: string | null;
model: string | null;
serial: string | null;
/** Normalized status, resolved from the raw NUT `ups.status` flags */
status: UpsStatus;
/** Battery charge in percent */
batteryCharge: number | null;
/** Estimated remaining runtime on battery, in seconds */
batteryRuntime: number | null;
batteryVoltage: number | null;
/** UPS load in percent */
load: number | null;
inputVoltage: number | null;
outputVoltage: number | null;
/** Real power drawn by the load, in watts */
power: number | null;
/** UPS temperature in degrees celsius */
temperature: number | null;
}
+191 -69
View File
@@ -1,24 +1,130 @@
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
export class CalendarMockService implements ICalendarIntegration {
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
const result = [homarrMeetup(start, end), titanicRelease(start, end), seriesRelease(start, end)];
return await Promise.resolve(result);
}
interface EventTemplate {
title: string;
subTitle: string;
description: string;
dayOffset: number;
hours: number;
image: CalendarEvent["image"];
indicatorColor: string;
metadata?: CalendarEvent["metadata"];
location?: string;
links: CalendarEvent["links"];
}
const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
const startDate = randomDateBetween(start, end);
const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours later
return {
const eventTemplates: EventTemplate[] = [
{
title: "The Mandalorian",
subTitle: "Season 4",
description: "A lone bounty hunter in the outer reaches of the galaxy.",
dayOffset: 0,
hours: 20,
image: {
src: "https://image.tmdb.org/t/p/w300/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S4/E3", color: "red" },
},
indicatorColor: "blue",
links: [
{
href: "https://www.imdb.com/title/tt8111088/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Silo",
subTitle: "Season 2",
description: "In a ruined and toxic future, thousands live in a giant underground silo.",
dayOffset: 3,
hours: 21,
image: {
src: "https://image.tmdb.org/t/p/w300/2xSzAuKMhERTcPA19SpFQtVrMC0.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S2/E8", color: "teal" },
},
indicatorColor: "teal",
links: [
{
href: "https://www.imdb.com/title/tt14688458/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Dune: Part Two",
subTitle: "Digital Release",
description: "Paul Atreides unites with the Fremen to seek revenge against the conspirators.",
dayOffset: 5,
hours: 0,
image: {
src: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "digitalRelease" },
indicatorColor: "cyan",
links: [
{
href: "https://www.imdb.com/title/tt15239678/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Severance",
subTitle: "Season 2",
description: "Mark leads a team of office workers whose memories are surgically divided.",
dayOffset: 7,
hours: 22,
image: {
src: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S2/E5", color: "violet" },
},
indicatorColor: "violet",
links: [],
},
{
title: "Oppenheimer",
subTitle: "Physical Release",
description: "The story of the development of the atomic bomb.",
dayOffset: 10,
hours: 0,
image: {
src: "https://image.tmdb.org/t/p/w300/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "physicalRelease" },
indicatorColor: "orange",
links: [
{
href: "https://www.imdb.com/title/tt15398776/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Homarr Meetup",
subTitle: "",
description: "Yearly meetup of the Homarr community",
startDate,
endDate,
image: null,
dayOffset: 14,
hours: 14,
location: "Mountains",
image: null,
indicatorColor: "#fa5252",
links: [
{
@@ -29,63 +135,79 @@ const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
isDark: true,
},
],
};
},
{
title: "The Bear",
subTitle: "Season 3",
description: "A young chef fights to transform a sandwich shop.",
dayOffset: 18,
hours: 3,
image: {
src: "https://image.tmdb.org/t/p/w300/sHFlYJhNbmpMOkr7JKGxFAnMPFm.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S3/E1", color: "yellow" },
},
indicatorColor: "yellow",
links: [],
},
{
title: "Foundation",
subTitle: "Season 3",
description: "A band of exiles discovers that the only way to save the Galactic Empire is to defy it.",
dayOffset: 21,
hours: 20,
image: {
src: "https://image.tmdb.org/t/p/w300/wSByZTwCPVYAjlqcVCBTCLZchkC.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S3/E1", color: "indigo" },
},
indicatorColor: "indigo",
links: [],
},
{
title: "Blade Runner 2099",
subTitle: "Premiere",
description: "A new chapter in the Blade Runner universe.",
dayOffset: 25,
hours: 21,
image: {
src: "https://image.tmdb.org/t/p/w300/63UMsIFOlCSz3uCPKtbgOdUr4pE.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "digitalRelease" },
indicatorColor: "grape",
links: [],
},
];
const buildEventsForRange = (start: Date, end: Date): CalendarEvent[] => {
const rangeStart = new Date(start);
rangeStart.setDate(rangeStart.getDate() - 3);
return eventTemplates
.map((tpl) => {
const startDate = new Date(rangeStart);
startDate.setDate(startDate.getDate() + tpl.dayOffset);
startDate.setHours(tpl.hours, 0, 0, 0);
return {
title: tpl.title,
subTitle: tpl.subTitle,
description: tpl.description,
startDate,
endDate: tpl.location ? new Date(startDate.getTime() + 2 * 60 * 60 * 1000) : null,
image: tpl.image,
location: tpl.location ?? null,
indicatorColor: tpl.indicatorColor,
links: tpl.links,
...(tpl.metadata ? { metadata: tpl.metadata } : {}),
};
})
.filter((event) => event.startDate >= start && event.startDate <= end);
};
const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
title: "Titanic",
subTitle: "A classic movie",
description: "A tragic love story set on the ill-fated RMS Titanic.",
startDate: randomDateBetween(start, end),
endDate: null,
image: {
src: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
aspectRatio: { width: 7, height: 12 },
},
location: null,
metadata: {
type: "radarr",
releaseType: "inCinemas",
},
indicatorColor: "cyan",
links: [
{
href: "https://www.imdb.com/title/tt0120338/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
});
const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
title: "The Mandalorian",
subTitle: "A Star Wars Series",
description: "A lone bounty hunter in the outer reaches of the galaxy.",
startDate: randomDateBetween(start, end),
endDate: null,
image: {
src: "https://image.tmdb.org/t/p/original/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
aspectRatio: { width: 7, height: 12 },
badge: {
content: "S1/E1",
color: "red",
},
},
location: null,
indicatorColor: "blue",
links: [
{
href: "https://www.imdb.com/title/tt8111088/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
});
function randomDateBetween(start: Date, end: Date): Date {
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
export class CalendarMockService implements ICalendarIntegration {
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
return await Promise.resolve(buildEventsForRange(start, end));
}
}
+135 -29
View File
@@ -2,57 +2,163 @@ import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/dow
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
const mockItems: Omit<DownloadClientItem, "id" | "index">[] = [
{
name: "Big.Buck.Bunny.2008.1080p.BluRay.x264",
type: "torrent",
size: 4_700_000_000,
progress: 0.47,
state: "leeching",
downSpeed: 12_400_000,
upSpeed: 890_000,
sent: 420_000_000,
received: 2_209_000_000,
time: 201_000,
category: "movies",
},
{
name: "Ubuntu.24.04.1.LTS.Desktop.amd64.iso",
type: "torrent",
size: 5_800_000_000,
progress: 0.82,
state: "leeching",
downSpeed: 34_500_000,
upSpeed: 2_100_000,
sent: 1_200_000_000,
received: 4_756_000_000,
time: 30_000,
category: "iso",
},
{
name: "Sintel.2010.4K.DTS-HD.MA.5.1.mkv",
type: "torrent",
size: 18_200_000_000,
progress: 0.03,
state: "leeching",
downSpeed: 1_200_000,
upSpeed: 50_000,
sent: 30_000_000,
received: 546_000_000,
time: 14_880_000,
category: "movies",
},
{
name: "Arch.Linux.2024.12.01.x86_64.iso",
type: "torrent",
size: 1_100_000_000,
progress: 1,
state: "seeding",
downSpeed: 0,
upSpeed: 4_500_000,
sent: 3_300_000_000,
received: 1_100_000_000,
time: -7_200_000,
category: "iso",
},
{
name: "Tears.of.Steel.2012.1080p.DTS.mkv",
type: "torrent",
size: 7_340_000_000,
progress: 0,
state: "paused",
downSpeed: 0,
upSpeed: 0,
time: 0,
category: "movies",
},
{
name: "Cosmos.Laundromat.First.Cycle.2015.4K.mkv",
type: "torrent",
size: 12_000_000_000,
progress: 0.65,
state: "stalled",
downSpeed: 0,
upSpeed: 0,
sent: 200_000_000,
received: 7_800_000_000,
time: 0,
category: "movies",
},
{
name: "Spring.2019.4K.HDR.mkv",
type: "usenet",
size: 3_200_000_000,
progress: 0.91,
state: "downloading",
downSpeed: 45_000_000,
time: 6_500,
category: "movies",
},
{
name: "Agent.327.Operation.Barbershop.2017.1080p.mkv",
type: "usenet",
size: 1_800_000_000,
progress: 1,
state: "completed",
time: -3_600_000,
category: "movies",
},
{
name: "Caminandes.3.Llamigos.2016.1080p.mkv",
type: "torrent",
size: 890_000_000,
progress: 1,
state: "seeding",
downSpeed: 0,
upSpeed: 1_800_000,
sent: 2_670_000_000,
received: 890_000_000,
time: -86_400_000,
category: "movies",
},
{
name: "Hero.2024.S01.1080p.WEB-DL.DDP5.1",
type: "usenet",
size: 9_400_000_000,
progress: 0.22,
state: "downloading",
downSpeed: 28_000_000,
time: 244_000,
category: "tv",
},
];
export class DownloadClientMockService implements IDownloadClientIntegration {
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
return await Promise.resolve({
status: {
paused: Math.random() < 0.5,
paused: false,
rates: {
down: Math.floor(Math.random() * 5000),
up: Math.floor(Math.random() * 5000),
down: 121_100_000,
up: 9_340_000,
},
types: ["torrent", "usenet"],
types: ["torrent", "usenet"] as const,
},
items: Array.from({ length: 20 }, (_, index) => DownloadClientMockService.createItem(index)).slice(
0,
input.limit,
),
items: mockItems.slice(0, input.limit).map((item, index) => ({
...item,
id: `mock-${index}`,
index,
})),
});
}
public async pauseQueueAsync(): Promise<void> {
return await Promise.resolve();
await Promise.resolve();
}
public async pauseItemAsync(_item: DownloadClientItem): Promise<void> {
return await Promise.resolve();
await Promise.resolve();
}
public async resumeQueueAsync(): Promise<void> {
return Promise.resolve();
await Promise.resolve();
}
public async resumeItemAsync(_item: DownloadClientItem): Promise<void> {
return await Promise.resolve();
await Promise.resolve();
}
public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise<void> {
return await Promise.resolve();
}
private static createItem(index: number): DownloadClientItem {
const progress = Math.random() < 0.5 ? Math.random() : 1;
return {
id: `item-${index}`,
index,
name: `Item ${index}`,
type: Math.random() > 0.5 ? "torrent" : "usenet",
progress,
size: Math.floor(Math.random() * 10000) + 1,
downSpeed: Math.floor(Math.random() * 5000),
upSpeed: Math.floor(Math.random() * 5000),
state: progress >= 1 ? "completed" : "downloading",
time: 0,
};
await Promise.resolve();
}
}
@@ -13,17 +13,17 @@ export const mockMediaReleases: MediaRelease[] = [
title: "Inception",
subtitle: "A mind-bending thriller",
description:
"A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a CEO.",
"A thief who steals corporate secrets through dream-sharing technology is given the task of planting an idea into the mind of a CEO.",
releaseDate: new Date("2010-07-16"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/inception_backdrop.jpg",
poster: "https://image.tmdb.org/t/p/w300/ljsZTbVsrQSqZgWeep2B1QiDKuh.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/8ZTVqvKDQ8emSGUEMjsS4yHAwrp.jpg",
},
producer: "Warner Bros.",
price: 14.99,
rating: "8.8/10",
tags: ["Sci-Fi", "Thriller"],
href: "https://example.com/inception",
href: "https://www.themoviedb.org/movie/27205-inception",
length: 148,
},
{
@@ -34,13 +34,13 @@ export const mockMediaReleases: MediaRelease[] = [
description: "When Walter White's secret is revealed, he must face the consequences of his actions.",
releaseDate: new Date("2013-09-15"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/breaking_bad_backdrop.jpg",
poster: "https://image.tmdb.org/t/p/w300/ztkUQFLlC19CCMYHW73GM9hBNRh.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
},
producer: "AMC",
rating: "9.5/10",
tags: ["Crime", "Drama"],
href: "https://example.com/breaking_bad",
href: "https://www.themoviedb.org/tv/1396-breaking-bad",
},
{
id: "3",
@@ -50,79 +50,86 @@ export const mockMediaReleases: MediaRelease[] = [
description: "The fourth studio album by French electronic music duo Daft Punk.",
releaseDate: new Date("2013-05-17"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/ram_backdrop.jpg",
poster: "https://image.tmdb.org/t/p/w300/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
},
producer: "Columbia Records",
price: 9.99,
rating: "8.5/10",
tags: ["Electronic", "Dance", "Pop", "Funk"],
href: "https://example.com/ram",
tags: ["Electronic", "Funk"],
href: "https://www.daftpunk.com",
},
{
id: "4",
type: "book",
title: "The Great Gatsby",
subtitle: "F. Scott Fitzgerald",
description: "A novel about the American dream and the disillusionment that comes with it.",
releaseDate: new Date("1925-04-10"),
type: "movie",
title: "The Dark Knight",
subtitle: "Theatrical Release",
description:
"Batman raises the stakes in his war on crime. With the help of Lt. Gordon and DA Harvey Dent, Batman sets out to dismantle organized crime.",
releaseDate: new Date("2008-07-18"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/gatsby_backdrop.jpg",
poster: "https://image.tmdb.org/t/p/w300/qJ2tW6WMUDux911BTUgMe1nV8Is.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/nMKdUUepR0i5zn0y1T4CsSB5ez.jpg",
},
producer: "Scribner",
price: 10.99,
rating: "4.2/5",
tags: ["Classic", "Fiction"],
href: "https://example.com/gatsby",
producer: "Warner Bros.",
price: 12.99,
rating: "9.0/10",
tags: ["Action", "Crime", "Drama"],
href: "https://www.themoviedb.org/movie/155-the-dark-knight",
length: 152,
},
{
id: "5",
type: "game",
title: "The Legend of Zelda: Breath of the Wild",
subtitle: "Nintendo Switch",
description: "An open-world action-adventure game set in the fantasy land of Hyrule.",
releaseDate: new Date("2017-03-03"),
type: "movie",
title: "Interstellar",
subtitle: "Digital Release",
description: "A team of explorers travel through a wormhole in space in an attempt to ensure humanity's survival.",
releaseDate: new Date("2014-11-07"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/zelda_backdrop.jpg",
poster: "https://image.tmdb.org/t/p/w300/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/xJHokMbljXjADYdit5fK1DVfjko.jpg",
},
producer: "Nintendo",
price: 59.99,
rating: "10/10",
tags: ["Action", "Adventure"],
href: "https://example.com/zelda",
producer: "Paramount",
price: 14.99,
rating: "8.7/10",
tags: ["Sci-Fi", "Adventure"],
href: "https://www.themoviedb.org/movie/157336-interstellar",
length: 169,
},
{
id: "6",
type: "article",
title: "The Rise of AI in Healthcare",
subtitle: "Tech Innovations",
description: "Exploring the impact of artificial intelligence on the healthcare industry.",
releaseDate: new Date("2023-10-01"),
type: "tv",
title: "Severance",
subtitle: "S2E1 - Premiere",
description:
"Mark leads a team of office workers whose memories are surgically divided between work and personal lives.",
releaseDate: new Date("2025-01-17"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/ai_healthcare_backdrop.jpg",
poster: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/gvaAaDUbF41Z1NLuJsHPRHkv1Ie.jpg",
},
producer: "Tech Innovations",
rating: "4.8/5",
tags: ["Technology", "Healthcare"],
href: "https://example.com/ai_healthcare",
producer: "Apple TV+",
rating: "8.7/10",
tags: ["Thriller", "Sci-Fi"],
href: "https://www.themoviedb.org/tv/95396-severance",
},
{
id: "7",
type: "video",
title: "Wir LIEBEN unsere MAMAS | 50 Fragen zu Mamas",
releaseDate: new Date("2024-05-18T17:00:00Z"),
type: "movie",
title: "Dune: Part Two",
subtitle: "Theatrical Release",
description:
"Paul Atreides unites with the Fremen to seek revenge against the conspirators who destroyed his family.",
releaseDate: new Date("2024-03-01"),
imageUrls: {
poster:
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
backdrop:
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
poster: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
},
producer: "PietSmiet",
rating: "1K",
tags: [],
href: "https://www.youtube.com/watch?v=a3qyfXc1Pfg",
producer: "Warner Bros.",
price: 19.99,
rating: "8.5/10",
tags: ["Sci-Fi", "Adventure"],
href: "https://www.themoviedb.org/movie/693134-dune-part-two",
length: 166,
},
];
@@ -1,36 +1,95 @@
import type { IMediaServerIntegration } from "../../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../../interfaces/media-server/media-server-types";
export class MediaServerMockService implements IMediaServerIntegration {
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
return await Promise.resolve(
Array.from({ length: 10 }, (_, index) => MediaServerMockService.createSession(index)).filter(
(session) => !options.showOnlyPlaying || session.currentlyPlaying !== null,
),
);
}
const sessions: StreamSession[] = [
{
sessionId: "s-0",
sessionName: "Living Room TV",
user: { userId: "u-0", username: "thomas", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "Interstellar",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-1",
sessionName: "Bedroom Chromecast",
user: { userId: "u-1", username: "sarah", profilePictureUrl: null },
currentlyPlaying: {
type: "video",
name: "Breaking Bad",
seasonName: "Season 5",
episodeName: "Ozymandias",
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-2",
sessionName: "Office Desktop",
user: { userId: "u-2", username: "mike", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "The Dark Knight",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-3",
sessionName: "iPad",
user: { userId: "u-3", username: "emily", profilePictureUrl: null },
currentlyPlaying: {
type: "video",
name: "Severance",
seasonName: "Season 2",
episodeName: "The We We Are",
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-4",
sessionName: "Mobile",
user: { userId: "u-4", username: "alex", profilePictureUrl: null },
currentlyPlaying: {
type: "audio",
name: "Daft Punk",
seasonName: undefined,
episodeName: null,
albumName: "Random Access Memories",
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-5",
sessionName: "Guest Room",
user: { userId: "u-5", username: "guest", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "Oppenheimer",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
];
private static createSession(index: number): StreamSession {
return {
sessionId: `session-${index}`,
sessionName: `Session ${index}`,
user: {
userId: `user-${index}`,
username: `User${index}`,
profilePictureUrl: "/images/mock/avatar.jpg",
},
currentlyPlaying:
Math.random() > 0.9 // 10% chance of being null (not currently playing)
? {
type: "movie",
name: `Movie ${index}`,
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
}
: null,
};
export class MediaServerMockService implements IMediaServerIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
return await Promise.resolve(sessions);
}
}
@@ -4,27 +4,11 @@ import type { NetworkControllerSummary } from "../../types";
export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration {
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
return await Promise.resolve({
lan: {
guests: 5,
users: 10,
status: "enabled",
},
vpn: {
users: 3,
status: "enabled",
},
wanStatus: "disabled",
wifi: {
status: "disabled",
guests: 0,
users: 0,
},
www: {
latency: 22,
status: "enabled",
ping: 32,
uptime: 3600,
},
lan: { guests: 5, users: 18, status: "enabled" },
vpn: { users: 3, status: "enabled" },
wanStatus: "enabled",
wifi: { status: "enabled", guests: 2, users: 12 },
www: { latency: 22, status: "enabled", ping: 14, uptime: 864000 },
});
}
}
@@ -0,0 +1,41 @@
import type { IUpsSummaryIntegration } from "../../interfaces/ups-summary/ups-summary-integration";
import type { UpsSummary } from "../../types";
export class UpsSummaryMockService implements IUpsSummaryIntegration {
public async getUpsSummariesAsync(): Promise<UpsSummary[]> {
return await Promise.resolve([
{
id: "ups-rack-1",
name: "CyberPower OR1500",
manufacturer: "CyberPower",
model: "OR1500LCDRM1U",
serial: "CPS1500-001",
status: "online",
batteryCharge: 100,
batteryRuntime: 2280,
batteryVoltage: 27.3,
load: 42,
inputVoltage: 230,
outputVoltage: 230,
power: 315,
temperature: 29,
},
{
id: "ups-rack-2",
name: "APC Back-UPS Pro",
manufacturer: "APC",
model: "BR1500G",
serial: "APC1500-002",
status: "onBattery",
batteryCharge: 64,
batteryRuntime: 840,
batteryVoltage: 25.1,
load: 58,
inputVoltage: 0,
outputVoltage: 229,
power: 410,
temperature: 31,
},
]);
}
}
@@ -15,6 +15,7 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration";
import type { ISmartHomeIntegration } from "../interfaces/smart-home/smart-home-integration";
import type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
import { CalendarMockService } from "./data/calendar";
import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monitoring";
import { DnsHoleMockService } from "./data/dns-hole";
@@ -28,6 +29,7 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s
import { NotificationsMockService } from "./data/notifications";
import { SmartHomeMockService } from "./data/smart-home";
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
import { UpsSummaryMockService } from "./data/ups-summary";
export class MockIntegration
extends Integration
@@ -43,7 +45,8 @@ export class MockIntegration
IMediaServerIntegration,
IMediaTranscodingIntegration,
NetworkControllerSummaryIntegration,
ISmartHomeIntegration
ISmartHomeIntegration,
IUpsSummaryIntegration
{
private static readonly dnsHole = new DnsHoleMockService();
private static readonly calendar = new CalendarMockService();
@@ -58,6 +61,7 @@ export class MockIntegration
private static readonly networkController = new NetworkControllerSummaryMockService();
private static readonly notifications = new NotificationsMockService();
private static readonly smartHome = new SmartHomeMockService();
private static readonly upsSummary = new UpsSummaryMockService();
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
return await Promise.resolve({
@@ -85,7 +89,7 @@ export class MockIntegration
// Health Monitoring Integrations
getSystemInfoAsync = MockIntegration.systemMonitoring.getSystemInfoAsync.bind(MockIntegration.systemMonitoring);
getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.downloadClient);
getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.clusterMonitoring);
// IndexerManagerIntegration
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
@@ -104,7 +108,7 @@ export class MockIntegration
declineRequestAsync = MockIntegration.mediaRequest.declineRequestAsync.bind(MockIntegration.mediaRequest);
// MediaServerIntegration
getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaRequest);
getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaServer);
// MediaTranscodingIntegration
getStatisticsAsync = MockIntegration.mediaTranscoding.getStatisticsAsync.bind(MockIntegration.mediaTranscoding);
@@ -123,4 +127,7 @@ export class MockIntegration
getEntityStateAsync = MockIntegration.smartHome.getEntityStateAsync.bind(MockIntegration.smartHome);
triggerAutomationAsync = MockIntegration.smartHome.triggerAutomationAsync.bind(MockIntegration.smartHome);
triggerToggleAsync = MockIntegration.smartHome.triggerToggleAsync.bind(MockIntegration.smartHome);
// UpsSummaryIntegration
getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary);
}
@@ -0,0 +1,103 @@
import { 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 { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
import type { UpsStatus, UpsSummary } from "../interfaces/ups-summary/ups-summary-types";
import type { PeaNutDevice } from "./peanut-types";
import { peaNutDevicesSchema } from "./peanut-types";
export class PeaNutIntegration extends Integration implements IUpsSummaryIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v1/devices"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) return TestConnectionError.StatusResult(response);
await response.json();
return { success: true };
}
public async getUpsSummariesAsync(): Promise<UpsSummary[]> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/devices", { meta: true }), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
const devices = peaNutDevicesSchema.parse(await response.json());
return devices.map((device, index) => this.mapDeviceToSummary(device, index));
}
private mapDeviceToSummary(device: PeaNutDevice, index: number): UpsSummary {
const manufacturer = this.readString(device, "device.mfr") ?? this.readString(device, "ups.mfr");
const model = this.readString(device, "device.model") ?? this.readString(device, "ups.model");
const id = this.readString(device, "peanut.device_id") ?? `ups-${index}`;
const name = [manufacturer, model].filter(Boolean).join(" ") || id;
return {
id,
name,
manufacturer,
model,
serial: this.readString(device, "device.serial"),
status: this.parseStatus(this.readString(device, "ups.status")),
batteryCharge: this.readNumber(device, "battery.charge"),
batteryRuntime: this.readNumber(device, "battery.runtime"),
batteryVoltage: this.readNumber(device, "battery.voltage"),
load: this.readNumber(device, "ups.load"),
inputVoltage: this.readNumber(device, "input.voltage"),
outputVoltage: this.readNumber(device, "output.voltage"),
power: this.readNumber(device, "ups.realpower") ?? this.readNumber(device, "ups.power"),
temperature: this.readNumber(device, "ups.temperature"),
};
}
/**
* Resolves the raw NUT `ups.status` flags (e.g. "OL CHRG", "OB DISCHRG LB") into a single
* normalized status. The flags are checked in priority order so the most critical state wins.
*/
private parseStatus(rawStatus: string | null): UpsStatus {
const flags = (rawStatus ?? "").toUpperCase().split(/\s+/).filter(Boolean);
if (flags.includes("LB")) return "lowBattery";
if (flags.includes("OB")) return "onBattery";
if (flags.includes("OL")) {
return flags.includes("CHRG") ? "charging" : "online";
}
return "unknown";
}
private readString(device: PeaNutDevice, key: string): string | null {
const value = device[key];
if (value === undefined || value === null) return null;
const stringValue = String(value).trim();
return stringValue.length > 0 ? stringValue : null;
}
private readNumber(device: PeaNutDevice, key: string): number | null {
const value = device[key];
if (value === undefined || value === null || value === "") return null;
const numberValue = typeof value === "number" ? value : Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}
private getAuthHeaders(): Record<string, string> {
if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
"base64",
);
return { Authorization: `Basic ${credentials}` };
}
return {};
}
}
@@ -0,0 +1,10 @@
import { z } from "zod/v4";
// PeaNUT returns each device as a flat map of NUT variables (e.g. "ups.status",
// "battery.charge"). Values are either strings or numbers depending on the
// variable, so we accept both and coerce them when mapping to a UpsSummary.
export const peaNutDeviceSchema = z.record(z.string(), z.union([z.string(), z.number()]));
export const peaNutDevicesSchema = z.array(peaNutDeviceSchema);
export type PeaNutDevice = z.infer<typeof peaNutDeviceSchema>;
@@ -0,0 +1,166 @@
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../base/types";
import { PeaNutIntegration } from "../peanut-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "https://peanut.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const mockDevicesResponse = (devices: Record<string, string | number>[]) => {
mockFetch.mockImplementation(
() =>
Promise.resolve(
new Response(JSON.stringify(devices), {
status: 200,
headers: { "content-type": "application/json" },
}),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>,
);
};
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
new PeaNutIntegration({
id: "test-peanut",
name: "Test PeaNUT",
url: TEST_URL,
externalUrl: null,
decryptedSecrets,
});
beforeEach(() => {
mockFetch.mockReset();
});
describe("PeaNutIntegration getUpsSummariesAsync", () => {
test("maps NUT variables to a UPS summary and coerces string values to numbers", async () => {
mockDevicesResponse([
{
"device.mfr": "CyberPower",
"device.model": "CP1500EPFCLCD",
"device.serial": "ABC123",
"ups.status": "OL CHRG",
"ups.load": "23",
"ups.realpower": "120",
"ups.temperature": "31.5",
"battery.charge": "100",
"battery.runtime": "3720",
"battery.voltage": "27.3",
"input.voltage": "230",
"output.voltage": "229",
"peanut.device_id": "ups",
"peanut.server": "localhost:3493",
},
]);
const [summary] = await createIntegration().getUpsSummariesAsync();
expect(summary).toStrictEqual({
id: "ups",
name: "CyberPower CP1500EPFCLCD",
manufacturer: "CyberPower",
model: "CP1500EPFCLCD",
serial: "ABC123",
status: "charging",
batteryCharge: 100,
batteryRuntime: 3720,
batteryVoltage: 27.3,
load: 23,
inputVoltage: 230,
outputVoltage: 229,
power: 120,
temperature: 31.5,
});
});
test("falls back when optional variables are missing", async () => {
mockDevicesResponse([
{
"ups.status": "OB DISCHRG LB",
"ups.power": "90",
"battery.charge": "15",
},
]);
const [summary] = await createIntegration().getUpsSummariesAsync();
expect(summary).toMatchObject({
id: "ups-0",
name: "ups-0",
manufacturer: null,
model: null,
status: "lowBattery",
batteryCharge: 15,
batteryRuntime: null,
load: null,
inputVoltage: null,
outputVoltage: null,
power: 90,
temperature: null,
});
});
test("returns a summary per device", async () => {
mockDevicesResponse([
{ "peanut.device_id": "ups-a", "ups.status": "OL" },
{ "peanut.device_id": "ups-b", "ups.status": "OB" },
]);
const summaries = await createIntegration().getUpsSummariesAsync();
expect(summaries.map((summary) => summary.id)).toStrictEqual(["ups-a", "ups-b"]);
});
test("normalizes NUT status flags into a typed status", async () => {
mockDevicesResponse([
{ "ups.status": "OL" },
{ "ups.status": "OL CHRG" },
{ "ups.status": "OB DISCHRG" },
{ "ups.status": "OB DISCHRG LB" },
{ "ups.status": "OL LB" },
{ "ups.status": "" },
]);
const summaries = await createIntegration().getUpsSummariesAsync();
expect(summaries.map((summary) => summary.status)).toStrictEqual([
"online",
"charging",
"onBattery",
"lowBattery",
"lowBattery",
"unknown",
]);
});
});
describe("PeaNutIntegration authentication", () => {
test("sends a Basic auth header when username and password are configured", async () => {
mockDevicesResponse([]);
await createIntegration([
{ kind: "username", value: "admin" },
{ kind: "password", value: "secret" },
]).getUpsSummariesAsync();
const expected = `Basic ${Buffer.from("admin:secret").toString("base64")}`;
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
expect(requestInit?.headers).toMatchObject({ Authorization: expected });
});
test("omits the auth header when no credentials are configured", async () => {
mockDevicesResponse([]);
await createIntegration().getUpsSummariesAsync();
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
expect(requestInit?.headers).toStrictEqual({});
});
});
@@ -0,0 +1,79 @@
import { describe, expect, test } from "vitest";
import { trueNasApis } from "../truenas-api";
describe("trueNasApis descriptors", () => {
test("expose the expected endpoints and handshake requirements", () => {
expect(trueNasApis.jsonRpc).toMatchObject({
kind: "jsonRpc",
path: "/api/current",
requiresSessionHandshake: false,
});
expect(trueNasApis.legacy).toMatchObject({
kind: "legacy",
path: "/websocket",
requiresSessionHandshake: true,
});
});
});
describe("jsonRpc api", () => {
const { buildRequest, matchResponse } = trueNasApis.jsonRpc;
test("builds a JSON-RPC 2.0 envelope", () => {
expect(buildRequest("id-1", "system.info", [])).toStrictEqual({
jsonrpc: "2.0",
id: "id-1",
method: "system.info",
params: [],
});
});
test("matches a result for the requested id", () => {
const result = matchResponse({ jsonrpc: "2.0", id: "id-1", result: true }, "id-1");
expect(result).toStrictEqual({ result: true });
});
test("returns the error when the response carries one", () => {
const error = { code: -32001, message: "Not authenticated" };
expect(matchResponse({ jsonrpc: "2.0", id: "id-1", error }, "id-1")).toStrictEqual({ error });
});
test("ignores responses for a different id", () => {
expect(matchResponse({ jsonrpc: "2.0", id: "other", result: true }, "id-1")).toBeUndefined();
});
test("ignores non JSON-RPC messages", () => {
expect(matchResponse({ msg: "result", id: "id-1", result: true }, "id-1")).toBeUndefined();
});
});
describe("legacy api", () => {
const { buildRequest, matchResponse } = trueNasApis.legacy;
test("builds a DDP method envelope", () => {
expect(buildRequest("id-1", "auth.login", ["user", "pass"])).toStrictEqual({
id: "id-1",
msg: "method",
method: "auth.login",
params: ["user", "pass"],
});
});
test("matches a result message for the requested id", () => {
expect(matchResponse({ msg: "result", id: "id-1", result: false }, "id-1")).toStrictEqual({ result: false });
});
test("returns the error when the result message carries one", () => {
const error = { error: 13, reason: "boom" };
expect(matchResponse({ msg: "result", id: "id-1", error }, "id-1")).toStrictEqual({ error });
});
test("ignores responses for a different id", () => {
expect(matchResponse({ msg: "result", id: "other", result: true }, "id-1")).toBeUndefined();
});
test("ignores non-result messages such as the session handshake", () => {
expect(matchResponse({ msg: "connected", session: "abc" }, "id-1")).toBeUndefined();
});
});
@@ -0,0 +1,264 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { IntegrationRequestError } from "../../base/errors/http/integration-request-error";
import type { IntegrationSecret } from "../../base/types";
const ws = vi.hoisted(() => {
const sentPayloads: Record<string, unknown>[] = [];
const constructedUrls: string[] = [];
// A failure with a `code` emits a coded TLS-style error; without one it mimics a missing endpoint (HTTP 404).
const failures: { path: string; code?: string }[] = [];
let responder: (method: string, params: unknown[]) => unknown = () => undefined;
type Listener = (...args: unknown[]) => void;
class FakeWebSocket {
static readonly OPEN = 1;
public readyState = FakeWebSocket.OPEN;
private readonly listeners: Record<string, Listener[]> = {};
constructor(url: URL | string) {
constructedUrls.push(url.toString());
setTimeout(() => {
const failure = failures.find((candidate) => url.toString().includes(candidate.path));
if (failure) {
const error: Error & { code?: string } = new Error(failure.code ?? "Unexpected server response: 404");
if (failure.code) error.code = failure.code;
this.dispatch("error", error);
} else {
this.dispatch("open");
}
}, 0);
}
on(event: string, listener: Listener) {
(this.listeners[event] ??= []).push(listener);
return this;
}
once(event: string, listener: Listener) {
const wrapper: Listener = (...args) => {
this.off(event, wrapper);
listener(...args);
};
return this.on(event, wrapper);
}
off(event: string, listener: Listener) {
this.listeners[event] = (this.listeners[event] ?? []).filter((candidate) => candidate !== listener);
return this;
}
send(raw: string) {
const payload = JSON.parse(raw) as Record<string, unknown>;
sentPayloads.push(payload);
if (payload.msg === "connect") {
this.respond(JSON.stringify({ msg: "connected", session: "test-session" }));
return;
}
const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
const envelope =
payload.jsonrpc === "2.0"
? { jsonrpc: "2.0", id: payload.id, result }
: { msg: "result", id: payload.id, result };
this.respond(JSON.stringify(envelope));
}
close() {
this.readyState = 3;
}
private respond(message: string) {
setTimeout(() => this.dispatch("message", message), 0);
}
private dispatch(event: string, ...args: unknown[]) {
// `off` replaces the array rather than mutating it, so iterating it directly is safe.
for (const listener of this.listeners[event] ?? []) listener(...args);
}
}
return {
FakeWebSocket,
sentPayloads,
constructedUrls,
failures,
setResponder: (fn: (method: string, params: unknown[]) => unknown) => {
responder = fn;
},
};
});
vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket }));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
}));
vi.mock("@homarr/core/infrastructure/http", () => ({
createCustomCheckServerIdentity: () => () => undefined,
}));
import { TrueNasIntegration } from "../truenas-integration";
const happyResponder = (method: string) => {
switch (method) {
case "auth.login":
case "auth.login_with_api_key":
return true;
case "system.info":
return {
version: "TrueNAS-SCALE-24.10",
hostname: "nas",
physmem: 16_000_000_000,
model: "Intel Xeon",
uptime_seconds: 3600,
};
case "reporting.get_data":
return [
{
name: "cpu",
identifier: "cpu",
aggregations: emptyAggregations(),
start: 0,
end: 1,
legend: [],
data: [[1000, 12]],
},
{
name: "memory",
identifier: "memory",
aggregations: emptyAggregations(),
start: 0,
end: 1,
legend: [],
data: [[1000, 8_000_000_000]],
},
{
name: "cputemp",
identifier: "cputemp",
aggregations: emptyAggregations(),
start: 0,
end: 1,
legend: [],
data: [[1000, 45]],
},
];
case "pool.query":
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
case "interface.query":
return [{ id: "eth0", name: "eth0" }];
case "reporting.netdata_get_data":
return [{ name: "interface", identifier: "eth0", data: [[1000, 100, 200]] }];
default:
return null;
}
};
const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
let integrationCounter = 0;
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
new TrueNasIntegration({
id: `truenas-${(integrationCounter += 1)}`,
name: "TrueNAS Test",
url: "https://truenas.local",
externalUrl: null,
decryptedSecrets,
});
const credentials: IntegrationSecret[] = [
{ kind: "username", value: "root" },
{ kind: "password", value: "secret" },
];
describe("TrueNasIntegration", () => {
beforeEach(() => {
ws.sentPayloads.length = 0;
ws.constructedUrls.length = 0;
ws.failures.length = 0;
ws.setResponder(happyResponder);
});
test("fetches and maps system info over the JSON-RPC API", async () => {
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(ws.constructedUrls).toContain("wss://truenas.local/api/current");
// JSON-RPC does not use the legacy "connect" session handshake.
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
expect(result).toMatchObject({
version: "TrueNAS-SCALE-24.10",
cpuModelName: "Intel Xeon",
cpuTemp: 45,
memAvailableInBytes: 16_000_000_000,
memUsedInBytes: 8_000_000_000,
uptime: 3600,
rebootRequired: false,
availablePkgUpdates: 0,
loadAverage: null,
gpu: [],
network: { up: 20_000, down: 10_000 },
fileSystem: [{ deviceName: "tank", available: "1000", used: "500", percentage: 50 }],
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
});
});
test("authenticates with an API key when one is configured", async () => {
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
await integration.getSystemInfoAsync();
const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."));
expect(authPayload).toMatchObject({ method: "auth.login_with_api_key", params: ["api-key-123"] });
});
test("authenticates with username and password when no API key is configured", async () => {
const integration = createIntegration(credentials);
await integration.getSystemInfoAsync();
const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."));
expect(authPayload).toMatchObject({ method: "auth.login", params: ["root", "secret"] });
});
test("falls back to the legacy websocket API when the JSON-RPC endpoint is unavailable", async () => {
ws.failures.push({ path: "/api/current" });
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(ws.constructedUrls).toContain("wss://truenas.local/api/current");
expect(ws.constructedUrls).toContain("wss://truenas.local/websocket");
// The legacy API requires the "connect" session handshake before authenticating.
expect(ws.sentPayloads[0]).toMatchObject({ msg: "connect" });
expect(ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."))).toMatchObject({
msg: "method",
method: "auth.login",
});
expect(result.version).toBe("TrueNAS-SCALE-24.10");
});
test("rejects when authentication is not successful", async () => {
ws.setResponder((method) => (method.startsWith("auth.") ? false : happyResponder(method)));
const integration = createIntegration(credentials);
await expect(integration.getSystemInfoAsync()).rejects.toThrow();
});
test("surfaces a self-signed certificate as a certificate request error without falling back", async () => {
ws.failures.push({ path: "/api/current", code: "DEPTH_ZERO_SELF_SIGNED_CERT" });
ws.failures.push({ path: "/websocket", code: "DEPTH_ZERO_SELF_SIGNED_CERT" });
const integration = createIntegration(credentials);
const error = await integration.getSystemInfoAsync().catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(IntegrationRequestError);
expect((error as IntegrationRequestError).cause).toMatchObject({ type: "certificate", reason: "untrusted" });
// A certificate failure is endpoint-independent, so the legacy API must not be tried.
expect(ws.constructedUrls).not.toContain("wss://truenas.local/websocket");
});
});
@@ -0,0 +1,50 @@
export type TrueNasApiKind = "jsonRpc" | "legacy";
export type TrueNasResponse = { result: unknown } | { error: unknown };
export interface TrueNasApi {
kind: TrueNasApiKind;
/** Websocket path appended to the integration URL. */
path: `/${string}`;
/** The legacy DDP API requires a "connect" handshake before authentication, the JSON-RPC API does not. */
requiresSessionHandshake: boolean;
/** Builds the request envelope for a method call. */
buildRequest: (id: string, method: string, params: unknown[]) => Record<string, unknown>;
/**
* Returns the response payload when `message` is the reply to `id`,
* otherwise `undefined` so the caller keeps waiting for a later message.
*/
matchResponse: (message: Record<string, unknown>, id: string) => TrueNasResponse | undefined;
}
/**
* JSON-RPC 2.0 API introduced in TrueNAS SCALE 24.10 (Electric Eel) and the
* legacy DDP-style websocket API used before it. Both expose the same
* middleware methods, so only the transport envelope differs.
* @see https://www.truenas.com/docs/api/scale_rest_api.html
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
export const trueNasApis = {
jsonRpc: {
kind: "jsonRpc",
path: "/api/current",
requiresSessionHandshake: false,
buildRequest: (id: string, method: string, params: unknown[]) => ({ jsonrpc: "2.0", id, method, params }),
matchResponse: (message: Record<string, unknown>, id: string) => {
if (message.id !== id || message.jsonrpc !== "2.0") return undefined;
if (message.error != null) return { error: message.error };
return { result: message.result };
},
},
legacy: {
kind: "legacy",
path: "/websocket",
requiresSessionHandshake: true,
buildRequest: (id: string, method: string, params: unknown[]) => ({ id, msg: "method", method, params }),
matchResponse: (message: Record<string, unknown>, id: string) => {
if (message.id !== id || message.msg !== "result") return undefined;
if (message.error != null) return { error: message.error };
return { result: message.result };
},
},
} satisfies Record<TrueNasApiKind, TrueNasApi>;
@@ -0,0 +1,315 @@
import type { RawData } from "ws";
import { WebSocket } from "ws";
import z from "zod";
import { createId } from "@homarr/common";
import { matchErrorCode, RequestError, ResponseError } from "@homarr/common/server";
import {
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/core/infrastructure/certificates";
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import type { TrueNasApi } from "./truenas-api";
import { trueNasApis } from "./truenas-api";
const logger = createLogger({ module: "trueNasClient" });
const REQUEST_TIMEOUT_MS = 5000;
type CertificateOptions = IntegrationTestingInput["options"];
export type TrueNasCredentials = { apiKey: string } | { username: string; password: string };
/**
* Handles talking to a TrueNAS server: opening a websocket with Homarr's
* trusted certificates, negotiating the API variant, authenticating, and
* sending method requests. Connections are reused across the per-request
* integration instances and reconnected transparently when the server drops
* an idle socket.
*/
export class TrueNasClient {
// Keyed by integration id. The value is the in-flight connection promise so concurrent
// callers reuse a single socket instead of racing to open duplicates.
private static readonly connectionMap = new Map<string, Promise<TrueNasSocket>>();
private static readonly apiMap = new Map<string, TrueNasApi>();
constructor(
private readonly id: string,
private readonly webSocketUrl: (path: `/${string}`) => URL,
private readonly credentials: TrueNasCredentials,
) {}
/** Sends an authenticated request, reusing the cached connection while it is open. */
public async requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
const socket = await this.getSocketAsync();
return socket.requestAsync(method, params);
}
/**
* Opens a connection with the provided certificate options, authenticates,
* and closes it. Used by the connection test, which may run with updated
* credentials, so any cached connection is dropped and the API re-detected.
*/
public async testAsync(certificate: CertificateOptions): Promise<void> {
this.evictConnection();
TrueNasClient.apiMap.delete(this.id);
const socket = await this.openSocketAsync(certificate);
try {
await this.authenticateAsync(socket);
} finally {
socket.close();
}
}
private getSocketAsync(): Promise<TrueNasSocket> {
let connection = TrueNasClient.connectionMap.get(this.id);
if (!connection) {
connection = this.connectAndAuthenticateAsync();
TrueNasClient.connectionMap.set(this.id, connection);
const evict = () => {
if (TrueNasClient.connectionMap.get(this.id) === connection) {
TrueNasClient.connectionMap.delete(this.id);
}
};
// Reconnect on the next request once this attempt fails or the server drops the socket.
connection.then((socket) => socket.onClose(evict), evict);
}
return connection;
}
private async connectAndAuthenticateAsync(): Promise<TrueNasSocket> {
const socket = await this.openSocketAsync(await resolveCertificateOptionsAsync());
try {
await this.authenticateAsync(socket);
} catch (error) {
// Never keep an unauthenticated socket, it would be reused without re-authenticating.
socket.close();
throw error;
}
return socket;
}
private evictConnection() {
const connection = TrueNasClient.connectionMap.get(this.id);
if (!connection) return;
TrueNasClient.connectionMap.delete(this.id);
connection.then((socket) => socket.close()).catch(() => undefined);
}
/**
* Connects using the JSON-RPC API (TrueNAS 24.10+) when available and falls
* back to the legacy websocket API otherwise. A missing JSON-RPC endpoint
* surfaces as a plain protocol error; certificate, connection, DNS and
* timeout errors are endpoint-independent and are never retried.
*/
private async openSocketAsync(certificate: CertificateOptions): Promise<TrueNasSocket> {
const cachedApi = TrueNasClient.apiMap.get(this.id);
if (cachedApi) {
return openTrueNasSocketAsync(this.webSocketUrl(cachedApi.path), cachedApi, certificate);
}
try {
const socket = await openTrueNasSocketAsync(
this.webSocketUrl(trueNasApis.jsonRpc.path),
trueNasApis.jsonRpc,
certificate,
);
TrueNasClient.apiMap.set(this.id, trueNasApis.jsonRpc);
return socket;
} catch (error) {
if (error instanceof RequestError) throw error;
logger.debug("JSON-RPC API unavailable, falling back to the legacy websocket API", {
error: error instanceof Error ? error.message : undefined,
});
const socket = await openTrueNasSocketAsync(
this.webSocketUrl(trueNasApis.legacy.path),
trueNasApis.legacy,
certificate,
);
TrueNasClient.apiMap.set(this.id, trueNasApis.legacy);
return socket;
}
}
/**
* Authenticates with an API key when one is configured, otherwise with the
* username and password. Both methods work on either API.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
private async authenticateAsync(socket: TrueNasSocket): Promise<void> {
const response =
"apiKey" in this.credentials
? await socket.requestAsync("auth.login_with_api_key", [this.credentials.apiKey])
: await socket.requestAsync("auth.login", [this.credentials.username, this.credentials.password]);
const success = await z.boolean().parseAsync(response);
if (!success) throw new ResponseError({ status: 401 });
}
}
/**
* Wraps a single websocket connection and speaks whichever TrueNAS API
* ({@link trueNasApis}) was negotiated when it was opened.
*/
class TrueNasSocket {
constructor(
private readonly webSocket: WebSocket,
private readonly api: TrueNasApi,
) {}
public close() {
this.webSocket.close();
}
public onClose(listener: () => void) {
this.webSocket.once("close", listener);
}
/**
* The legacy DDP API requires a "connect" handshake to obtain a session
* before any method can be called.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
public registerSessionAsync(): Promise<void> {
return new Promise((resolve, reject) => {
const handler = (raw: RawData) => {
const message = parseMessage(raw);
if (message?.msg === "connected") {
this.webSocket.off("message", handler);
resolve();
} else if (message?.msg === "failed") {
this.webSocket.off("message", handler);
reject(new Error("Unable to establish TrueNAS session"));
}
};
this.webSocket.on("message", handler);
this.webSocket.send(JSON.stringify({ msg: "connect", version: "1", support: ["1"] }));
});
}
/**
* Sends a method request and resolves with its result. Rejects after
* {@link REQUEST_TIMEOUT_MS} when no matching response was received.
*/
public requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
return new Promise((resolve, reject) => {
const id = createId();
const cleanup = () => {
clearTimeout(timeoutId);
this.webSocket.off("message", handler);
this.webSocket.off("close", onClose);
this.webSocket.off("error", onError);
};
const handler = (raw: RawData) => {
const message = parseMessage(raw);
if (!message) return;
const response = this.api.matchResponse(message, id);
if (!response) return;
cleanup();
if ("error" in response) {
reject(new Error(`TrueNAS method "${method}" failed: ${JSON.stringify(response.error)}`));
return;
}
logger.debug("Received method response", { id, method, api: this.api.kind });
resolve(response.result);
};
// A dropped connection would otherwise leave the request hanging until the timeout below.
const onClose = () => {
cleanup();
reject(
new RequestError(
{ type: "connection", reason: "reset", code: "ECONNRESET" },
{ cause: new Error("Connection closed before a response was received") },
),
);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const timeoutId = setTimeout(() => {
cleanup();
reject(
new RequestError(
{
type: "timeout",
reason: "aborted",
code: "ECONNABORTED",
},
{ cause: new Error("Canceled request after 5 seconds") },
),
);
}, REQUEST_TIMEOUT_MS);
this.webSocket.on("message", handler);
this.webSocket.once("close", onClose);
this.webSocket.once("error", onError);
logger.debug("Sending method request", { id, method, api: this.api.kind });
this.webSocket.send(JSON.stringify(this.api.buildRequest(id, method, params)));
});
}
}
/**
* Opens a websocket using Homarr's trusted certificates and performs the
* session handshake when the API requires it. TLS, connection, DNS and timeout
* failures are translated into {@link RequestError}s so the connection test can
* offer to trust a self-signed certificate, exactly like the HTTP integrations.
*/
const openTrueNasSocketAsync = async (
url: URL,
api: TrueNasApi,
certificate: CertificateOptions,
): Promise<TrueNasSocket> => {
logger.debug("Connecting to TrueNAS websocket", { url: url.toString(), api: api.kind });
const webSocket = await new Promise<WebSocket>((resolve, reject) => {
const socket = new WebSocket(url, {
ca: certificate.ca,
// The @types/ws contract types this as returning a boolean, but ws forwards it to
// tls.connect which uses Node's `Error | undefined` contract that our helper follows.
checkServerIdentity: certificate.checkServerIdentity as never,
});
socket.once("open", () => resolve(socket));
socket.once("error", (error: Error & { code?: string }) => {
const matched = error.code ? matchErrorCode(error.code) : undefined;
reject(matched ? new RequestError(matched, { cause: error }) : error);
});
});
const socket = new TrueNasSocket(webSocket, api);
if (api.requiresSessionHandshake) await socket.registerSessionAsync();
return socket;
};
const resolveCertificateOptionsAsync = async (): Promise<CertificateOptions> => ({
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
});
const parseMessage = (raw: RawData): Record<string, unknown> | undefined => {
try {
const parsed: unknown = JSON.parse(raw.toString());
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
};
@@ -1,8 +1,6 @@
import dayjs from "dayjs";
import z from "zod";
import { createId } from "@homarr/common";
import { RequestError, ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
@@ -10,252 +8,21 @@ import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
import type { TrueNasCredentials } from "./truenas-client";
import { TrueNasClient } from "./truenas-client";
const logger = createLogger({ module: "trueNasIntegration" });
const NETWORK_MULTIPLIER = 100;
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
private static webSocketMap = new Map<string, WebSocket>();
private wsUrl() {
const url = super.url("/websocket");
url.protocol = url.protocol.replace("http", "ws");
return url;
}
private get webSocket() {
return TrueNasIntegration.webSocketMap.get(this.integration.id) ?? null;
}
protected async testingAsync(_input: IntegrationTestingInput): Promise<TestingResult> {
const webSocket = await this.connectWebSocketAsync();
await this.registerSessionAsync(webSocket);
await this.authenticateWebSocketAsync(webSocket);
// Remove current socket connection so we can authenticate with updated credentials
TrueNasIntegration.webSocketMap.delete(this.integration.id);
private client?: TrueNasClient;
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await this.getClient().testAsync(input.options);
return { success: true };
}
/**
* TrueNAS API uses WebSocket. This function connects to the socket
* and resolves the promise if the connection was successful.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html
*/
private async connectWebSocketAsync(): Promise<WebSocket> {
logger.debug("Connecting to websocket server", {
url: this.wsUrl(),
});
const webSocket = new WebSocket(this.wsUrl());
return new Promise((resolve, reject) => {
webSocket.onopen = () => {
logger.debug("Connected to websocket server", {
url: this.wsUrl(),
});
resolve(webSocket);
};
webSocket.onerror = () => {
reject(new Error("Failed to connect"));
};
});
}
/**
* Before authentication, a session must be obtained from the server using the "connect" event.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
private async registerSessionAsync(webSocket: WebSocket): Promise<void> {
return new Promise((resolve, reject) => {
const subscribe = (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as { msg: string };
if (data.msg === "connected") {
webSocket.removeEventListener("message", subscribe);
resolve();
} else if (data.msg === "failed") {
webSocket.removeEventListener("message", subscribe);
reject(new Error("Unable to establish connection"));
}
};
webSocket.addEventListener("message", subscribe);
webSocket.send(
JSON.stringify({
msg: "connect",
version: "1", // this must be number, not string
support: ["1"], // this must be number, not string
}),
);
});
}
/**
* After a session was obtained, the session can be authenticated.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
private async authenticateWebSocketAsync(webSocket?: WebSocket): Promise<void> {
logger.debug("Authenticating with username and password", {
url: this.wsUrl(),
});
const response = await this.requestAsync(
"auth.login",
[this.getSecretValue("username"), this.getSecretValue("password")],
webSocket,
);
const result = await z.boolean().parseAsync(response);
if (!result) throw new ResponseError({ status: 401 });
logger.debug("Authenticated successfully with username and password", {
url: this.wsUrl(),
});
}
private async getPoolsAsync() {
logger.debug("Retrieving pools", {
url: this.wsUrl(),
});
const response = await this.requestAsync("pool.query", [
[],
{
extra: {
is_upgraded: true,
},
},
]);
const result = await poolSchema.parseAsync(response);
// Offline / exported pools have null values for allocated, size and free, so we filter them out
// See https://github.com/homarr-labs/homarr/issues/5194
const activePools = result
.map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return {
...pool,
allocated: pool.allocated,
size: pool.size,
free: pool.free,
};
}
return null;
})
.filter((pool) => pool !== null);
logger.debug("Retrieved pools", {
url: this.wsUrl(),
totalCount: result.length,
activeCount: activePools.length,
});
return activePools;
}
/**
* Retrieves data using the reporting method
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingAsync(): Promise<ReportingItem[]> {
logger.debug("Retrieving reporting data", {
url: this.wsUrl(),
});
const response = await this.requestAsync("reporting.get_data", [
[
{
name: "cpu",
},
{
name: "memory",
},
{
name: "cputemp",
},
],
{
aggregate: true,
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await z.array(reportingItemSchema).parseAsync(response);
logger.debug("Retrieved reporting data", {
url: this.wsUrl(),
count: result.length,
});
return result;
}
/**
* Retrieves a list of all available network interfaces
* @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface
*/
private async getNetworkInterfacesAsync(): Promise<z.infer<typeof networkInterfaceSchema>> {
logger.debug("Retrieving available network-interfaces", {
url: this.wsUrl(),
});
const response = await this.requestAsync("interface.query", [
[], // no filters
{},
]);
const result = await networkInterfaceSchema.parseAsync(response);
logger.debug("Retrieved available network-interfaces", {
url: this.wsUrl(),
count: result.length,
});
return result;
}
/**
* Retrieves reporting network data of the last 5 minutes
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingNetdataAsync(): Promise<z.infer<typeof reportingNetDataSchema>> {
const networkInterfaces = await this.getNetworkInterfacesAsync();
logger.debug("Retrieving reporting network data", {
url: this.wsUrl(),
});
const response = await this.requestAsync("reporting.netdata_get_data", [
networkInterfaces.map((networkInterface) => ({
name: "interface",
identifier: networkInterface.id,
})),
{
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await reportingNetDataSchema.parseAsync(response);
logger.debug("Retrieved reporting-network-data", {
url: this.wsUrl(),
count: result.length,
});
return result;
}
/**
* Retrieves information about the system
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#system
*/
private async getSystemInformationAsync(): Promise<z.infer<typeof systemInfoSchema>> {
logger.debug("Retrieving system-information", {
url: this.wsUrl(),
});
const response = await this.requestAsync("system.info");
const result = await systemInfoSchema.parseAsync(response);
logger.debug("Retrieved system-information", {
url: this.wsUrl(),
});
return result;
}
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const systemInformation = await this.getSystemInformationAsync();
const reporting = await this.getReportingAsync();
@@ -272,7 +39,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
return {
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
cpuTemp: Math.max(...cpuTempData.filter((_item, i) => i > 0)),
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
memAvailableInBytes: systemInformation.physmem,
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
fileSystem: datasets.map((dataset) => ({
@@ -301,87 +68,164 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
};
}
/**
* Send a request through websocket and return response
* Times out after 5 seconds when no response was received.
* @param method json-rpc method to call
* @param params array of parameters
* @param webSocketOverride override of webSocket, helpful for not storing the connection
* @returns result of json-rpc call
*/
private async requestAsync(method: string, params: unknown[] = [], webSocketOverride?: WebSocket) {
let webSocket = webSocketOverride ?? this.webSocket;
if (webSocket?.readyState !== WebSocket.OPEN) {
logger.debug("Connecting to websocket", {
url: this.wsUrl(),
});
// We can only land here with static webSocket
webSocket = await this.connectWebSocketAsync();
await this.registerSessionAsync(webSocket);
TrueNasIntegration.webSocketMap.set(this.integration.id, webSocket);
await this.authenticateWebSocketAsync();
}
return await new Promise((resolve, reject) => {
const id = createId();
const handler = (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as Record<string, unknown>;
if (data.msg !== "result") return;
if (data.id !== id) return;
clearTimeout(timeoutId);
webSocket.removeEventListener("message", handler);
logger.debug("Received method response", {
id,
method,
url: this.wsUrl(),
});
resolve(data.result);
};
const timeoutId = setTimeout(() => {
webSocket.removeEventListener("message", handler);
reject(
new RequestError(
{
type: "timeout",
reason: "aborted",
code: "ECONNABORTED",
},
{ cause: new Error("Canceled request after 5 seconds") },
),
);
}, 5000);
webSocket.addEventListener("message", handler);
logger.debug("Sending method request", {
id,
method,
url: this.wsUrl(),
});
webSocket.send(
JSON.stringify({
id,
msg: "method",
method,
params,
}),
);
});
private getClient(): TrueNasClient {
return (this.client ??= this.createClient());
}
private extractNetworkTrafficData = (data: z.infer<typeof reportingNetDataSchema>, index = 1 | 2) => {
private createClient(): TrueNasClient {
const credentials: TrueNasCredentials = this.hasSecretValue("apiKey")
? { apiKey: this.getSecretValue("apiKey") }
: { username: this.getSecretValue("username"), password: this.getSecretValue("password") };
return new TrueNasClient(this.integration.id, (path) => this.webSocketUrl(path), credentials);
}
private async requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
return this.getClient().requestAsync(method, params);
}
private async getPoolsAsync() {
logger.debug("Retrieving pools", { url: this.integration.url });
const response = await this.requestAsync("pool.query", [
[],
{
extra: {
is_upgraded: true,
},
},
]);
const result = await poolSchema.parseAsync(response);
// Offline / exported pools have null values for allocated, size and free, so we filter them out
// See https://github.com/homarr-labs/homarr/issues/5194
const activePools = result
.map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return {
...pool,
allocated: pool.allocated,
size: pool.size,
free: pool.free,
};
}
return null;
})
.filter((pool) => pool !== null);
logger.debug("Retrieved pools", {
url: this.integration.url,
totalCount: result.length,
activeCount: activePools.length,
});
return activePools;
}
/**
* Retrieves data using the reporting method
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingAsync(): Promise<ReportingItem[]> {
logger.debug("Retrieving reporting data", { url: this.integration.url });
const response = await this.requestAsync("reporting.get_data", [
[
{
name: "cpu",
},
{
name: "memory",
},
{
name: "cputemp",
},
],
{
aggregate: true,
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await z.array(reportingItemSchema).parseAsync(response);
logger.debug("Retrieved reporting data", {
url: this.integration.url,
count: result.length,
});
return result;
}
/**
* Retrieves a list of all available network interfaces
* @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface
*/
private async getNetworkInterfacesAsync(): Promise<z.infer<typeof networkInterfaceSchema>> {
logger.debug("Retrieving available network-interfaces", { url: this.integration.url });
const response = await this.requestAsync("interface.query", [
[], // no filters
{},
]);
const result = await networkInterfaceSchema.parseAsync(response);
logger.debug("Retrieved available network-interfaces", {
url: this.integration.url,
count: result.length,
});
return result;
}
/**
* Retrieves reporting network data of the last 5 minutes
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingNetdataAsync(): Promise<z.infer<typeof reportingNetDataSchema>> {
const networkInterfaces = await this.getNetworkInterfacesAsync();
logger.debug("Retrieving reporting network data", { url: this.integration.url });
const response = await this.requestAsync("reporting.netdata_get_data", [
networkInterfaces.map((networkInterface) => ({
name: "interface",
identifier: networkInterface.id,
})),
{
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await reportingNetDataSchema.parseAsync(response);
logger.debug("Retrieved reporting-network-data", {
url: this.integration.url,
count: result.length,
});
return result;
}
/**
* Retrieves information about the system
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#system
*/
private async getSystemInformationAsync(): Promise<z.infer<typeof systemInfoSchema>> {
logger.debug("Retrieving system-information", { url: this.integration.url });
const response = await this.requestAsync("system.info");
const result = await systemInfoSchema.parseAsync(response);
logger.debug("Retrieved system-information", { url: this.integration.url });
return result;
}
private extractNetworkTrafficData = (data: z.infer<typeof reportingNetDataSchema>, index: 1 | 2) => {
return data.reduce((acc, current) => acc + (current.data.at(-1)?.at(index) ?? 0), 0);
};
private extractLatestReportingData(data: ReportingItem[], key: ReportingItem["identifier"]) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const dataObject = data.find((item) => item.identifier === key)!;
// TODO: check why the below sorting is done, because right now it compares number[] with number[]?
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return dataObject.data.sort((item1, item2) => (item1 > item2 ? -1 : 1))[0]!;
private extractLatestReportingData(data: ReportingItem[], identifier: ReportingItem["identifier"]) {
const item = data.find((reportingItem) => reportingItem.identifier === identifier);
// Reporting rows are [timestamp, ...values] in ascending time order, so the last row is the most recent reading.
const latest = item?.data.at(-1);
if (!latest) throw new Error(`TrueNAS returned no "${identifier}" reporting data`);
return latest;
}
}
+1
View File
@@ -3,6 +3,7 @@ export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./interfaces/network-controller-summary/network-controller-summary-types";
export * from "./interfaces/firewall-summary/firewall-summary-types";
export * from "./interfaces/health-monitoring/health-monitoring-types";
export * from "./interfaces/ups-summary/ups-summary-types";
export * from "./interfaces/indexer-manager/indexer-manager-types";
export * from "./interfaces/media-requests/media-request-types";
export * from "./base/searchable-integration";
+8 -1
View File
@@ -1,6 +1,6 @@
import type { NotificationData } from "@mantine/notifications";
import { notifications } from "@mantine/notifications";
import { IconCheck, IconX } from "@tabler/icons-react";
import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
export const showSuccessNotification = (props: NotificationData) =>
notifications.show({
@@ -15,3 +15,10 @@ export const showErrorNotification = (props: NotificationData) =>
color: "red",
icon: <IconX size={20} />,
});
export const showWarningNotification = (props: NotificationData) =>
notifications.show({
...props,
color: "yellow",
icon: <IconInfoCircle size={20} />,
});
-2
View File
@@ -13,8 +13,6 @@ export {
handshakeAsync,
createSubPubChannel,
createGetSetChannel,
createQueryCacheChannel,
getAllQueryCacheAsync,
} from "./lib/channel";
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
-43
View File
@@ -125,49 +125,6 @@ export const createGetSetChannel = <TData>(name: string) => {
};
};
interface QueryCacheChannelOptions {
userId: string;
boardId: string;
key: string;
ttlMs: number;
maxValueBytes: number;
}
const queryCacheHashKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`;
export const createQueryCacheChannel = ({ userId, boardId, key, ttlMs, maxValueBytes }: QueryCacheChannelOptions) => {
const hkey = queryCacheHashKey(userId, boardId);
return {
name: `${hkey}:${key}`,
getAsync: async () => {
return await getSetClient.hget(hkey, key);
},
setAsync: async (value: string) => {
if (Buffer.byteLength(value, "utf8") > maxValueBytes) {
logger.warn("Query cache value exceeded maximum size", {
channel: hkey,
valueBytes: Buffer.byteLength(value, "utf8"),
maxValueBytes,
});
return false;
}
await getSetClient.hset(hkey, key, value);
await getSetClient.pexpire(hkey, ttlMs);
return true;
},
removeAsync: async () => {
await getSetClient.hdel(hkey, key);
},
};
};
export const getAllQueryCacheAsync = async (userId: string, boardId: string): Promise<Record<string, string>> => {
const hkey = queryCacheHashKey(userId, boardId);
return await getSetClient.hgetall(hkey);
};
/**
* Creates a new cache channel.
* @param name name of the channel
+158 -1
View File
@@ -9,7 +9,7 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import { extractContainerImageName } from "@homarr/definitions";
import type { ContainerState } from "@homarr/docker";
import type { ContainerState, Port } from "@homarr/docker";
import { dockerLabels, DockerSingleton } from "@homarr/docker";
import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
@@ -17,10 +17,167 @@ import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-ha
const logger = createLogger({ module: "dockerRequestHandler" });
const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
const port = (privatePort: number, publicPort: number, type: string): Port => ({
IP: "0.0.0.0",
PrivatePort: privatePort,
PublicPort: publicPort,
Type: type,
});
const mockContainers: {
id: string;
name: string;
host: string;
state: ContainerState;
image: string;
iconUrl: string;
cpuUsage: number;
memoryUsage: number;
ports: Port[];
}[] = [
{
id: "a1b2c3d4e5f6",
name: "sonarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/sonarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
cpuUsage: 2.3,
memoryUsage: 256 * 1024 * 1024,
ports: [port(8989, 8989, "tcp")],
},
{
id: "b2c3d4e5f6a7",
name: "radarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/radarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
cpuUsage: 1.8,
memoryUsage: 220 * 1024 * 1024,
ports: [port(7878, 7878, "tcp")],
},
{
id: "c3d4e5f6a7b8",
name: "plex",
host: "local",
state: "running",
image: "lscr.io/linuxserver/plex:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
cpuUsage: 12.5,
memoryUsage: 1024 * 1024 * 1024,
ports: [port(32400, 32400, "tcp")],
},
{
id: "d4e5f6a7b8c9",
name: "qbittorrent",
host: "local",
state: "running",
image: "lscr.io/linuxserver/qbittorrent:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
cpuUsage: 5.1,
memoryUsage: 380 * 1024 * 1024,
ports: [port(8080, 8080, "tcp")],
},
{
id: "e5f6a7b8c9d0",
name: "prowlarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/prowlarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
cpuUsage: 0.8,
memoryUsage: 120 * 1024 * 1024,
ports: [port(9696, 9696, "tcp")],
},
{
id: "f6a7b8c9d0e1",
name: "overseerr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/overseerr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
cpuUsage: 1.2,
memoryUsage: 180 * 1024 * 1024,
ports: [port(5055, 5055, "tcp")],
},
{
id: "a7b8c9d0e1f2",
name: "homarr",
host: "local",
state: "running",
image: "ghcr.io/homarr-labs/homarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
cpuUsage: 3.4,
memoryUsage: 290 * 1024 * 1024,
ports: [port(7575, 7575, "tcp")],
},
{
id: "b8c9d0e1f2a3",
name: "pihole",
host: "local",
state: "running",
image: "pihole/pihole:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
cpuUsage: 0.5,
memoryUsage: 95 * 1024 * 1024,
ports: [port(80, 80, "tcp"), port(53, 53, "udp")],
},
{
id: "c9d0e1f2a3b4",
name: "nginx-proxy",
host: "local",
state: "running",
image: "jc21/nginx-proxy-manager:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nginx-proxy-manager.svg",
cpuUsage: 0.3,
memoryUsage: 65 * 1024 * 1024,
ports: [port(443, 443, "tcp"), port(81, 81, "tcp")],
},
{
id: "d0e1f2a3b4c5",
name: "watchtower",
host: "local",
state: "running",
image: "containrrr/watchtower:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/watchtower.svg",
cpuUsage: 0.1,
memoryUsage: 30 * 1024 * 1024,
ports: [],
},
{
id: "e1f2a3b4c5d6",
name: "tdarr",
host: "local",
state: "exited",
image: "ghcr.io/haveagitgat/tdarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tdarr.svg",
cpuUsage: 0,
memoryUsage: 0,
ports: [],
},
{
id: "f2a3b4c5d6e7",
name: "bazarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/bazarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg",
cpuUsage: 0.6,
memoryUsage: 110 * 1024 * 1024,
ports: [port(6767, 6767, "tcp")],
},
];
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
queryKey: "dockerContainersResult",
widgetKind: "dockerContainers",
async requestAsync() {
if (isDemoMode) {
return mockContainers;
}
return await getContainersWithStatsAsync();
},
cacheDuration: dayjs.duration(20, "seconds"),
+20
View File
@@ -0,0 +1,20 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { UpsSummary } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const upsSummariesRequestHandler = createCachedIntegrationRequestHandler<
UpsSummary[],
IntegrationKindByCategory<"ups">,
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getUpsSummariesAsync();
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "upsSummaries",
});
@@ -88,7 +88,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
setMediaRequestSearchScope({ integrationIds });
setMode("media");
setChildrenStack([]);
clearChildrenStack();
setQuery(query ?? "");
spotlightActions.open();
setTimeout(() => selectAction(0, spotlightStore));
@@ -98,7 +98,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
return () => {
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
};
}, [setMediaRequestSearchScope, setMode, setQuery]);
}, [setMediaRequestSearchScope, setMode, setQuery, clearChildrenStack]);
useEffect(() => {
if (mode !== "media") {
@@ -55,8 +55,7 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
const { result } = option;
const hasAvailabilityLabel =
result.availability &&
(availabilityLabelKeys as readonly string[]).includes(result.availability);
result.availability && (availabilityLabelKeys as readonly string[]).includes(result.availability);
const badgeLabel = hasAvailabilityLabel
? tMedia(`availability.${result.availability as AvailabilityWithLabel}`)
: undefined;
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Contrasenya",
"suggestionsTitle": "",
"suggestion": {
"length": "Inclou com a mínim 8 caràcters",
"lowercase": "Inclou minúscules",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "密码",
"suggestionsTitle": "",
"suggestion": {
"length": "至少包含 8 个字符",
"lowercase": "包含小写字母",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "在新标签页中打开"
},
"withBorder": {
"label": ""
},
"items": {
"label": "书签",
"add": "添加书签"
+9 -5
View File
@@ -132,12 +132,13 @@
},
"password": {
"label": "crwdns26:0crwdne26:0",
"suggestionsTitle": "crwdns4261:0crwdne4261:0",
"suggestion": {
"length": "crwdns28:0crwdne28:0",
"lowercase": "crwdns30:0crwdne30:0",
"uppercase": "crwdns32:0crwdne32:0",
"number": "crwdns34:0crwdne34:0",
"special": "crwdns36:0crwdne36:0"
"length": "crwdns4263:0crwdne4263:0",
"lowercase": "crwdns4265:0crwdne4265:0",
"uppercase": "crwdns4267:0crwdne4267:0",
"number": "crwdns4269:0crwdne4269:0",
"special": "crwdns4271:0crwdne4271:0"
}
},
"passwordConfirm": {
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "crwdns2686:0crwdne2686:0"
},
"withBorder": {
"label": "crwdns4273:0crwdne4273:0"
},
"items": {
"label": "crwdns764:0crwdne764:0",
"add": "crwdns766:0crwdne766:0"
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Heslo",
"suggestionsTitle": "",
"suggestion": {
"length": "Obsahuje alespoň 8 znaků",
"lowercase": "Obsahuje malé písmeno",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Adgangskode",
"suggestionsTitle": "",
"suggestion": {
"length": "Inkluderer mindst 8 tegn",
"lowercase": "Inkluderer små bogstaver",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Åbn i nyt faneblad"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bogmærker",
"add": "Tilføj bogmærke"
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Passwort",
"suggestionsTitle": "",
"suggestion": {
"length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "In neuem Tab öffnen"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Lesezeichen",
"add": "Lesezeichen hinzufügen"
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Passwort",
"suggestionsTitle": "",
"suggestion": {
"length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "In neuem Tab öffnen"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Lesezeichen",
"add": "Lesezeichen hinzufügen"
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Κωδικός",
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "Περιλαμβάνει πεζό γράμμα",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Password",
"suggestionsTitle": "",
"suggestion": {
"length": "Includes a minimum of 8 characters",
"lowercase": "Includes a lower-case letter",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Open in new tab"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bookmarks",
"add": "Add bookmark"
+31
View File
@@ -2150,6 +2150,37 @@
"title": "Instances"
}
},
"ups": {
"name": "UPS",
"description": "Monitor the status of your UPS devices through a NUT server",
"option": {
"showBattery": {
"label": "Show battery"
},
"showLoad": {
"label": "Show load"
},
"showVoltage": {
"label": "Show voltage"
}
},
"status": {
"online": "Online",
"onBattery": "On battery",
"lowBattery": "Low battery",
"charging": "Charging",
"unknown": "Unknown"
},
"field": {
"runtime": "Runtime",
"load": "Load",
"input": "Input",
"output": "Output"
},
"error": {
"internalServerError": "Failed to fetch UPS data"
}
},
"tracearr": {
"name": "Tracearr",
"description": "Enhanced media server monitoring with detailed stream and transcoding data",
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Contraseña",
"suggestionsTitle": "",
"suggestion": {
"length": "Incluye al menos 8 caracteres",
"lowercase": "Incluye al menos una letra minúscula",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Abrir en una nueva pestaña"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Marcadores",
"add": "Añadir marcador"
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "",
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+5 -1
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Mot de passe",
"suggestionsTitle": "",
"suggestion": {
"length": "Contient au moins 8 caractères",
"lowercase": "Inclut une lettre minuscule",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Ouvrir dans un nouvel onglet"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Signets",
"add": "Ajouter un signet"
@@ -2088,7 +2092,7 @@
"empty": "",
"user": "Utilisateur",
"media": "Média",
"state": "",
"state": "État",
"quality": "Qualité",
"device": "Appareil",
"progress": "Progression",
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "סיסמה",
"suggestionsTitle": "",
"suggestion": {
"length": "כולל לפחות 8 תווים",
"lowercase": "אפשר אותיות קטנות",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "פתיחה בכרטיסיה חדשה"
},
"withBorder": {
"label": ""
},
"items": {
"label": "סימניות",
"add": "הוסף סימניה"
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Lozinka",
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "Uključuje mala slova",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Jelszó",
"suggestionsTitle": "",
"suggestion": {
"length": "Tartalmazzon legalább 8 karaktert",
"lowercase": "Tartalmazzon kisbetűt",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Megnyitás új lapon"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Könyjelzők",
"add": "Könyvjelző hozzáadása"
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Password",
"suggestionsTitle": "Suggerimenti password",
"suggestion": {
"length": "Include almeno 8 caratteri",
"lowercase": "Include lettera minuscola",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Apri in una nuova scheda"
},
"withBorder": {
"label": "Mostra bordo"
},
"items": {
"label": "Segnalibri",
"add": "Aggiungi segnalibro"
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "パスワード",
"suggestionsTitle": "",
"suggestion": {
"length": "少なくとも 8 文字以上を含む",
"lowercase": "小文字を含む",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "新しいタブで開く"
},
"withBorder": {
"label": ""
},
"items": {
"label": "ブックマーク",
"add": "ブックマークに追加"
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "비밀번호",
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "소문자 포함",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Slaptažodis",
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Parole",
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "Ietver mazo burtu",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Wachtwoord",
"suggestionsTitle": "",
"suggestion": {
"length": "Bevat ten minste 8 tekens",
"lowercase": "Inclusief kleine letter",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "In nieuw tabblad openen"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bladwijzers",
"add": "Bladwijzer toevoegen"
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Passord",
"suggestionsTitle": "",
"suggestion": {
"length": "Inkluderer minst 8 tegn",
"lowercase": "Inkluderer liten bokstav",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Åpne i ny fane"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bokmerke",
"add": "Legg til bokmerke"
+4
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Hasło",
"suggestionsTitle": "",
"suggestion": {
"length": "Zawiera co najmniej 8 znaków",
"lowercase": "Zawiera małą literę",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Otwórz w nowej karcie"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Zakładki",
"add": "Dodaj zakładkę"
+9 -5
View File
@@ -132,6 +132,7 @@
},
"password": {
"label": "Senha",
"suggestionsTitle": "",
"suggestion": {
"length": "Inclua pelo menos 8 caracteres",
"lowercase": "Inclui letras minúsculas",
@@ -1056,7 +1057,7 @@
}
},
"noResults": {
"title": ""
"title": "Nenhuma mídia enviada"
}
},
"common": {
@@ -1088,8 +1089,8 @@
"checkLogs": "Verifique os logs para mais detalhes",
"tryAgain": "Tente novamente",
"loading": "Carregando",
"show": "",
"hide": ""
"show": "Mostrar",
"hide": "Esconder"
},
"here": "aqui",
"iconPicker": {
@@ -1255,7 +1256,7 @@
"label": {
"create": "Nova categoria",
"changePosition": "Mudar de posição",
"settings": ""
"settings": "Configurações"
}
},
"openAllInNewTabs": {
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Abrir em nova guia"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Marcadores",
"add": "Adicionar marcador"
@@ -3459,7 +3463,7 @@
"private": "Este quadro é privado"
},
"noResults": {
"title": ""
"title": "Nenhum painel ainda"
},
"modal": {
"createBoard": {
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more