Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79cb9277e6 | ||
|
|
484b8cf9f1 | ||
|
|
b3cdab579b | ||
|
|
44a0c6ce54 | ||
|
|
7f05e0325d | ||
|
|
f71b145403 | ||
|
|
607fd498f4 | ||
|
|
3f2fb54cdf | ||
|
|
69412f2d10 | ||
|
|
f6d5f97933 | ||
|
|
a447a9f615 | ||
|
|
87d3f6a0a1 | ||
|
|
3d2c4979a6 | ||
|
|
79b27815fe | ||
|
|
f2705eeb57 | ||
|
|
76eacf8353 | ||
|
|
0c14e715a0 | ||
|
|
ac37d2c73f | ||
|
|
e00ea624cd | ||
|
|
20b62c42e3 | ||
|
|
59971991c7 | ||
|
|
a629681ae5 | ||
|
|
572d5fb5ec | ||
|
|
91a1858743 | ||
|
|
f00d3627c9 | ||
|
|
7760e01ccb | ||
|
|
8a1ead3674 | ||
|
|
9c05f44916 | ||
|
|
efd37dca93 | ||
|
|
796c679140 | ||
|
|
76fd4c5fb1 | ||
|
|
68f24e34d5 | ||
|
|
22d7ebab50 | ||
|
|
4f5f3e14a1 | ||
|
|
dbf021e04a | ||
|
|
cc24f070db | ||
|
|
0b663a7fc6 | ||
|
|
0dc2558faf | ||
|
|
dd7a4908a3 | ||
|
|
0a6467979a | ||
|
|
25d964a197 | ||
|
|
3d3e3059a9 | ||
|
|
9ae8cb2768 | ||
|
|
99346f288b | ||
|
|
ff1e4c1381 | ||
|
|
21bb20ce0c | ||
|
|
92b75cb924 | ||
|
|
1ea008b71c |
No files matched your search
@@ -33,6 +33,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
# The below comment is used to insert a new version with on-release.yml
|
# The below comment is used to insert a new version with on-release.yml
|
||||||
#NEXT_VERSION#
|
#NEXT_VERSION#
|
||||||
|
- 1.64.0
|
||||||
- 1.62.0
|
- 1.62.0
|
||||||
- 1.61.0
|
- 1.61.0
|
||||||
- 1.60.1-beta.1
|
- 1.60.1-beta.1
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[tools]
|
|
||||||
node = "24.16.0"
|
|
||||||
pnpm = "10.33.4"
|
|
||||||
@@ -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)
|
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -5,9 +5,21 @@ import "@homarr/common/env";
|
|||||||
import "@homarr/core/infrastructure/logs/env";
|
import "@homarr/core/infrastructure/logs/env";
|
||||||
import "@homarr/docker/env";
|
import "@homarr/docker/env";
|
||||||
|
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
|
const homarrVersion = (() => {
|
||||||
|
for (const candidate of ["../../package.json", "./package.json"]) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
||||||
|
if (pkg.name === "homarr") return pkg.version as string;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
})();
|
||||||
|
|
||||||
// Package path does not work... so we need to use relative path
|
// Package path does not work... so we need to use relative path
|
||||||
const withNextIntl = createNextIntlPlugin({
|
const withNextIntl = createNextIntlPlugin({
|
||||||
experimental: {
|
experimental: {
|
||||||
@@ -17,6 +29,9 @@ const withNextIntl = createNextIntlPlugin({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
env: {
|
||||||
|
HOMARR_VERSION: homarrVersion,
|
||||||
|
},
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// react compiler breaks mantine-react-table, so disabled for now
|
// react compiler breaks mantine-react-table, so disabled for now
|
||||||
@@ -27,7 +42,7 @@ const nextConfig: NextConfig = {
|
|||||||
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
||||||
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
||||||
*/
|
*/
|
||||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
|
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||||
turbopackFileSystemCacheForDev: true,
|
turbopackFileSystemCacheForDev: true,
|
||||||
|
|||||||
@@ -57,7 +57,6 @@
|
|||||||
"@mantine/modals": "catalog:",
|
"@mantine/modals": "catalog:",
|
||||||
"@mantine/tiptap": "catalog:",
|
"@mantine/tiptap": "catalog:",
|
||||||
"@tabler/icons-react": "catalog:",
|
"@tabler/icons-react": "catalog:",
|
||||||
"@tanstack/query-persist-client-core": "catalog:",
|
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-query-devtools": "catalog:",
|
"@tanstack/react-query-devtools": "catalog:",
|
||||||
"@tanstack/react-query-next-experimental": "catalog:",
|
"@tanstack/react-query-next-experimental": "catalog:",
|
||||||
@@ -73,9 +72,13 @@
|
|||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
|
"drizzle-orm": "catalog:",
|
||||||
"flag-icons": "catalog:",
|
"flag-icons": "catalog:",
|
||||||
"glob": "catalog:",
|
"glob": "catalog:",
|
||||||
|
"adm-zip": "catalog:",
|
||||||
|
"better-sqlite3": "catalog:",
|
||||||
"isomorphic-dompurify": "catalog:",
|
"isomorphic-dompurify": "catalog:",
|
||||||
|
"jszip": "catalog:",
|
||||||
"jotai": "catalog:",
|
"jotai": "catalog:",
|
||||||
"mantine-react-table": "catalog:",
|
"mantine-react-table": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
@@ -86,6 +89,7 @@
|
|||||||
"react-error-boundary": "catalog:",
|
"react-error-boundary": "catalog:",
|
||||||
"react-simple-code-editor": "catalog:",
|
"react-simple-code-editor": "catalog:",
|
||||||
"sass": "catalog:",
|
"sass": "catalog:",
|
||||||
|
"sql.js": "catalog:",
|
||||||
"superjson": "catalog:",
|
"superjson": "catalog:",
|
||||||
"swagger-ui-react": "catalog:",
|
"swagger-ui-react": "catalog:",
|
||||||
"use-deep-compare-effect": "catalog:",
|
"use-deep-compare-effect": "catalog:",
|
||||||
@@ -94,6 +98,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
|
"@types/adm-zip": "catalog:",
|
||||||
|
"@types/better-sqlite3": "catalog:",
|
||||||
"@types/chroma-js": "catalog:",
|
"@types/chroma-js": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/prismjs": "catalog:",
|
"@types/prismjs": "catalog:",
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
@@ -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 superjson from "superjson";
|
||||||
import type { SuperJSONResult } from "superjson";
|
import type { SuperJSONResult } from "superjson";
|
||||||
|
|
||||||
|
import { TRPCClientError } from "@trpc/client";
|
||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
|
|
||||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
|
import { showWarningNotification } from "@homarr/notifications";
|
||||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
|
||||||
|
|
||||||
const getWebSocketProtocol = () => {
|
const getWebSocketProtocol = () => {
|
||||||
// window is not defined on server side
|
// window is not defined on server side
|
||||||
@@ -52,14 +52,26 @@ const wsClient = createWSClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||||
const [queryPersister] = useState(() => createWidgetQueryPersister());
|
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
persister: queryPersister.persisterFn,
|
staleTime: 5 * 1000,
|
||||||
staleTime: queryCacheDefaultStaleTimeMs,
|
},
|
||||||
|
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 { 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 { env } from "@homarr/auth/env";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||||
|
|
||||||
|
import { env as appEnv } from "~/env";
|
||||||
|
|
||||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||||
import { LoginForm } from "./_login-form";
|
import { LoginForm } from "./_login-form";
|
||||||
|
|
||||||
@@ -37,6 +40,13 @@ export default async function Login(props: LoginProps) {
|
|||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</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">
|
<Card w={64 * 6} maw="90vw">
|
||||||
<LoginForm
|
<LoginForm
|
||||||
providers={env.AUTH_PROVIDERS}
|
providers={env.AUTH_PROVIDERS}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { cache } from "react";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
// Placed here because gridstack styles are used for board content
|
// Placed here because gridstack styles are used for board content
|
||||||
import "~/styles/gridstack.scss";
|
import "~/styles/gridstack.scss";
|
||||||
|
|
||||||
import type { DehydratedState } from "@tanstack/react-query";
|
|
||||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||||
import { parse } from "superjson";
|
|
||||||
|
|
||||||
import { getQueryClient } from "@homarr/api/server";
|
import { getQueryClient } from "@homarr/api/server";
|
||||||
import { IntegrationProvider } from "@homarr/auth/client";
|
import { IntegrationProvider } from "@homarr/auth/client";
|
||||||
@@ -18,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common";
|
|||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { getAllQueryCacheAsync } from "@homarr/redis";
|
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||||
|
|
||||||
@@ -39,13 +34,10 @@ interface Props<TParams extends Params> {
|
|||||||
export const createBoardContentPage = <TParams extends Record<string, unknown>>({
|
export const createBoardContentPage = <TParams extends Record<string, unknown>>({
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
}: Props<TParams>) => {
|
}: Props<TParams>) => {
|
||||||
const getCachedBoard = cache((_paramsKey: string, params: TParams) => getInitialBoard(params));
|
|
||||||
const getBoardForParams = (params: TParams) => getCachedBoard(JSON.stringify(params), params);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout: createBoardLayout({
|
layout: createBoardLayout({
|
||||||
headerActions: <BoardContentHeaderActions />,
|
headerActions: <BoardContentHeaderActions />,
|
||||||
getInitialBoardAsync: getBoardForParams,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
withTour: true,
|
withTour: true,
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
@@ -53,7 +45,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const queryClient = getQueryClient();
|
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 itemsMap = board.items.reduce((acc, item) => {
|
||||||
const existing = acc.get(item.kind);
|
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 (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<IntegrationProvider integrations={integrations}>
|
<IntegrationProvider integrations={integrations}>
|
||||||
<Suspense>
|
|
||||||
<QueryCacheHydration userId={userId} boardId={board.id} />
|
|
||||||
</Suspense>
|
|
||||||
<DynamicClientBoard />
|
<DynamicClientBoard />
|
||||||
</IntegrationProvider>
|
</IntegrationProvider>
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
@@ -95,7 +82,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
},
|
},
|
||||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||||
try {
|
try {
|
||||||
const board = await getBoardForParams(await params);
|
const board = await getInitialBoard(await params);
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -111,6 +98,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Ignore not found errors and return empty metadata
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
return {};
|
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 [isEditMode] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { hasChangeAccess } = useBoardPermissions(board);
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
|
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
||||||
|
|
||||||
if (!hasChangeAccess) {
|
if (!hasChangeAccess || isLoading) {
|
||||||
return <SelectBoardsMenu />;
|
return <SelectBoardsMenu />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,13 +55,15 @@ export const BoardContentHeaderActions = () => {
|
|||||||
<>
|
<>
|
||||||
{isEditMode && <AddMenu />}
|
{isEditMode && <AddMenu />}
|
||||||
|
|
||||||
<EditModeMenu />
|
<EditModeMenu demoMode={demoMode ?? false} />
|
||||||
|
|
||||||
<OnboardingTour.Target id="board-settings">
|
{!demoMode && (
|
||||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
<OnboardingTour.Target id="board-settings">
|
||||||
<IconSettings stroke={1.5} />
|
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||||
</HeaderButton>
|
<IconSettings stroke={1.5} />
|
||||||
</OnboardingTour.Target>
|
</HeaderButton>
|
||||||
|
</OnboardingTour.Target>
|
||||||
|
)}
|
||||||
|
|
||||||
<SelectBoardsMenu />
|
<SelectBoardsMenu />
|
||||||
</>
|
</>
|
||||||
@@ -155,7 +158,7 @@ const AddMenu = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditModeMenu = () => {
|
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
||||||
const [isEditMode, { open, close }] = useEditMode();
|
const [isEditMode, { open, close }] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const utils = clientApi.useUtils();
|
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(() => {
|
const toggle = useCallback(() => {
|
||||||
if (isEditMode) return saveBoard(board);
|
if (isEditMode) {
|
||||||
|
if (demoMode) return discardDemoChanges();
|
||||||
|
return saveBoard(board);
|
||||||
|
}
|
||||||
open();
|
open();
|
||||||
}, [board, isEditMode, saveBoard, open]);
|
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
|
||||||
|
|
||||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||||
usePreventLeaveWithDirty(isEditMode);
|
usePreventLeaveWithDirty(isEditMode);
|
||||||
|
|||||||
@@ -78,10 +78,7 @@ export const InitIntegrations = () => {
|
|||||||
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
|
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
|
||||||
const totalDetected = dockerIntegrations.length + dockerApps.length;
|
const totalDetected = dockerIntegrations.length + dockerApps.length;
|
||||||
|
|
||||||
const detectedKinds = useMemo(
|
const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]);
|
||||||
() => new Set(dockerIntegrations.map((i) => i.kind)),
|
|
||||||
[dockerIntegrations],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
|
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
|
||||||
const [appsExpanded, setAppsExpanded] = useState(false);
|
const [appsExpanded, setAppsExpanded] = useState(false);
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { Stack, Text } from "@mantine/core";
|
|||||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { getMantineColor } from "@homarr/common";
|
import { getMantineColor } from "@homarr/common";
|
||||||
|
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
import { InitStepCard } from "../../_components/init-step-card";
|
||||||
|
import { InitSqliteRestore } from "./sqlite-restore";
|
||||||
import { InitStartButton } from "./next-button";
|
import { InitStartButton } from "./next-button";
|
||||||
|
|
||||||
export const InitStart = async () => {
|
export const InitStart = async () => {
|
||||||
const tStart = await getScopedI18n("init.step.start");
|
const tStart = await getScopedI18n("init.step.start");
|
||||||
|
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InitStepCard>
|
<InitStepCard>
|
||||||
@@ -27,6 +30,7 @@ export const InitStart = async () => {
|
|||||||
>
|
>
|
||||||
{tStart("action.importOldmarr")}
|
{tStart("action.importOldmarr")}
|
||||||
</InitStartButton>
|
</InitStartButton>
|
||||||
|
{isSqlite && <InitSqliteRestore />}
|
||||||
</Stack>
|
</Stack>
|
||||||
</InitStepCard>
|
</InitStepCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Collapse, Stack, Text } from "@mantine/core";
|
||||||
|
import { IconDatabaseImport } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { getMantineColor } from "@homarr/common";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { DatabaseRestoreFlow } from "~/components/backup";
|
||||||
|
|
||||||
|
export const InitSqliteRestore = () => {
|
||||||
|
const t = useScopedI18n("init.step.start.action");
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
>
|
||||||
|
{t("backToOptions")}
|
||||||
|
</Button>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("restoreSqliteDescription")}
|
||||||
|
</Text>
|
||||||
|
<DatabaseRestoreFlow variant="standalone" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
variant="default"
|
||||||
|
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
|
||||||
|
>
|
||||||
|
{t("restoreSqlite")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -26,6 +26,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
|||||||
|
|
||||||
import { Analytics } from "~/components/layout/analytics";
|
import { Analytics } from "~/components/layout/analytics";
|
||||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||||
|
|
||||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
IconBrandTablerFilled,
|
IconBrandTablerFilled,
|
||||||
IconCertificate,
|
IconCertificate,
|
||||||
IconClipboardListFilled,
|
IconClipboardListFilled,
|
||||||
|
IconDatabaseExport,
|
||||||
IconDirectionsFilled,
|
IconDirectionsFilled,
|
||||||
IconGitFork,
|
IconGitFork,
|
||||||
IconHelpSquareRoundedFilled,
|
IconHelpSquareRoundedFilled,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
|
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||||
import { env } from "@homarr/docker/env";
|
import { env } from "@homarr/docker/env";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
@@ -154,6 +156,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/tools/tasks",
|
href: "/manage/tools/tasks",
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("items.tools.items.backup"),
|
||||||
|
icon: IconDatabaseExport,
|
||||||
|
href: "/manage/tools/backup",
|
||||||
|
hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation";
|
||||||
|
|
||||||
|
export const BackupExportCard = () => {
|
||||||
|
const t = useScopedI18n("management.page.tool.backup.export");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/backup/export");
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = await response.json().then(
|
||||||
|
(data) => data.error,
|
||||||
|
() => `Server returned ${response.status}`,
|
||||||
|
);
|
||||||
|
throw new Error(errorMessage ?? t("error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const filename = response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
|
||||||
|
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setShowConfirm(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("error"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="sm">
|
||||||
|
<IconDatabaseExport size={24} />
|
||||||
|
<Title order={4}>{t("title")}</Title>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("description")}
|
||||||
|
</Text>
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showConfirm ? (
|
||||||
|
<DangerousActionConfirmation
|
||||||
|
title={t("confirm.title")}
|
||||||
|
warningTitle={t("confirm.warningTitle")}
|
||||||
|
warningBody={t("confirm.warningBody")}
|
||||||
|
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
|
||||||
|
submitLabel={t("confirm.submit")}
|
||||||
|
submitIcon={<IconDownload size={16} />}
|
||||||
|
cancelLabel={t("confirm.cancel")}
|
||||||
|
onConfirm={handleExport}
|
||||||
|
onCancel={() => setShowConfirm(false)}
|
||||||
|
disabled={loading}
|
||||||
|
color="orange"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Group>
|
||||||
|
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
|
||||||
|
{t("button")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { IconDatabaseImport } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { DatabaseRestoreFlow } from "~/components/backup";
|
||||||
|
|
||||||
|
export const BackupImportCard = () => {
|
||||||
|
const t = useScopedI18n("management.page.tool.backup.import");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
<IconDatabaseImport size={24} />
|
||||||
|
<Title order={4}>{t("title")}</Title>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("description")}
|
||||||
|
</Text>
|
||||||
|
<DatabaseRestoreFlow />
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||||
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { createMetaTitle } from "~/metadata";
|
||||||
|
import { BackupExportCard } from "./_components/backup-export-card";
|
||||||
|
import { BackupImportCard } from "./_components/backup-import-card";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getScopedI18n("management.page.tool.backup");
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: createMetaTitle(t("metaTitle")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BackupPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getScopedI18n("management.page.tool.backup");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Title>{t("title")}</Title>
|
||||||
|
<BackupExportCard />
|
||||||
|
<BackupImportCard />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
-85
@@ -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>;
|
|
||||||
-84
@@ -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>;
|
|
||||||
-83
@@ -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>;
|
|
||||||
-69
@@ -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>;
|
|
||||||
-94
@@ -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 { auth } from "@homarr/auth/next";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
|
||||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { canAccessUserEditPage } from "../access";
|
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 { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||||
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
|
|
||||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||||
import { UserProfileForm } from "./_components/_profile-form";
|
|
||||||
import { ResetTours } from "./_components/_reset-tours";
|
import { ResetTours } from "./_components/_reset-tours";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -64,7 +59,7 @@ export default async function EditUserPage(props: Props) {
|
|||||||
|
|
||||||
const boards = await api.board.getAllBoards();
|
const boards = await api.board.getAllBoards();
|
||||||
const searchEngines = await api.searchEngine.getSelectable();
|
const searchEngines = await api.searchEngine.getSelectable();
|
||||||
|
const isSelf = session?.user.id === user.id;
|
||||||
const isCredentialsUser = user.provider === "credentials";
|
const isCredentialsUser = user.provider === "credentials";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,51 +69,25 @@ export default async function EditUserPage(props: Props) {
|
|||||||
{t("management.page.user.fieldsDisabledExternalProvider")}
|
{t("management.page.user.fieldsDisabledExternalProvider")}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Title>{tGeneral("title")}</Title>
|
<Title>{tGeneral("title")}</Title>
|
||||||
<Group gap="xl">
|
<Group gap="xl" align="flex-start" wrap="wrap">
|
||||||
<Box flex={1}>
|
<Box flex={1} miw={{ base: "100%", md: 540 }}>
|
||||||
<UserProfileForm user={user} />
|
<UserGeneralSettingsForm
|
||||||
|
user={user}
|
||||||
|
boardsData={boards.map((board) => ({
|
||||||
|
id: board.id,
|
||||||
|
name: board.name,
|
||||||
|
logoImageUrl: board.logoImageUrl,
|
||||||
|
}))}
|
||||||
|
searchEnginesData={searchEngines}
|
||||||
|
showLanguageSelector={isSelf}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box w={{ base: "100%", lg: 200 }}>
|
<Box w={{ base: "100%", lg: 260 }}>
|
||||||
<UserProfileAvatarForm user={user} />
|
<UserProfileAvatarForm user={user} />
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</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 && (
|
{session?.user.id === user.id && (
|
||||||
<Stack mb="lg">
|
<Stack mb="lg">
|
||||||
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
|
<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 { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
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 { NavigationLink } from "../groups/[id]/_navigation";
|
||||||
import { canAccessUserEditPage } from "./access";
|
import { canAccessUserEditPage } from "./access";
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const tUser = await getScopedI18n("management.page.user");
|
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)) {
|
if (!canAccessUserEditPage(session, user.id)) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
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 { canAccessUserEditPage } from "../access";
|
||||||
import { ChangePasswordForm } from "./_components/_change-password-form";
|
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||||
|
|
||||||
@@ -23,7 +23,8 @@ export default async function UserSecurityPage(props: Props) {
|
|||||||
.getById({
|
.getById({
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
})
|
})
|
||||||
.catch(catchTrpcNotFound);
|
.catch(catchTrpcNotFound)
|
||||||
|
.catch(catchTrpcUnauthorized);
|
||||||
|
|
||||||
if (!canAccessUserEditPage(session, user.id)) {
|
if (!canAccessUserEditPage(session, user.id)) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import AdmZip from "adm-zip";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { env } from "@homarr/common/env";
|
||||||
|
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbEnv.DRIVER !== "better-sqlite3") {
|
||||||
|
return NextResponse.json({ error: "SQLite backup is only available for SQLite databases" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = dbEnv.URL as string | undefined;
|
||||||
|
if (!dbPath || !fs.existsSync(dbPath)) {
|
||||||
|
return NextResponse.json({ error: "Database file not found" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempPath = path.join(path.dirname(dbPath), "db.backup.sqlite");
|
||||||
|
let sourceDb: InstanceType<typeof Database> | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
sourceDb = new Database(dbPath, { readonly: true });
|
||||||
|
sourceDb.pragma("wal_checkpoint(TRUNCATE)");
|
||||||
|
sourceDb.exec(`VACUUM INTO '${tempPath}'`);
|
||||||
|
sourceDb.close();
|
||||||
|
sourceDb = null;
|
||||||
|
|
||||||
|
const dbBuffer = fs.readFileSync(tempPath);
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
homarrVersion: process.env.HOMARR_VERSION ?? "unknown",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
dbDialect: "sqlite",
|
||||||
|
encryptionKey: env.SECRET_ENCRYPTION_KEY,
|
||||||
|
};
|
||||||
|
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile("db.sqlite", dbBuffer);
|
||||||
|
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata, null, 2)));
|
||||||
|
|
||||||
|
const zipBuffer = zip.toBuffer();
|
||||||
|
|
||||||
|
const date = new Date().toISOString().split("T")[0];
|
||||||
|
const filename = `homarr-backup-${date}.zip`;
|
||||||
|
|
||||||
|
return new NextResponse(zipBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/zip",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
"Content-Length": zipBuffer.length.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[backup/export] Export failed:", error);
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: `Export failed: ${message}` }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
sourceDb?.close();
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
console.error("[backup/export] Failed to clean up temp file:", cleanupErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import AdmZip from "adm-zip";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { env } from "@homarr/common/env";
|
||||||
|
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||||
|
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||||
|
import { db } from "@homarr/db";
|
||||||
|
|
||||||
|
import { findMigrationsFolder } from "../shared";
|
||||||
|
|
||||||
|
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
|
||||||
|
const ALGORITHM = "aes-256-cbc";
|
||||||
|
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
|
||||||
|
|
||||||
|
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
|
||||||
|
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
|
||||||
|
if (importedKeyHex === currentKeyHex) return;
|
||||||
|
|
||||||
|
if (!HEX_KEY_REGEX.test(importedKeyHex)) {
|
||||||
|
throw new Error("Imported encryption key is not a valid 64-character hex string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldKey = Buffer.from(importedKeyHex, "hex");
|
||||||
|
const newKey = Buffer.from(currentKeyHex, "hex");
|
||||||
|
|
||||||
|
const rows = tempDb.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"').all() as {
|
||||||
|
integration_id: string;
|
||||||
|
kind: string;
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
const updateStmt = tempDb.prepare('UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?');
|
||||||
|
const transaction = tempDb.transaction(() => {
|
||||||
|
for (const row of rows) {
|
||||||
|
const parts = row.value.split(".");
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
throw new Error(
|
||||||
|
`Malformed secret value for integration ${row.integration_id} (${row.kind}): expected "data.iv" format`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [data, dataIv] = parts as [string, string];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const iv = Buffer.from(dataIv, "hex");
|
||||||
|
const encrypted = Buffer.from(data, "hex");
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, oldKey, iv);
|
||||||
|
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
||||||
|
|
||||||
|
const newIv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, newKey, newIv);
|
||||||
|
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
|
||||||
|
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
|
||||||
|
|
||||||
|
updateStmt.run(newValue, row.integration_id, row.kind);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Failed to re-encrypt secret for integration ${row.integration_id} (${row.kind})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
transaction();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOnboardingActiveAsync = async (): Promise<boolean> => {
|
||||||
|
const onboardingRow = await db.query.onboarding.findFirst();
|
||||||
|
if (!onboardingRow) return false;
|
||||||
|
return onboardingRow.step === "start";
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||||
|
const isOnboarding = !isAdmin && (await isOnboardingActiveAsync());
|
||||||
|
|
||||||
|
if (!isAdmin && !isOnboarding) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbEnv.DRIVER !== "better-sqlite3") {
|
||||||
|
return NextResponse.json({ error: "SQLite restore is only available for SQLite databases" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = dbEnv.URL as string | undefined;
|
||||||
|
if (!dbPath) {
|
||||||
|
return NextResponse.json({ error: "Database path not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let formData: FormData;
|
||||||
|
try {
|
||||||
|
formData = await req.formData();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = formData.get("file");
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
let zip: AdmZip;
|
||||||
|
try {
|
||||||
|
zip = new AdmZip(Buffer.from(arrayBuffer));
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = zip.getEntries().map((e) => e.entryName);
|
||||||
|
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
|
||||||
|
if (missingEntries.length > 0) {
|
||||||
|
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrationsFolder = findMigrationsFolder();
|
||||||
|
if (!migrationsFolder) {
|
||||||
|
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite");
|
||||||
|
let tempDb: InstanceType<typeof Database> | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbEntry = zip.getEntry("db.sqlite")!;
|
||||||
|
fs.writeFileSync(tempPath, dbEntry.getData());
|
||||||
|
|
||||||
|
tempDb = new Database(tempPath);
|
||||||
|
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
|
||||||
|
migrate(drizzleDb, { migrationsFolder });
|
||||||
|
|
||||||
|
const metadataEntry = zip.getEntry("metadata.json")!;
|
||||||
|
const metadata = JSON.parse(metadataEntry.getData().toString());
|
||||||
|
|
||||||
|
const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim();
|
||||||
|
const importedKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined;
|
||||||
|
|
||||||
|
const secretCount = (
|
||||||
|
tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number }
|
||||||
|
).count;
|
||||||
|
|
||||||
|
if (secretCount > 0 && !importedKey) {
|
||||||
|
throw new Error(
|
||||||
|
"Backup contains integration secrets but no encryption key. " +
|
||||||
|
"Cannot restore without the original SECRET_ENCRYPTION_KEY.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importedKey) {
|
||||||
|
reEncryptSecrets(tempDb, importedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDb.close();
|
||||||
|
tempDb = null;
|
||||||
|
|
||||||
|
fs.renameSync(tempPath, dbPath);
|
||||||
|
|
||||||
|
for (const suffix of ["-wal", "-shm"] as const) {
|
||||||
|
try {
|
||||||
|
const sidecar = `${dbPath}${suffix}`;
|
||||||
|
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
console.error(`Failed to remove ${suffix} file:`, cleanupErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log("Database restored, restarting server...");
|
||||||
|
process.exit(0);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[backup/import] Restore failed:", error);
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
tempDb?.close();
|
||||||
|
if (fs.existsSync(tempPath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
} catch {
|
||||||
|
// temp file may have been renamed already
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { findMigrationsFolder } from "../shared";
|
||||||
|
|
||||||
|
interface JournalEntry {
|
||||||
|
idx: number;
|
||||||
|
version: string;
|
||||||
|
when: number;
|
||||||
|
tag: string;
|
||||||
|
breakpoints: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MigrationFile {
|
||||||
|
idx: number;
|
||||||
|
tag: string;
|
||||||
|
sql: string;
|
||||||
|
when: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const migrationsFolder = findMigrationsFolder();
|
||||||
|
if (!migrationsFolder) {
|
||||||
|
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const journalPath = path.join(migrationsFolder, "meta", "_journal.json");
|
||||||
|
if (!fs.existsSync(journalPath)) {
|
||||||
|
return NextResponse.json({ error: "Migration journal not found" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as { entries: JournalEntry[] };
|
||||||
|
|
||||||
|
const migrations: MigrationFile[] = [];
|
||||||
|
for (const entry of journal.entries) {
|
||||||
|
const sqlPath = path.join(migrationsFolder, `${entry.tag}.sql`);
|
||||||
|
if (!fs.existsSync(sqlPath)) continue;
|
||||||
|
|
||||||
|
migrations.push({
|
||||||
|
idx: entry.idx,
|
||||||
|
tag: entry.tag,
|
||||||
|
sql: fs.readFileSync(sqlPath, "utf-8"),
|
||||||
|
when: entry.when,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ total: migrations.length, migrations },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, max-age=3600",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[backup/migrations] Failed to read migrations:", error);
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const migrationsFolderCandidates = [
|
||||||
|
path.join(process.cwd(), "db/migrations/sqlite"),
|
||||||
|
path.join(process.cwd(), "packages/db/migrations/sqlite"),
|
||||||
|
path.resolve(process.cwd(), "../../db/migrations/sqlite"),
|
||||||
|
"/app/db/migrations/sqlite",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const findMigrationsFolder = () =>
|
||||||
|
migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const wasmCandidates = [
|
||||||
|
path.join(process.cwd(), "public/sql-wasm.wasm"),
|
||||||
|
path.join(process.cwd(), "apps/nextjs/public/sql-wasm.wasm"),
|
||||||
|
path.resolve(process.cwd(), "../../node_modules/sql.js/dist/sql-wasm.wasm"),
|
||||||
|
path.join(process.cwd(), "node_modules/sql.js/dist/sql-wasm.wasm"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let cachedWasm: Buffer | null = null;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
if (!cachedWasm) {
|
||||||
|
const found = wasmCandidates.find((p) => fs.existsSync(p));
|
||||||
|
if (!found) {
|
||||||
|
return NextResponse.json({ error: "WASM binary not found" }, { status: 500 });
|
||||||
|
}
|
||||||
|
cachedWasm = fs.readFileSync(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(cachedWasm, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/wasm",
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import AdmZip from "adm-zip";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||||
|
|
||||||
|
const ALGORITHM = "aes-256-cbc";
|
||||||
|
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
|
||||||
|
|
||||||
|
const generateHexKey = () => crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
const encryptWithKey = (text: string, keyHex: string): `${string}.${string}` => {
|
||||||
|
const key = Buffer.from(keyHex, "hex");
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||||
|
let encrypted = cipher.update(text);
|
||||||
|
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||||
|
return `${encrypted.toString("hex")}.${iv.toString("hex")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptWithKey = (value: `${string}.${string}`, keyHex: string): string => {
|
||||||
|
const key = Buffer.from(keyHex, "hex");
|
||||||
|
const [data, dataIv] = value.split(".") as [string, string];
|
||||||
|
const iv = Buffer.from(dataIv, "hex");
|
||||||
|
const encrypted = Buffer.from(data, "hex");
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
let decrypted = decipher.update(encrypted);
|
||||||
|
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||||
|
return decrypted.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestDb = (dbPath: string) => {
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
const db = drizzle(sqlite, { casing: DB_CASING });
|
||||||
|
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
|
||||||
|
return { db, sqlite };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SQLite backup", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-backup-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("export ZIP structure", () => {
|
||||||
|
it("should create a ZIP containing all required entries", () => {
|
||||||
|
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||||
|
const { sqlite } = createTestDb(dbPath);
|
||||||
|
|
||||||
|
const backupPath = path.join(tmpDir, "db.backup.sqlite");
|
||||||
|
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
||||||
|
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
||||||
|
sqlite.close();
|
||||||
|
|
||||||
|
const dbBuffer = fs.readFileSync(backupPath);
|
||||||
|
const metadata = {
|
||||||
|
homarrVersion: "test",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
dbDialect: "sqlite",
|
||||||
|
encryptionKey: generateHexKey(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile("db.sqlite", dbBuffer);
|
||||||
|
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata)));
|
||||||
|
|
||||||
|
const zipPath = path.join(tmpDir, "backup.zip");
|
||||||
|
zip.writeZip(zipPath);
|
||||||
|
|
||||||
|
const readBack = new AdmZip(zipPath);
|
||||||
|
const entryNames = readBack.getEntries().map((e) => e.entryName).sort();
|
||||||
|
expect(entryNames).toEqual(["db.sqlite", "metadata.json"]);
|
||||||
|
|
||||||
|
const dbEntry = readBack.getEntry("db.sqlite");
|
||||||
|
expect(dbEntry).toBeDefined();
|
||||||
|
expect(dbEntry!.header.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should produce a valid SQLite database in the ZIP", () => {
|
||||||
|
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||||
|
const { sqlite } = createTestDb(dbPath);
|
||||||
|
|
||||||
|
const backupPath = path.join(tmpDir, "db.backup.sqlite");
|
||||||
|
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
||||||
|
sqlite.close();
|
||||||
|
|
||||||
|
const backupDb = new Database(backupPath, { readonly: true });
|
||||||
|
const tables = backupDb
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||||
|
.all() as { name: string }[];
|
||||||
|
|
||||||
|
expect(tables.map((t) => t.name)).toContain("board");
|
||||||
|
expect(tables.map((t) => t.name)).toContain("user");
|
||||||
|
expect(tables.map((t) => t.name)).toContain("integrationSecret");
|
||||||
|
|
||||||
|
backupDb.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("import ZIP validation", () => {
|
||||||
|
it("should detect missing required entries", () => {
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
||||||
|
|
||||||
|
const entries = zip.getEntries().map((e) => e.entryName);
|
||||||
|
const required = ["db.sqlite", "metadata.json"];
|
||||||
|
const missing = required.filter((name) => !entries.includes(name));
|
||||||
|
|
||||||
|
expect(missing).toEqual(["metadata.json"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept valid backup ZIP with all entries", () => {
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
||||||
|
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite", encryptionKey: generateHexKey() })));
|
||||||
|
|
||||||
|
const entries = zip.getEntries().map((e) => e.entryName);
|
||||||
|
const required = ["db.sqlite", "metadata.json"];
|
||||||
|
const missing = required.filter((name) => !entries.includes(name));
|
||||||
|
|
||||||
|
expect(missing).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept legacy backup ZIP with encryption-key.txt", () => {
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
||||||
|
zip.addFile("encryption-key.txt", Buffer.from(generateHexKey()));
|
||||||
|
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite" })));
|
||||||
|
|
||||||
|
const entries = zip.getEntries().map((e) => e.entryName);
|
||||||
|
const required = ["db.sqlite", "metadata.json"];
|
||||||
|
const missing = required.filter((name) => !entries.includes(name));
|
||||||
|
|
||||||
|
expect(missing).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("secret re-encryption", () => {
|
||||||
|
it("should re-encrypt secrets from old key to new key", () => {
|
||||||
|
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||||
|
const { sqlite } = createTestDb(dbPath);
|
||||||
|
|
||||||
|
const oldKey = generateHexKey();
|
||||||
|
const newKey = generateHexKey();
|
||||||
|
const plaintext = "my-secret-password";
|
||||||
|
|
||||||
|
const encryptedValue = encryptWithKey(plaintext, oldKey);
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
INSERT INTO "integration" ("id", "name", "url", "kind")
|
||||||
|
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
|
||||||
|
`);
|
||||||
|
sqlite.exec(`
|
||||||
|
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
|
||||||
|
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
|
||||||
|
`);
|
||||||
|
|
||||||
|
const decryptedBefore = decryptWithKey(encryptedValue, oldKey);
|
||||||
|
expect(decryptedBefore).toBe(plaintext);
|
||||||
|
|
||||||
|
const oldKeyBuf = Buffer.from(oldKey, "hex");
|
||||||
|
const newKeyBuf = Buffer.from(newKey, "hex");
|
||||||
|
|
||||||
|
const rows = sqlite
|
||||||
|
.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"')
|
||||||
|
.all() as { integration_id: string; kind: string; value: string }[];
|
||||||
|
|
||||||
|
const updateStmt = sqlite.prepare(
|
||||||
|
'UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const [data, dataIv] = row.value.split(".") as [string, string];
|
||||||
|
const iv = Buffer.from(dataIv, "hex");
|
||||||
|
const encrypted = Buffer.from(data, "hex");
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, oldKeyBuf, iv);
|
||||||
|
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
||||||
|
|
||||||
|
const newIv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, newKeyBuf, newIv);
|
||||||
|
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
|
||||||
|
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
|
||||||
|
|
||||||
|
updateStmt.run(newValue, row.integration_id, row.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRow = sqlite
|
||||||
|
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
|
||||||
|
.get("int-1") as { value: string };
|
||||||
|
|
||||||
|
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
|
||||||
|
expect(decryptedAfter).toBe(plaintext);
|
||||||
|
|
||||||
|
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip re-encryption when keys match", () => {
|
||||||
|
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||||
|
const { sqlite } = createTestDb(dbPath);
|
||||||
|
|
||||||
|
const sameKey = generateHexKey();
|
||||||
|
const plaintext = "my-secret";
|
||||||
|
const encryptedValue = encryptWithKey(plaintext, sameKey);
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
INSERT INTO "integration" ("id", "name", "url", "kind")
|
||||||
|
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
|
||||||
|
`);
|
||||||
|
sqlite.exec(`
|
||||||
|
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
|
||||||
|
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
|
||||||
|
`);
|
||||||
|
|
||||||
|
const rowBefore = sqlite
|
||||||
|
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
|
||||||
|
.get("int-1") as { value: string };
|
||||||
|
|
||||||
|
expect(rowBefore.value).toBe(encryptedValue);
|
||||||
|
|
||||||
|
const decrypted = decryptWithKey(rowBefore.value as `${string}.${string}`, sameKey);
|
||||||
|
expect(decrypted).toBe(plaintext);
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VACUUM INTO consistency", () => {
|
||||||
|
it("should produce a consistent copy via VACUUM INTO", () => {
|
||||||
|
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||||
|
const { sqlite } = createTestDb(dbPath);
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
INSERT INTO "app" ("id", "name", "icon_url")
|
||||||
|
VALUES ('app-1', 'TestApp', 'https://example.com/icon.png')
|
||||||
|
`);
|
||||||
|
|
||||||
|
const backupPath = path.join(tmpDir, "db.vacuum.sqlite");
|
||||||
|
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
||||||
|
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
||||||
|
|
||||||
|
const backupDb = new Database(backupPath, { readonly: true });
|
||||||
|
const app = backupDb.prepare('SELECT "name" FROM "app" WHERE "id" = ?').get("app-1") as { name: string } | undefined;
|
||||||
|
|
||||||
|
expect(app).toBeDefined();
|
||||||
|
expect(app!.name).toBe("TestApp");
|
||||||
|
|
||||||
|
backupDb.close();
|
||||||
|
sqlite.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("migration on imported DB", () => {
|
||||||
|
it("should successfully migrate an imported database", () => {
|
||||||
|
const sourcePath = path.join(tmpDir, "source.sqlite");
|
||||||
|
const { sqlite: sourceDb } = createTestDb(sourcePath);
|
||||||
|
|
||||||
|
sourceDb.exec(`
|
||||||
|
INSERT INTO "board" ("id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius", "background_image_attachment", "background_image_repeat", "background_image_size", "disable_status")
|
||||||
|
VALUES ('board-1', 'TestBoard', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0)
|
||||||
|
`);
|
||||||
|
sourceDb.close();
|
||||||
|
|
||||||
|
const importPath = path.join(tmpDir, "import.sqlite");
|
||||||
|
fs.copyFileSync(sourcePath, importPath);
|
||||||
|
|
||||||
|
const importDb = new Database(importPath);
|
||||||
|
const drizzleDb = drizzle(importDb, { casing: DB_CASING });
|
||||||
|
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
|
||||||
|
|
||||||
|
const board = importDb.prepare('SELECT "name" FROM "board" WHERE "id" = ?').get("board-1") as { name: string } | undefined;
|
||||||
|
expect(board).toBeDefined();
|
||||||
|
expect(board!.name).toBe("TestBoard");
|
||||||
|
|
||||||
|
importDb.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("encryption key validation", () => {
|
||||||
|
it("should reject invalid hex keys", () => {
|
||||||
|
const invalidKeys = ["short", "not-hex-at-all-this-is-invalid-key-that-is-sixty-four-char!", ""];
|
||||||
|
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
|
||||||
|
|
||||||
|
for (const key of invalidKeys) {
|
||||||
|
expect(hexKeyRegex.test(key)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept valid 64-char hex keys", () => {
|
||||||
|
const validKey = generateHexKey();
|
||||||
|
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
|
||||||
|
expect(hexKeyRegex.test(validKey)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge, Group, Paper, Stack, Table, Text, ThemeIcon, Title } from "@mantine/core";
|
||||||
|
import { IconCheck, IconDatabase, IconGitBranch } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { BackupAnalysis } from "./types";
|
||||||
|
import { PREVIEW_TABLE_KEYS } from "./types";
|
||||||
|
|
||||||
|
interface BackupPreviewPanelProps {
|
||||||
|
analysis: BackupAnalysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
|
||||||
|
const t = useScopedI18n("management.page.tool.backup.restore.preview");
|
||||||
|
const exportDate = new Date(analysis.metadata.exportedAt).toLocaleString();
|
||||||
|
const pendingCount = analysis.migrations.pending.length;
|
||||||
|
const hasPendingMigrations = pendingCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Paper p="md" radius="md" withBorder>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon variant="light" color="blue" size="lg" radius="xl">
|
||||||
|
<IconDatabase size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={5}>{t("title")}</Title>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("exported", { date: exportDate })}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Badge variant="light" color="gray" ml="auto">
|
||||||
|
Homarr {analysis.metadata.homarrVersion}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Table striped withTableBorder>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t("entity")}</Table.Th>
|
||||||
|
<Table.Th ta="right">{t("count")}</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{PREVIEW_TABLE_KEYS.map((key) => (
|
||||||
|
<Table.Tr key={key}>
|
||||||
|
<Table.Td>{t(`entities.${key}` as never)}</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Group gap={4} justify="flex-end">
|
||||||
|
{(analysis.counts[key] ?? 0) > 0 && (
|
||||||
|
<IconCheck size={14} color="var(--mantine-color-green-6)" />
|
||||||
|
)}
|
||||||
|
<Text size="sm" ff="monospace">{analysis.counts[key] ?? 0}</Text>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{analysis.boardNames.length > 0 && (
|
||||||
|
<Group gap="xs" wrap="wrap">
|
||||||
|
<Text size="sm" fw={500}>{t("boards")}:</Text>
|
||||||
|
{analysis.boardNames.map((name) => (
|
||||||
|
<Badge key={name} variant="light" size="sm">{name}</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{hasPendingMigrations && (
|
||||||
|
<Paper p="md" radius="md" withBorder bd="1px solid var(--mantine-color-orange-8)">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon variant="light" color="orange" size="lg" radius="xl">
|
||||||
|
<IconGitBranch size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{pendingCount === 1
|
||||||
|
? t("migrationsApplied", { count: String(pendingCount) })
|
||||||
|
: t("migrationsAppliedPlural", { count: String(pendingCount) })}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{pendingCount === 1
|
||||||
|
? t("migrationsOlderVersion", { count: String(pendingCount) })
|
||||||
|
: t("migrationsOlderVersionPlural", { count: String(pendingCount) })}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Badge variant="light" color="orange" ml="auto">
|
||||||
|
{t("migrationsPending", { count: String(pendingCount) })}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasPendingMigrations && (
|
||||||
|
<Paper p="md" radius="md" withBorder>
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon variant="light" color="green" size="lg" radius="xl">
|
||||||
|
<IconGitBranch size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" fw={500} c="dimmed">
|
||||||
|
{t("migrationsUpToDate", { count: String(analysis.migrations.total) })}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, Box, Button, Group, Paper, Stack, Text, TextInput } from "@mantine/core";
|
||||||
|
import { IconAlertTriangle, IconShieldExclamation } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
const CONFIRMATION_PHRASE = "I understand";
|
||||||
|
|
||||||
|
interface DangerousActionConfirmationProps {
|
||||||
|
title: string;
|
||||||
|
warningTitle: string;
|
||||||
|
warningBody: string;
|
||||||
|
typePrompt: string;
|
||||||
|
submitLabel: string;
|
||||||
|
submitIcon: ReactNode;
|
||||||
|
cancelLabel: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DangerousActionConfirmation = ({
|
||||||
|
title,
|
||||||
|
warningTitle,
|
||||||
|
warningBody,
|
||||||
|
typePrompt,
|
||||||
|
submitLabel,
|
||||||
|
submitIcon,
|
||||||
|
cancelLabel,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
disabled,
|
||||||
|
color = "red",
|
||||||
|
}: DangerousActionConfirmationProps) => {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const isMatch = value.trim().toLowerCase() === CONFIRMATION_PHRASE.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper p="lg" radius="md" withBorder bd={`1px solid var(--mantine-color-${color}-9)`}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
<IconShieldExclamation size={24} color={`var(--mantine-color-${color}-6)`} />
|
||||||
|
<Text size="lg" fw={700} c={color}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Alert color={color} variant="light" icon={<IconAlertTriangle size={20} />}>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="sm" fw={600}>{warningTitle}</Text>
|
||||||
|
<Text size="sm">{warningBody}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={500} mb={4}>
|
||||||
|
{typePrompt}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.currentTarget.value)}
|
||||||
|
placeholder={CONFIRMATION_PHRASE}
|
||||||
|
disabled={disabled}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
fontFamily: "var(--mantine-font-family-monospace)",
|
||||||
|
borderColor: isMatch ? "var(--mantine-color-green-6)" : undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="subtle" color="gray" onClick={onCancel} disabled={disabled}>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color={color}
|
||||||
|
leftSection={submitIcon}
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={!isMatch || disabled}
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core";
|
||||||
|
import type { FileWithPath } from "@mantine/dropzone";
|
||||||
|
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
|
||||||
|
import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import "@mantine/dropzone/styles.css";
|
||||||
|
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { RestoreStep } from "./types";
|
||||||
|
import { BackupPreviewPanel } from "./backup-preview-panel";
|
||||||
|
import { MigrationProgressPanel } from "./migration-progress-panel";
|
||||||
|
import { RestoreConfirmation } from "./restore-confirmation";
|
||||||
|
import { RestoreProgressPanel } from "./restore-progress-panel";
|
||||||
|
import { useBackupAnalysis } from "./use-backup-analysis";
|
||||||
|
|
||||||
|
interface DatabaseRestoreFlowProps {
|
||||||
|
variant?: "card" | "standalone";
|
||||||
|
onRestoreComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => {
|
||||||
|
const t = useScopedI18n("management.page.tool.backup.restore");
|
||||||
|
const [file, setFile] = useState<FileWithPath | null>(null);
|
||||||
|
const [step, setStep] = useState<RestoreStep>("upload");
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
const apiDoneRef = useRef(false);
|
||||||
|
const animDoneRef = useRef(false);
|
||||||
|
const apiErrorRef = useRef<string | null>(null);
|
||||||
|
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
|
||||||
|
|
||||||
|
const handleFileDrop = useCallback(
|
||||||
|
(files: FileWithPath[]) => {
|
||||||
|
const droppedFile = files[0];
|
||||||
|
if (!droppedFile) return;
|
||||||
|
setFile(droppedFile);
|
||||||
|
setStep("preview");
|
||||||
|
setImportError(null);
|
||||||
|
void analyzeFile(droppedFile);
|
||||||
|
},
|
||||||
|
[analyzeFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
setFile(null);
|
||||||
|
setStep("upload");
|
||||||
|
setImportError(null);
|
||||||
|
apiDoneRef.current = false;
|
||||||
|
animDoneRef.current = false;
|
||||||
|
apiErrorRef.current = null;
|
||||||
|
reset();
|
||||||
|
}, [reset]);
|
||||||
|
|
||||||
|
const tryFinalize = useCallback(() => {
|
||||||
|
if (!apiDoneRef.current) return;
|
||||||
|
|
||||||
|
if (apiErrorRef.current) {
|
||||||
|
setImportError(apiErrorRef.current);
|
||||||
|
setStep("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!animDoneRef.current) return;
|
||||||
|
onRestoreComplete?.();
|
||||||
|
window.location.reload();
|
||||||
|
}, [onRestoreComplete]);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setStep("restoring");
|
||||||
|
setImportError(null);
|
||||||
|
apiDoneRef.current = false;
|
||||||
|
animDoneRef.current = false;
|
||||||
|
apiErrorRef.current = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const response = await fetch("/api/backup/import", { method: "POST", body: formData });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
apiErrorRef.current = data.error ?? t("failed.title");
|
||||||
|
} catch {
|
||||||
|
apiErrorRef.current = `Server returned ${response.status}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
apiErrorRef.current = t("failed.title");
|
||||||
|
} finally {
|
||||||
|
apiDoneRef.current = true;
|
||||||
|
tryFinalize();
|
||||||
|
}
|
||||||
|
}, [file, t, tryFinalize]);
|
||||||
|
|
||||||
|
const handleAnimationComplete = useCallback(() => {
|
||||||
|
animDoneRef.current = true;
|
||||||
|
tryFinalize();
|
||||||
|
}, [tryFinalize]);
|
||||||
|
|
||||||
|
if (step === "upload") {
|
||||||
|
return (
|
||||||
|
<Dropzone
|
||||||
|
onDrop={handleFileDrop}
|
||||||
|
acceptColor="blue.6"
|
||||||
|
rejectColor="red.6"
|
||||||
|
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
|
||||||
|
multiple={false}
|
||||||
|
maxSize={1024 * 1024 * 256}
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={variant === "standalone" ? 200 : 160} style={{ pointerEvents: "none" }}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
<div>
|
||||||
|
<Text size="xl" inline>
|
||||||
|
{t("dropzone.title")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" inline mt={7}>
|
||||||
|
{t("dropzone.description")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Dropzone>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === "preview") {
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group gap="sm">
|
||||||
|
<IconFileZip size={20} />
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500}>{file?.name}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Button variant="subtle" size="xs" onClick={handleClear}>
|
||||||
|
{t("changeFile")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{loading && migrationProgress && (
|
||||||
|
<MigrationProgressPanel progress={migrationProgress} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !migrationProgress && (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
<Text size="sm" c="dimmed">{t("analyzing")}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysisError && (
|
||||||
|
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("analysisError")}>
|
||||||
|
{analysisError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis && (
|
||||||
|
<>
|
||||||
|
<BackupPreviewPanel analysis={analysis} />
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
rightSection={<IconArrowRight size={16} />}
|
||||||
|
onClick={() => setStep("confirm")}
|
||||||
|
>
|
||||||
|
{t("continueToRestore")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === "confirm") {
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{analysis && <BackupPreviewPanel analysis={analysis} />}
|
||||||
|
<RestoreConfirmation
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onCancel={() => setStep("preview")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === "restoring") {
|
||||||
|
return (
|
||||||
|
<RestoreProgressPanel
|
||||||
|
active
|
||||||
|
migrations={analysis?.migrations.pending ?? []}
|
||||||
|
onComplete={handleAnimationComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === "error") {
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("failed.title")} radius="md">
|
||||||
|
<Text size="sm">{importError}</Text>
|
||||||
|
</Alert>
|
||||||
|
<Group>
|
||||||
|
<Button variant="subtle" onClick={handleClear}>
|
||||||
|
{t("tryAgain")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { DatabaseRestoreFlow } from "./database-restore-flow";
|
||||||
|
export { DangerousActionConfirmation } from "./dangerous-action-confirmation";
|
||||||
|
export type { BackupAnalysis, RestoreStep } from "./types";
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
||||||
|
import { IconCheck, IconDatabase, IconLoader } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { MigrationProgress } from "./use-backup-analysis";
|
||||||
|
|
||||||
|
interface MigrationProgressPanelProps {
|
||||||
|
progress: MigrationProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MigrationProgressPanel = ({ progress }: MigrationProgressPanelProps) => {
|
||||||
|
const t = useScopedI18n("management.page.tool.backup.restore.migration");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper p="md" radius="md" bg="var(--mantine-color-dark-7)">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
|
||||||
|
<IconDatabase size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{t("title", { current: String(progress.current), total: String(progress.total) })}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Box pl={4}>
|
||||||
|
<Timeline active={progress.current - 1} bulletSize={20} lineWidth={2} color="blue">
|
||||||
|
{Array.from({ length: progress.total }, (_, i) => {
|
||||||
|
const isDone = i < progress.current - 1 || (i === progress.current - 1 && progress.phase === "done");
|
||||||
|
const isActive = i === progress.current - 1 && progress.phase === "applying";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline.Item
|
||||||
|
key={i}
|
||||||
|
bullet={
|
||||||
|
isDone ? (
|
||||||
|
<IconCheck size={12} />
|
||||||
|
) : isActive ? (
|
||||||
|
<Loader size={10} color="white" />
|
||||||
|
) : (
|
||||||
|
<IconLoader size={12} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<Text size="xs" ff="monospace" c={isDone ? "dimmed" : isActive ? "white" : "dimmed"}>
|
||||||
|
{i === progress.current - 1 ? progress.tag : t("migrationLabel", { index: String(i + 1) })}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Timeline>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconDatabaseImport } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { DangerousActionConfirmation } from "./dangerous-action-confirmation";
|
||||||
|
|
||||||
|
interface RestoreConfirmationProps {
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RestoreConfirmation = ({ onConfirm, onCancel, disabled }: RestoreConfirmationProps) => {
|
||||||
|
const t = useScopedI18n("management.page.tool.backup.restore.confirm");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DangerousActionConfirmation
|
||||||
|
title={t("title")}
|
||||||
|
warningTitle={t("warningTitle")}
|
||||||
|
warningBody={t("warningBody")}
|
||||||
|
typePrompt={t("typePrompt", { phrase: "I understand" })}
|
||||||
|
submitLabel={t("submit")}
|
||||||
|
submitIcon={<IconDatabaseImport size={16} />}
|
||||||
|
cancelLabel={t("cancel")}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onCancel={onCancel}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconCheck,
|
||||||
|
IconDatabaseImport,
|
||||||
|
IconFileZip,
|
||||||
|
IconKey,
|
||||||
|
IconLoader,
|
||||||
|
IconRefresh,
|
||||||
|
IconTransform,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import type { Icon } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { MigrationFile } from "./types";
|
||||||
|
|
||||||
|
const BASE_PHASE_DURATIONS: Record<string, number> = {
|
||||||
|
extracting: 1200,
|
||||||
|
encrypting: 1500,
|
||||||
|
swapping: 800,
|
||||||
|
restarting: 10000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIGRATION_STEP_DURATION = 100;
|
||||||
|
|
||||||
|
interface TimelineStep {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: Icon;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RestoreProgressPanelProps {
|
||||||
|
active: boolean;
|
||||||
|
migrations: MigrationFile[];
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => {
|
||||||
|
const t = useScopedI18n("management.page.tool.backup.restore.progress");
|
||||||
|
const [currentStepIdx, setCurrentStepIdx] = useState(-1);
|
||||||
|
|
||||||
|
const steps = useMemo((): TimelineStep[] => {
|
||||||
|
const result: TimelineStep[] = [
|
||||||
|
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting! },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (migrations.length > 0) {
|
||||||
|
for (const migration of migrations) {
|
||||||
|
result.push({
|
||||||
|
key: `migration-${migration.idx}`,
|
||||||
|
label: migration.tag,
|
||||||
|
icon: IconDatabaseImport,
|
||||||
|
duration: MIGRATION_STEP_DURATION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push({
|
||||||
|
key: "migrating",
|
||||||
|
label: t("migrating"),
|
||||||
|
icon: IconDatabaseImport,
|
||||||
|
duration: 300,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(
|
||||||
|
{ key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting! },
|
||||||
|
{ key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping! },
|
||||||
|
{ key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting! },
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [migrations, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const run = async () => {
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setCurrentStepIdx(i);
|
||||||
|
await new Promise((r) => setTimeout(r, steps[i]!.duration));
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [active, onComplete, steps]);
|
||||||
|
|
||||||
|
if (!active && currentStepIdx === -1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper p="lg" radius="md" bg="var(--mantine-color-dark-7)">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Loader size="sm" color="blue" />
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{t("title")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Box pl={4}>
|
||||||
|
<Timeline
|
||||||
|
active={currentStepIdx}
|
||||||
|
bulletSize={24}
|
||||||
|
lineWidth={2}
|
||||||
|
color="blue"
|
||||||
|
>
|
||||||
|
{steps.map((step, i) => {
|
||||||
|
const isDone = i < currentStepIdx;
|
||||||
|
const isActive = i === currentStepIdx;
|
||||||
|
const isMigration = step.key.startsWith("migration-");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline.Item
|
||||||
|
key={step.key}
|
||||||
|
bullet={
|
||||||
|
isDone ? (
|
||||||
|
<ThemeIcon size={24} radius="xl" color="green" variant="filled">
|
||||||
|
<IconCheck size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
) : isActive ? (
|
||||||
|
<ThemeIcon size={24} radius="xl" color="blue" variant="filled">
|
||||||
|
<Loader size={12} color="white" />
|
||||||
|
</ThemeIcon>
|
||||||
|
) : (
|
||||||
|
<ThemeIcon size={24} radius="xl" color="gray" variant="light">
|
||||||
|
<IconLoader size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Group gap="xs" mt={-4}>
|
||||||
|
<step.icon size={16} color={isDone ? "var(--mantine-color-green-6)" : isActive ? "var(--mantine-color-blue-5)" : "var(--mantine-color-dimmed)"} />
|
||||||
|
<Text
|
||||||
|
size={isMigration ? "xs" : "sm"}
|
||||||
|
fw={isActive ? 600 : 400}
|
||||||
|
ff={isMigration ? "monospace" : undefined}
|
||||||
|
c={isDone ? "green" : isActive ? "white" : "dimmed"}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Timeline.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Timeline>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export interface BackupMetadata {
|
||||||
|
homarrVersion: string;
|
||||||
|
exportedAt: string;
|
||||||
|
dbDialect: string;
|
||||||
|
encryptionKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationFile {
|
||||||
|
idx: number;
|
||||||
|
tag: string;
|
||||||
|
sql: string;
|
||||||
|
when: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationStatus {
|
||||||
|
applied: number;
|
||||||
|
pending: MigrationFile[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupAnalysis {
|
||||||
|
metadata: BackupMetadata;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
boardNames: string[];
|
||||||
|
migrations: MigrationStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error";
|
||||||
|
|
||||||
|
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types";
|
||||||
|
import { PREVIEW_TABLE_KEYS } from "./types";
|
||||||
|
|
||||||
|
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const getAppliedMigrationCount = (db: any): number => {
|
||||||
|
try {
|
||||||
|
const result = db.exec(`SELECT COUNT(*) FROM "${DRIZZLE_MIGRATIONS_TABLE}"`);
|
||||||
|
return (result[0]?.values[0]?.[0] as number) ?? 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMigrations = async (): Promise<MigrationFile[]> => {
|
||||||
|
const res = await fetch("/api/backup/migrations");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch migration files from server");
|
||||||
|
const data = await res.json();
|
||||||
|
return data.migrations ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const countEntities = (db: any): Record<string, number> => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const key of PREVIEW_TABLE_KEYS) {
|
||||||
|
try {
|
||||||
|
const result = db.exec(`SELECT COUNT(*) FROM "${key}"`);
|
||||||
|
counts[key] = (result[0]?.values[0]?.[0] as number) ?? 0;
|
||||||
|
} catch {
|
||||||
|
counts[key] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBoardNames = (db: any): string[] => {
|
||||||
|
try {
|
||||||
|
const result = db.exec('SELECT "name" FROM "board" ORDER BY "name"');
|
||||||
|
return result[0]?.values.map((row: unknown[]) => row[0] as string) ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MigrationProgress {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
tag: string;
|
||||||
|
phase: "pending" | "applying" | "done" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBackupAnalysis = () => {
|
||||||
|
const t = useScopedI18n("management.page.tool.backup.restore");
|
||||||
|
const [analysis, setAnalysis] = useState<BackupAnalysis | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [migrationProgress, setMigrationProgress] = useState<MigrationProgress | null>(null);
|
||||||
|
const dbRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const analyzeFile = useCallback(async (file: File) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setAnalysis(null);
|
||||||
|
setMigrationProgress(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [JSZip, initSqlJs, allMigrations, wasmBinary] = await Promise.all([
|
||||||
|
import("jszip").then((m) => m.default),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
import("sql.js").then((m: any) => m.default),
|
||||||
|
fetchMigrations(),
|
||||||
|
fetch("/api/backup/sql-wasm").then((r) => {
|
||||||
|
if (!r.ok) throw new Error("Failed to load SQL WASM binary");
|
||||||
|
return r.arrayBuffer();
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||||
|
|
||||||
|
const metadataFile = zip.file("metadata.json");
|
||||||
|
if (!metadataFile) throw new Error(t("invalidMissingMetadata"));
|
||||||
|
const dbFile = zip.file("db.sqlite");
|
||||||
|
if (!dbFile) throw new Error(t("invalidMissingDb"));
|
||||||
|
|
||||||
|
const metadata = JSON.parse(await metadataFile.async("string"));
|
||||||
|
const dbBuffer = await dbFile.async("uint8array");
|
||||||
|
|
||||||
|
const SQL = await initSqlJs({ wasmBinary });
|
||||||
|
|
||||||
|
const db = new SQL.Database(dbBuffer);
|
||||||
|
dbRef.current = db;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appliedCount = getAppliedMigrationCount(db);
|
||||||
|
const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount);
|
||||||
|
|
||||||
|
const migrations: MigrationStatus = {
|
||||||
|
applied: appliedCount,
|
||||||
|
pending: pendingMigrations,
|
||||||
|
total: allMigrations.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pendingMigrations.length > 0) {
|
||||||
|
for (let i = 0; i < pendingMigrations.length; i++) {
|
||||||
|
const migration = pendingMigrations[i]!;
|
||||||
|
setMigrationProgress({
|
||||||
|
current: i + 1,
|
||||||
|
total: pendingMigrations.length,
|
||||||
|
tag: migration.tag,
|
||||||
|
phase: "applying",
|
||||||
|
});
|
||||||
|
|
||||||
|
await delay(100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statements = migration.sql
|
||||||
|
.split("--> statement-breakpoint")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (const stmt of statements) {
|
||||||
|
db.run(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`,
|
||||||
|
[migration.tag, Date.now()],
|
||||||
|
);
|
||||||
|
} catch (migrationErr) {
|
||||||
|
setMigrationProgress({
|
||||||
|
current: i + 1,
|
||||||
|
total: pendingMigrations.length,
|
||||||
|
tag: migration.tag,
|
||||||
|
phase: "error",
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMigrationProgress({
|
||||||
|
current: i + 1,
|
||||||
|
total: pendingMigrations.length,
|
||||||
|
tag: migration.tag,
|
||||||
|
phase: "done",
|
||||||
|
});
|
||||||
|
|
||||||
|
await delay(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = countEntities(db);
|
||||||
|
const boardNames = getBoardNames(db);
|
||||||
|
|
||||||
|
setMigrationProgress(null);
|
||||||
|
setAnalysis({ metadata, counts, boardNames, migrations });
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
dbRef.current = null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("analyzeError"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
if (dbRef.current) {
|
||||||
|
try { dbRef.current.close(); } catch { /* already closed */ }
|
||||||
|
dbRef.current = null;
|
||||||
|
}
|
||||||
|
setAnalysis(null);
|
||||||
|
setError(null);
|
||||||
|
setMigrationProgress(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { analysis, loading, error, migrationProgress, analyzeFile, reset };
|
||||||
|
};
|
||||||
@@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
|||||||
window.location.assign(logoutUrl);
|
window.location.assign(logoutUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.refresh();
|
router.push("/auth/login");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [logoutUrl, openModal, router]);
|
}, [logoutUrl, openModal, router]);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
|
|||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
server: {
|
server: {
|
||||||
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
||||||
|
DEMO_MODE: createBooleanSchema(false),
|
||||||
},
|
},
|
||||||
experimental__runtimeEnv: process.env,
|
experimental__runtimeEnv: process.env,
|
||||||
});
|
});
|
||||||
Vendored
+1
@@ -2,3 +2,4 @@ declare module "*.png";
|
|||||||
declare module "*.svg";
|
declare module "*.svg";
|
||||||
declare module "*.jpeg";
|
declare module "*.jpeg";
|
||||||
declare module "*.jpg";
|
declare module "*.jpg";
|
||||||
|
declare module "sql.js";
|
||||||
+20
-10
@@ -19,6 +19,9 @@
|
|||||||
|
|
||||||
<!-- Links -->
|
<!-- Links -->
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
<a href="https://demo.homarr.dev">
|
||||||
|
<strong>Live Demo 🎮</strong>
|
||||||
|
</a> •
|
||||||
<a href="https://homarr.dev/docs/category/installation-1/">
|
<a href="https://homarr.dev/docs/category/installation-1/">
|
||||||
<strong>Install 💻</strong>
|
<strong>Install 💻</strong>
|
||||||
</a> •
|
</a> •
|
||||||
@@ -246,13 +249,20 @@
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<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">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">Pi-hole</p>
|
<p align="center">Pi-hole</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -293,15 +303,15 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Radarr</p>
|
<p align="center">Radarr</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/readarr" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg" alt="Readarr" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">Readarr</p>
|
<p align="center">Readarr</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -342,15 +352,15 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Speedtest<br/>Tracker</p>
|
<p align="center">Speedtest<br/>Tracker</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">Tdarr</p>
|
<p align="center">Tdarr</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
client_max_body_size 32M;
|
client_max_body_size 256M;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homarr",
|
"name": "homarr",
|
||||||
"version": "1.61.1",
|
"version": "1.64.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env CI=true turbo build",
|
"build": "cross-env CI=true turbo build",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@babel/helpers@<7.26.10": ">=7.29.7",
|
"@babel/helpers@<7.26.10": ">=7.29.7",
|
||||||
"@babel/runtime@<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@>=2.0.0 <=2.0.1": ">=5.0.6",
|
||||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
|
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
|
||||||
"esbuild@<=0.24.2": ">=0.28.0",
|
"esbuild@<=0.24.2": ">=0.28.0",
|
||||||
@@ -88,13 +88,13 @@
|
|||||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
|
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
|
||||||
"prismjs@<1.30.0": ">=1.30.0",
|
"prismjs@<1.30.0": ">=1.30.0",
|
||||||
"proxmox-api>undici": "7.24.6",
|
"proxmox-api>undici": "7.24.6",
|
||||||
"react-is": "^19.2.6",
|
"react-is": "^19.2.7",
|
||||||
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
|
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
|
||||||
"sha.js@<=2.4.11": ">=2.4.12",
|
"sha.js@<=2.4.11": ">=2.4.12",
|
||||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||||
"tmp@<=0.2.3": ">=0.2.7",
|
"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": {
|
"patchedDependencies": {
|
||||||
"@types/node-unifi": "patches/@types__node-unifi.patch",
|
"@types/node-unifi": "patches/@types__node-unifi.patch",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./client": "./src/client.ts",
|
"./client": "./src/client.ts",
|
||||||
"./query-cache": "./src/query-cache.ts",
|
|
||||||
"./server": "./src/server.ts",
|
"./server": "./src/server.ts",
|
||||||
"./websocket": "./src/websocket.ts",
|
"./websocket": "./src/websocket.ts",
|
||||||
"./shared": "./src/shared.ts",
|
"./shared": "./src/shared.ts",
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { createEnv } from "@homarr/core/infrastructure/env";
|
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
|
||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
server: {
|
server: {
|
||||||
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
|
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
|
||||||
|
DEMO_MODE: createBooleanSchema(false),
|
||||||
},
|
},
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
|
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
|
||||||
|
DEMO_MODE: process.env.DEMO_MODE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
|
|||||||
|
|
||||||
import { env } from "@homarr/docker/env";
|
import { env } from "@homarr/docker/env";
|
||||||
|
|
||||||
import { publicProcedure } from "../trpc";
|
import { isDemoMode, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
export const dockerMiddleware = () => {
|
export const dockerMiddleware = () => {
|
||||||
return publicProcedure.use(async ({ next }) => {
|
return publicProcedure.use(async ({ next }) => {
|
||||||
if (env.ENABLE_DOCKER) {
|
if (env.ENABLE_DOCKER || isDemoMode) {
|
||||||
return await next();
|
return await next();
|
||||||
}
|
}
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -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("."));
|
|
||||||
};
|
|
||||||
@@ -24,7 +24,6 @@ export const appRouter = createTRPCRouter({
|
|||||||
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
|
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
|
||||||
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
|
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
|
||||||
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
|
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)),
|
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
|
||||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
||||||
|
|||||||
@@ -1604,22 +1604,24 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
|||||||
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
items: items.map(({ integrations: itemIntegrations, ...item }) =>
|
items: items
|
||||||
parseItem({
|
.map(({ integrations: itemIntegrations, ...item }) =>
|
||||||
...item,
|
parseItem({
|
||||||
layouts: item.layouts.map((layout) => ({
|
...item,
|
||||||
xOffset: layout.xOffset,
|
layouts: item.layouts.map((layout) => ({
|
||||||
yOffset: layout.yOffset,
|
xOffset: layout.xOffset,
|
||||||
width: layout.width,
|
yOffset: layout.yOffset,
|
||||||
height: layout.height,
|
width: layout.width,
|
||||||
layoutId: layout.layoutId,
|
height: layout.height,
|
||||||
sectionId: layout.sectionId,
|
layoutId: layout.layoutId,
|
||||||
})),
|
sectionId: layout.sectionId,
|
||||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
})),
|
||||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||||
}),
|
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||||
),
|
}),
|
||||||
|
)
|
||||||
|
.filter((item) => item !== null),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1635,7 +1637,7 @@ const parseItem = (item: unknown) => {
|
|||||||
const result = outputItemSchema.safeParse(item);
|
const result = outputItemSchema.safeParse(item);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error.message);
|
return null;
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import z from "zod/v4";
|
import z from "zod/v4";
|
||||||
|
|
||||||
import packageJson from "../../../../package.json";
|
import packageJson from "../../../../package.json";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
export const infoRouter = createTRPCRouter({
|
export const infoRouter = createTRPCRouter({
|
||||||
getInfo: protectedProcedure
|
getInfo: protectedProcedure
|
||||||
@@ -13,4 +13,5 @@ export const infoRouter = createTRPCRouter({
|
|||||||
version: packageJson.version,
|
version: packageJson.version,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
isDemoMode: publicProcedure.query(() => isDemoMode),
|
||||||
});
|
});
|
||||||
@@ -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();
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
import { convertIntersectionToZodObject } from "../schema-merger";
|
||||||
import {
|
import {
|
||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
|
isDemoMode,
|
||||||
onboardingProcedure,
|
onboardingProcedure,
|
||||||
permissionRequiredProcedure,
|
permissionRequiredProcedure,
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
@@ -629,6 +630,13 @@ export const userRouter = createTRPCRouter({
|
|||||||
.where(eq(users.id, ctx.session.user.id));
|
.where(eq(users.id, ctx.session.user.id));
|
||||||
}),
|
}),
|
||||||
getTourStatus: protectedProcedure.query(async ({ ctx }) => {
|
getTourStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
if (isDemoMode) {
|
||||||
|
return {
|
||||||
|
completedManageTour: true,
|
||||||
|
completedBoardTour: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const user = await ctx.db.query.users.findFirst({
|
const user = await ctx.db.query.users.findFirst({
|
||||||
columns: {
|
columns: {
|
||||||
completedManageTour: true,
|
completedManageTour: true,
|
||||||
|
|||||||
@@ -31,4 +31,5 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
|
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
|
||||||
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
|
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
|
||||||
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
||||||
|
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
||||||
});
|
});
|
||||||
@@ -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,7 +1,5 @@
|
|||||||
import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
|
import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { queryCacheDefaultStaleTimeMs } from "./query-cache";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a headers callback for a given source
|
* Creates a headers callback for a given source
|
||||||
* It will set the x-trpc-source header and cookies if needed
|
* It will set the x-trpc-source header and cookies if needed
|
||||||
@@ -60,7 +58,7 @@ export const makeQueryClient = () => {
|
|||||||
return new QueryClient({
|
return new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: queryCacheDefaultStaleTimeMs,
|
staleTime: 5 * 1000,
|
||||||
},
|
},
|
||||||
dehydrate: {
|
dehydrate: {
|
||||||
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending",
|
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending",
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -18,6 +18,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
|||||||
import { db } from "@homarr/db";
|
import { db } from "@homarr/db";
|
||||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { env } from "./env";
|
||||||
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
|
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
|
||||||
|
|
||||||
const logger = createLogger({ module: "trpc" });
|
const logger = createLogger({ module: "trpc" });
|
||||||
@@ -87,6 +88,20 @@ export const createCallerFactory = t.createCallerFactory;
|
|||||||
*/
|
*/
|
||||||
export const createTRPCRouter = t.router;
|
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
|
* 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
|
* 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
|
* 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 }) => {
|
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||||
if (!ctx.session?.user) {
|
if (!ctx.session?.user) {
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
}
|
}
|
||||||
return next({
|
return next({
|
||||||
ctx: {
|
ctx: {
|
||||||
// infers the `session` as non-nullable
|
|
||||||
session: { ...ctx.session, user: ctx.session.user },
|
session: { ...ctx.session, user: ctx.session.user },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -121,7 +131,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
|||||||
*
|
*
|
||||||
* @see https://trpc.io/docs/procedures
|
* @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
|
* Procedure that requires a specific permission
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { usePathname } from "next/navigation";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache";
|
|
||||||
|
|
||||||
import { updateBoardName } from "./updater";
|
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
|
// Update the board name so it can be used within updateBoard method
|
||||||
updateBoardName(initialBoard.name);
|
updateBoardName(initialBoard.name);
|
||||||
|
|
||||||
@@ -45,16 +42,6 @@ export const BoardProvider = ({
|
|||||||
};
|
};
|
||||||
}, [pathname, utils, initialBoard.name]);
|
}, [pathname, utils, initialBoard.name]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveQueryCacheBoardId(data.id);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (getActiveQueryCacheBoardId() === data.id) {
|
|
||||||
setActiveQueryCacheBoardId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [data.id]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoardContext.Provider
|
<BoardContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -86,7 +73,7 @@ export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
if (typeof window === "undefined") return board.layouts.at(0)!.id;
|
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
|
// Fallback to smallest if none exists with breakpoint smaller than window width
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
import { createId, objectKeys } from "@homarr/common";
|
import { createId, objectKeys } from "@homarr/common";
|
||||||
import {
|
import {
|
||||||
createDocumentationLink,
|
createDocumentationLink,
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
integrationDefs,
|
integrationDefs,
|
||||||
integrationKinds,
|
integrationKinds,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||||
|
|
||||||
import type { Database } from "..";
|
import type { Database } from "..";
|
||||||
@@ -19,13 +22,16 @@ import {
|
|||||||
insertServerSettingByKeyAsync,
|
insertServerSettingByKeyAsync,
|
||||||
updateServerSettingByKeyAsync,
|
updateServerSettingByKeyAsync,
|
||||||
} from "../queries/server-setting";
|
} from "../queries/server-setting";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
apps,
|
apps,
|
||||||
boards,
|
boards,
|
||||||
groupMembers,
|
groupMembers,
|
||||||
groupPermissions,
|
groupPermissions,
|
||||||
groups,
|
groups,
|
||||||
|
integrationItems,
|
||||||
integrations,
|
integrations,
|
||||||
|
itemLayouts,
|
||||||
items,
|
items,
|
||||||
layouts,
|
layouts,
|
||||||
onboarding,
|
onboarding,
|
||||||
@@ -35,7 +41,16 @@ import {
|
|||||||
} from "../schema";
|
} from "../schema";
|
||||||
import type { Integration } 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) => {
|
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 seedEveryoneGroupAsync(db);
|
||||||
await seedOnboardingAsync(db);
|
await seedOnboardingAsync(db);
|
||||||
await seedServerSettingsAsync(db);
|
await seedServerSettingsAsync(db);
|
||||||
@@ -45,7 +60,7 @@ export const seedDataAsync = async (db: Database) => {
|
|||||||
await seedDefaultBoardAsync(db);
|
await seedDefaultBoardAsync(db);
|
||||||
await seedBoardWidgetsAsync(db);
|
await seedBoardWidgetsAsync(db);
|
||||||
|
|
||||||
if (process.env.DEMO_MODE === "true") {
|
if (isTruthyEnv(process.env.DEMO_MODE)) {
|
||||||
await seedDemoUserAsync(db);
|
await seedDemoUserAsync(db);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -273,6 +288,134 @@ const seedDefaultBoardAsync = async (db: Database) => {
|
|||||||
console.log("Created default board 'dashboard' through seed");
|
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 seedDemoUserAsync = async (db: Database) => {
|
||||||
const existingUser = await db.query.users.findFirst({
|
const existingUser = await db.query.users.findFirst({
|
||||||
where: eq(users.name, "demo"),
|
where: eq(users.name, "demo"),
|
||||||
@@ -289,7 +432,7 @@ const seedDemoUserAsync = async (db: Database) => {
|
|||||||
id: userId,
|
id: userId,
|
||||||
name: "demo",
|
name: "demo",
|
||||||
email: "demo@example.com",
|
email: "demo@example.com",
|
||||||
password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2",
|
password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxPosition = await getMaxGroupPositionAsync(db);
|
const maxPosition = await getMaxGroupPositionAsync(db);
|
||||||
@@ -314,13 +457,93 @@ const seedDemoUserAsync = async (db: Database) => {
|
|||||||
previousStep: "settings",
|
previousStep: "settings",
|
||||||
});
|
});
|
||||||
|
|
||||||
const board = await db.query.boards.findFirst();
|
const integrationId = createId();
|
||||||
if (board) {
|
await db.insert(integrations).values({
|
||||||
await db.update(users).set({ homeBoardId: board.id }).where(eq(users.id, userId));
|
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(
|
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/anchor"
|
||||||
| "/docs/integrations/aria2"
|
| "/docs/integrations/aria2"
|
||||||
| "/docs/integrations/codeberg"
|
| "/docs/integrations/codeberg"
|
||||||
|
| "/docs/integrations/coolify"
|
||||||
| "/docs/integrations/dash-dot"
|
| "/docs/integrations/dash-dot"
|
||||||
| "/docs/integrations/deluge"
|
| "/docs/integrations/deluge"
|
||||||
| "/docs/integrations/docker-hub"
|
| "/docs/integrations/docker-hub"
|
||||||
@@ -181,6 +182,7 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/open-media-vault"
|
| "/docs/integrations/open-media-vault"
|
||||||
| "/docs/integrations/opnsense"
|
| "/docs/integrations/opnsense"
|
||||||
| "/docs/integrations/overseerr"
|
| "/docs/integrations/overseerr"
|
||||||
|
| "/docs/integrations/peanut"
|
||||||
| "/docs/integrations/pi-hole"
|
| "/docs/integrations/pi-hole"
|
||||||
| "/docs/integrations/plex"
|
| "/docs/integrations/plex"
|
||||||
| "/docs/integrations/prowlarr"
|
| "/docs/integrations/prowlarr"
|
||||||
@@ -217,6 +219,7 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/widgets/bookmarks"
|
| "/docs/widgets/bookmarks"
|
||||||
| "/docs/widgets/calendar"
|
| "/docs/widgets/calendar"
|
||||||
| "/docs/widgets/clock"
|
| "/docs/widgets/clock"
|
||||||
|
| "/docs/widgets/coolify"
|
||||||
| "/docs/widgets/dns-hole-controls"
|
| "/docs/widgets/dns-hole-controls"
|
||||||
| "/docs/widgets/dns-hole-summary"
|
| "/docs/widgets/dns-hole-summary"
|
||||||
| "/docs/widgets/docker-containers"
|
| "/docs/widgets/docker-containers"
|
||||||
@@ -247,6 +250,7 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/widgets/timetable"
|
| "/docs/widgets/timetable"
|
||||||
| "/docs/widgets/tracearr"
|
| "/docs/widgets/tracearr"
|
||||||
| "/docs/widgets/umami"
|
| "/docs/widgets/umami"
|
||||||
|
| "/docs/widgets/ups"
|
||||||
| "/docs/widgets/video"
|
| "/docs/widgets/video"
|
||||||
| "/docs/widgets/weather"
|
| "/docs/widgets/weather"
|
||||||
| ""
|
| ""
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ export const integrationDefs = {
|
|||||||
},
|
},
|
||||||
truenas: {
|
truenas: {
|
||||||
name: "TrueNAS",
|
name: "TrueNAS",
|
||||||
secretKinds: [["username", "password"]],
|
secretKinds: [["username", "password"], ["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
|
||||||
category: ["healthMonitoring"],
|
category: ["healthMonitoring"],
|
||||||
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
|
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
|
||||||
@@ -383,7 +383,6 @@ export const integrationDefs = {
|
|||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg",
|
||||||
category: ["healthMonitoring"],
|
category: ["healthMonitoring"],
|
||||||
// @ts-expect-error - docs page will be created when integration is merged
|
|
||||||
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
|
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
|
||||||
defaultPort: 8000,
|
defaultPort: 8000,
|
||||||
},
|
},
|
||||||
@@ -427,6 +426,13 @@ export const integrationDefs = {
|
|||||||
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
|
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
|
||||||
defaultPort: 3000,
|
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)
|
// 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: {
|
mock: {
|
||||||
name: "Mock",
|
name: "Mock",
|
||||||
@@ -445,6 +451,7 @@ export const integrationDefs = {
|
|||||||
"networkController",
|
"networkController",
|
||||||
"notifications",
|
"notifications",
|
||||||
"smartHomeServer",
|
"smartHomeServer",
|
||||||
|
"ups",
|
||||||
],
|
],
|
||||||
documentationUrl: null,
|
documentationUrl: null,
|
||||||
},
|
},
|
||||||
@@ -532,6 +539,7 @@ export const integrationCategories = [
|
|||||||
"mediaMonitoring",
|
"mediaMonitoring",
|
||||||
"speedtest",
|
"speedtest",
|
||||||
"analytics",
|
"analytics",
|
||||||
|
"ups",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type IntegrationCategory = (typeof integrationCategories)[number];
|
export type IntegrationCategory = (typeof integrationCategories)[number];
|
||||||
@@ -38,5 +38,6 @@ export const widgetKinds = [
|
|||||||
"tracearr",
|
"tracearr",
|
||||||
"speedtestTracker",
|
"speedtestTracker",
|
||||||
"umami",
|
"umami",
|
||||||
|
"ups",
|
||||||
] as const;
|
] as const;
|
||||||
export type WidgetKind = (typeof widgetKinds)[number];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
@@ -47,12 +47,14 @@
|
|||||||
"timezones-ical-library": "catalog:",
|
"timezones-ical-library": "catalog:",
|
||||||
"tsdav": "catalog:",
|
"tsdav": "catalog:",
|
||||||
"undici": "catalog:",
|
"undici": "catalog:",
|
||||||
|
"ws": "catalog:",
|
||||||
"xml2js": "catalog:",
|
"xml2js": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node-unifi": "catalog:",
|
"@types/node-unifi": "catalog:",
|
||||||
|
"@types/ws": "catalog:",
|
||||||
"@types/xml2js": "catalog:",
|
"@types/xml2js": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { NTFYIntegration } from "../ntfy/ntfy-integration";
|
|||||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||||
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
|
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
|
||||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||||
|
import { PeaNutIntegration } from "../peanut/peanut-integration";
|
||||||
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
|
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
|
||||||
import { PlexIntegration } from "../plex/plex-integration";
|
import { PlexIntegration } from "../plex/plex-integration";
|
||||||
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
||||||
@@ -123,6 +124,7 @@ export const integrationCreators = {
|
|||||||
immich: ImmichIntegration,
|
immich: ImmichIntegration,
|
||||||
speedtestTracker: SpeedtestTrackerIntegration,
|
speedtestTracker: SpeedtestTrackerIntegration,
|
||||||
umami: UmamiIntegration,
|
umami: UmamiIntegration,
|
||||||
|
peaNut: PeaNutIntegration,
|
||||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||||
|
|
||||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
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);
|
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> {
|
public async testConnectionAsync(): Promise<TestingResult> {
|
||||||
try {
|
try {
|
||||||
const url = new URL(this.integration.url);
|
const url = new URL(this.integration.url);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export { ImmichIntegration } from "./immich/immich-integration";
|
|||||||
export { TracearrIntegration } from "./tracearr/tracearr-integration";
|
export { TracearrIntegration } from "./tracearr/tracearr-integration";
|
||||||
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
|
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
|
||||||
export { UmamiIntegration } from "./umami/umami-integration";
|
export { UmamiIntegration } from "./umami/umami-integration";
|
||||||
|
export { PeaNutIntegration } from "./peanut/peanut-integration";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type { IntegrationInput } from "./base/integration";
|
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;
|
||||||
|
}
|
||||||
@@ -1,24 +1,130 @@
|
|||||||
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
||||||
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
|
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
|
||||||
|
|
||||||
export class CalendarMockService implements ICalendarIntegration {
|
interface EventTemplate {
|
||||||
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
|
title: string;
|
||||||
const result = [homarrMeetup(start, end), titanicRelease(start, end), seriesRelease(start, end)];
|
subTitle: string;
|
||||||
return await Promise.resolve(result);
|
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 eventTemplates: EventTemplate[] = [
|
||||||
const startDate = randomDateBetween(start, end);
|
{
|
||||||
const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours later
|
title: "The Mandalorian",
|
||||||
return {
|
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",
|
title: "Homarr Meetup",
|
||||||
subTitle: "",
|
subTitle: "",
|
||||||
description: "Yearly meetup of the Homarr community",
|
description: "Yearly meetup of the Homarr community",
|
||||||
startDate,
|
dayOffset: 14,
|
||||||
endDate,
|
hours: 14,
|
||||||
image: null,
|
|
||||||
location: "Mountains",
|
location: "Mountains",
|
||||||
|
image: null,
|
||||||
indicatorColor: "#fa5252",
|
indicatorColor: "#fa5252",
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
@@ -29,63 +135,79 @@ const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
|
|||||||
isDark: true,
|
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 => ({
|
export class CalendarMockService implements ICalendarIntegration {
|
||||||
title: "Titanic",
|
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
|
||||||
subTitle: "A classic movie",
|
return await Promise.resolve(buildEventsForRange(start, end));
|
||||||
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()));
|
|
||||||
}
|
}
|
||||||
@@ -2,57 +2,163 @@ import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/dow
|
|||||||
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
|
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
|
||||||
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
|
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 {
|
export class DownloadClientMockService implements IDownloadClientIntegration {
|
||||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||||
return await Promise.resolve({
|
return await Promise.resolve({
|
||||||
status: {
|
status: {
|
||||||
paused: Math.random() < 0.5,
|
paused: false,
|
||||||
rates: {
|
rates: {
|
||||||
down: Math.floor(Math.random() * 5000),
|
down: 121_100_000,
|
||||||
up: Math.floor(Math.random() * 5000),
|
up: 9_340_000,
|
||||||
},
|
},
|
||||||
types: ["torrent", "usenet"],
|
types: ["torrent", "usenet"] as const,
|
||||||
},
|
},
|
||||||
items: Array.from({ length: 20 }, (_, index) => DownloadClientMockService.createItem(index)).slice(
|
items: mockItems.slice(0, input.limit).map((item, index) => ({
|
||||||
0,
|
...item,
|
||||||
input.limit,
|
id: `mock-${index}`,
|
||||||
),
|
index,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pauseQueueAsync(): Promise<void> {
|
public async pauseQueueAsync(): Promise<void> {
|
||||||
return await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pauseItemAsync(_item: DownloadClientItem): Promise<void> {
|
public async pauseItemAsync(_item: DownloadClientItem): Promise<void> {
|
||||||
return await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async resumeQueueAsync(): Promise<void> {
|
public async resumeQueueAsync(): Promise<void> {
|
||||||
return Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async resumeItemAsync(_item: DownloadClientItem): Promise<void> {
|
public async resumeItemAsync(_item: DownloadClientItem): Promise<void> {
|
||||||
return await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise<void> {
|
public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise<void> {
|
||||||
return await Promise.resolve();
|
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,17 +13,17 @@ export const mockMediaReleases: MediaRelease[] = [
|
|||||||
title: "Inception",
|
title: "Inception",
|
||||||
subtitle: "A mind-bending thriller",
|
subtitle: "A mind-bending thriller",
|
||||||
description:
|
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"),
|
releaseDate: new Date("2010-07-16"),
|
||||||
imageUrls: {
|
imageUrls: {
|
||||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
poster: "https://image.tmdb.org/t/p/w300/ljsZTbVsrQSqZgWeep2B1QiDKuh.jpg",
|
||||||
backdrop: "https://example.com/inception_backdrop.jpg",
|
backdrop: "https://image.tmdb.org/t/p/w780/8ZTVqvKDQ8emSGUEMjsS4yHAwrp.jpg",
|
||||||
},
|
},
|
||||||
producer: "Warner Bros.",
|
producer: "Warner Bros.",
|
||||||
price: 14.99,
|
price: 14.99,
|
||||||
rating: "8.8/10",
|
rating: "8.8/10",
|
||||||
tags: ["Sci-Fi", "Thriller"],
|
tags: ["Sci-Fi", "Thriller"],
|
||||||
href: "https://example.com/inception",
|
href: "https://www.themoviedb.org/movie/27205-inception",
|
||||||
length: 148,
|
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.",
|
description: "When Walter White's secret is revealed, he must face the consequences of his actions.",
|
||||||
releaseDate: new Date("2013-09-15"),
|
releaseDate: new Date("2013-09-15"),
|
||||||
imageUrls: {
|
imageUrls: {
|
||||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
poster: "https://image.tmdb.org/t/p/w300/ztkUQFLlC19CCMYHW73GM9hBNRh.jpg",
|
||||||
backdrop: "https://example.com/breaking_bad_backdrop.jpg",
|
backdrop: "https://image.tmdb.org/t/p/w780/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
|
||||||
},
|
},
|
||||||
producer: "AMC",
|
producer: "AMC",
|
||||||
rating: "9.5/10",
|
rating: "9.5/10",
|
||||||
tags: ["Crime", "Drama"],
|
tags: ["Crime", "Drama"],
|
||||||
href: "https://example.com/breaking_bad",
|
href: "https://www.themoviedb.org/tv/1396-breaking-bad",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3",
|
id: "3",
|
||||||
@@ -50,79 +50,86 @@ export const mockMediaReleases: MediaRelease[] = [
|
|||||||
description: "The fourth studio album by French electronic music duo Daft Punk.",
|
description: "The fourth studio album by French electronic music duo Daft Punk.",
|
||||||
releaseDate: new Date("2013-05-17"),
|
releaseDate: new Date("2013-05-17"),
|
||||||
imageUrls: {
|
imageUrls: {
|
||||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
poster: "https://image.tmdb.org/t/p/w300/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
|
||||||
backdrop: "https://example.com/ram_backdrop.jpg",
|
backdrop: "https://image.tmdb.org/t/p/w780/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
|
||||||
},
|
},
|
||||||
producer: "Columbia Records",
|
producer: "Columbia Records",
|
||||||
price: 9.99,
|
price: 9.99,
|
||||||
rating: "8.5/10",
|
rating: "8.5/10",
|
||||||
tags: ["Electronic", "Dance", "Pop", "Funk"],
|
tags: ["Electronic", "Funk"],
|
||||||
href: "https://example.com/ram",
|
href: "https://www.daftpunk.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "4",
|
id: "4",
|
||||||
type: "book",
|
type: "movie",
|
||||||
title: "The Great Gatsby",
|
title: "The Dark Knight",
|
||||||
subtitle: "F. Scott Fitzgerald",
|
subtitle: "Theatrical Release",
|
||||||
description: "A novel about the American dream and the disillusionment that comes with it.",
|
description:
|
||||||
releaseDate: new Date("1925-04-10"),
|
"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: {
|
imageUrls: {
|
||||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
poster: "https://image.tmdb.org/t/p/w300/qJ2tW6WMUDux911BTUgMe1nV8Is.jpg",
|
||||||
backdrop: "https://example.com/gatsby_backdrop.jpg",
|
backdrop: "https://image.tmdb.org/t/p/w780/nMKdUUepR0i5zn0y1T4CsSB5ez.jpg",
|
||||||
},
|
},
|
||||||
producer: "Scribner",
|
producer: "Warner Bros.",
|
||||||
price: 10.99,
|
price: 12.99,
|
||||||
rating: "4.2/5",
|
rating: "9.0/10",
|
||||||
tags: ["Classic", "Fiction"],
|
tags: ["Action", "Crime", "Drama"],
|
||||||
href: "https://example.com/gatsby",
|
href: "https://www.themoviedb.org/movie/155-the-dark-knight",
|
||||||
|
length: 152,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "5",
|
id: "5",
|
||||||
type: "game",
|
type: "movie",
|
||||||
title: "The Legend of Zelda: Breath of the Wild",
|
title: "Interstellar",
|
||||||
subtitle: "Nintendo Switch",
|
subtitle: "Digital Release",
|
||||||
description: "An open-world action-adventure game set in the fantasy land of Hyrule.",
|
description: "A team of explorers travel through a wormhole in space in an attempt to ensure humanity's survival.",
|
||||||
releaseDate: new Date("2017-03-03"),
|
releaseDate: new Date("2014-11-07"),
|
||||||
imageUrls: {
|
imageUrls: {
|
||||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
poster: "https://image.tmdb.org/t/p/w300/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg",
|
||||||
backdrop: "https://example.com/zelda_backdrop.jpg",
|
backdrop: "https://image.tmdb.org/t/p/w780/xJHokMbljXjADYdit5fK1DVfjko.jpg",
|
||||||
},
|
},
|
||||||
producer: "Nintendo",
|
producer: "Paramount",
|
||||||
price: 59.99,
|
price: 14.99,
|
||||||
rating: "10/10",
|
rating: "8.7/10",
|
||||||
tags: ["Action", "Adventure"],
|
tags: ["Sci-Fi", "Adventure"],
|
||||||
href: "https://example.com/zelda",
|
href: "https://www.themoviedb.org/movie/157336-interstellar",
|
||||||
|
length: 169,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "6",
|
id: "6",
|
||||||
type: "article",
|
type: "tv",
|
||||||
title: "The Rise of AI in Healthcare",
|
title: "Severance",
|
||||||
subtitle: "Tech Innovations",
|
subtitle: "S2E1 - Premiere",
|
||||||
description: "Exploring the impact of artificial intelligence on the healthcare industry.",
|
description:
|
||||||
releaseDate: new Date("2023-10-01"),
|
"Mark leads a team of office workers whose memories are surgically divided between work and personal lives.",
|
||||||
|
releaseDate: new Date("2025-01-17"),
|
||||||
imageUrls: {
|
imageUrls: {
|
||||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
poster: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
|
||||||
backdrop: "https://example.com/ai_healthcare_backdrop.jpg",
|
backdrop: "https://image.tmdb.org/t/p/w780/gvaAaDUbF41Z1NLuJsHPRHkv1Ie.jpg",
|
||||||
},
|
},
|
||||||
producer: "Tech Innovations",
|
producer: "Apple TV+",
|
||||||
rating: "4.8/5",
|
rating: "8.7/10",
|
||||||
tags: ["Technology", "Healthcare"],
|
tags: ["Thriller", "Sci-Fi"],
|
||||||
href: "https://example.com/ai_healthcare",
|
href: "https://www.themoviedb.org/tv/95396-severance",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "7",
|
id: "7",
|
||||||
type: "video",
|
type: "movie",
|
||||||
title: "Wir LIEBEN unsere MAMAS | 50 Fragen zu Mamas",
|
title: "Dune: Part Two",
|
||||||
releaseDate: new Date("2024-05-18T17:00:00Z"),
|
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: {
|
imageUrls: {
|
||||||
poster:
|
poster: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
|
||||||
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
|
backdrop: "https://image.tmdb.org/t/p/w780/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
|
||||||
backdrop:
|
|
||||||
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
|
|
||||||
},
|
},
|
||||||
producer: "PietSmiet",
|
producer: "Warner Bros.",
|
||||||
rating: "1K",
|
price: 19.99,
|
||||||
tags: [],
|
rating: "8.5/10",
|
||||||
href: "https://www.youtube.com/watch?v=a3qyfXc1Pfg",
|
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 { IMediaServerIntegration } from "../../interfaces/media-server/media-server-integration";
|
||||||
import type { CurrentSessionsInput, StreamSession } from "../../interfaces/media-server/media-server-types";
|
import type { CurrentSessionsInput, StreamSession } from "../../interfaces/media-server/media-server-types";
|
||||||
|
|
||||||
export class MediaServerMockService implements IMediaServerIntegration {
|
const sessions: StreamSession[] = [
|
||||||
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
|
{
|
||||||
return await Promise.resolve(
|
sessionId: "s-0",
|
||||||
Array.from({ length: 10 }, (_, index) => MediaServerMockService.createSession(index)).filter(
|
sessionName: "Living Room TV",
|
||||||
(session) => !options.showOnlyPlaying || session.currentlyPlaying !== null,
|
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 {
|
export class MediaServerMockService implements IMediaServerIntegration {
|
||||||
return {
|
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||||
sessionId: `session-${index}`,
|
return await Promise.resolve(sessions);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,27 +4,11 @@ import type { NetworkControllerSummary } from "../../types";
|
|||||||
export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration {
|
export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration {
|
||||||
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
|
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
|
||||||
return await Promise.resolve({
|
return await Promise.resolve({
|
||||||
lan: {
|
lan: { guests: 5, users: 18, status: "enabled" },
|
||||||
guests: 5,
|
vpn: { users: 3, status: "enabled" },
|
||||||
users: 10,
|
wanStatus: "enabled",
|
||||||
status: "enabled",
|
wifi: { status: "enabled", guests: 2, users: 12 },
|
||||||
},
|
www: { latency: 22, status: "enabled", ping: 14, uptime: 864000 },
|
||||||
vpn: {
|
|
||||||
users: 3,
|
|
||||||
status: "enabled",
|
|
||||||
},
|
|
||||||
wanStatus: "disabled",
|
|
||||||
wifi: {
|
|
||||||
status: "disabled",
|
|
||||||
guests: 0,
|
|
||||||
users: 0,
|
|
||||||
},
|
|
||||||
www: {
|
|
||||||
latency: 22,
|
|
||||||
status: "enabled",
|
|
||||||
ping: 32,
|
|
||||||
uptime: 3600,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
|
||||||
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-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 { ISmartHomeIntegration } from "../interfaces/smart-home/smart-home-integration";
|
||||||
|
import type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
|
||||||
import { CalendarMockService } from "./data/calendar";
|
import { CalendarMockService } from "./data/calendar";
|
||||||
import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monitoring";
|
import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monitoring";
|
||||||
import { DnsHoleMockService } from "./data/dns-hole";
|
import { DnsHoleMockService } from "./data/dns-hole";
|
||||||
@@ -28,6 +29,7 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s
|
|||||||
import { NotificationsMockService } from "./data/notifications";
|
import { NotificationsMockService } from "./data/notifications";
|
||||||
import { SmartHomeMockService } from "./data/smart-home";
|
import { SmartHomeMockService } from "./data/smart-home";
|
||||||
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
|
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
|
||||||
|
import { UpsSummaryMockService } from "./data/ups-summary";
|
||||||
|
|
||||||
export class MockIntegration
|
export class MockIntegration
|
||||||
extends Integration
|
extends Integration
|
||||||
@@ -43,7 +45,8 @@ export class MockIntegration
|
|||||||
IMediaServerIntegration,
|
IMediaServerIntegration,
|
||||||
IMediaTranscodingIntegration,
|
IMediaTranscodingIntegration,
|
||||||
NetworkControllerSummaryIntegration,
|
NetworkControllerSummaryIntegration,
|
||||||
ISmartHomeIntegration
|
ISmartHomeIntegration,
|
||||||
|
IUpsSummaryIntegration
|
||||||
{
|
{
|
||||||
private static readonly dnsHole = new DnsHoleMockService();
|
private static readonly dnsHole = new DnsHoleMockService();
|
||||||
private static readonly calendar = new CalendarMockService();
|
private static readonly calendar = new CalendarMockService();
|
||||||
@@ -58,6 +61,7 @@ export class MockIntegration
|
|||||||
private static readonly networkController = new NetworkControllerSummaryMockService();
|
private static readonly networkController = new NetworkControllerSummaryMockService();
|
||||||
private static readonly notifications = new NotificationsMockService();
|
private static readonly notifications = new NotificationsMockService();
|
||||||
private static readonly smartHome = new SmartHomeMockService();
|
private static readonly smartHome = new SmartHomeMockService();
|
||||||
|
private static readonly upsSummary = new UpsSummaryMockService();
|
||||||
|
|
||||||
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
return await Promise.resolve({
|
return await Promise.resolve({
|
||||||
@@ -85,7 +89,7 @@ export class MockIntegration
|
|||||||
|
|
||||||
// Health Monitoring Integrations
|
// Health Monitoring Integrations
|
||||||
getSystemInfoAsync = MockIntegration.systemMonitoring.getSystemInfoAsync.bind(MockIntegration.systemMonitoring);
|
getSystemInfoAsync = MockIntegration.systemMonitoring.getSystemInfoAsync.bind(MockIntegration.systemMonitoring);
|
||||||
getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.downloadClient);
|
getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.clusterMonitoring);
|
||||||
|
|
||||||
// IndexerManagerIntegration
|
// IndexerManagerIntegration
|
||||||
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
|
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
|
||||||
@@ -104,7 +108,7 @@ export class MockIntegration
|
|||||||
declineRequestAsync = MockIntegration.mediaRequest.declineRequestAsync.bind(MockIntegration.mediaRequest);
|
declineRequestAsync = MockIntegration.mediaRequest.declineRequestAsync.bind(MockIntegration.mediaRequest);
|
||||||
|
|
||||||
// MediaServerIntegration
|
// MediaServerIntegration
|
||||||
getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaRequest);
|
getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaServer);
|
||||||
|
|
||||||
// MediaTranscodingIntegration
|
// MediaTranscodingIntegration
|
||||||
getStatisticsAsync = MockIntegration.mediaTranscoding.getStatisticsAsync.bind(MockIntegration.mediaTranscoding);
|
getStatisticsAsync = MockIntegration.mediaTranscoding.getStatisticsAsync.bind(MockIntegration.mediaTranscoding);
|
||||||
@@ -123,4 +127,7 @@ export class MockIntegration
|
|||||||
getEntityStateAsync = MockIntegration.smartHome.getEntityStateAsync.bind(MockIntegration.smartHome);
|
getEntityStateAsync = MockIntegration.smartHome.getEntityStateAsync.bind(MockIntegration.smartHome);
|
||||||
triggerAutomationAsync = MockIntegration.smartHome.triggerAutomationAsync.bind(MockIntegration.smartHome);
|
triggerAutomationAsync = MockIntegration.smartHome.triggerAutomationAsync.bind(MockIntegration.smartHome);
|
||||||
triggerToggleAsync = MockIntegration.smartHome.triggerToggleAsync.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 dayjs from "dayjs";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
import { createId } from "@homarr/common";
|
|
||||||
import { RequestError, ResponseError } from "@homarr/common/server";
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
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 { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
|
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
|
||||||
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
|
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 logger = createLogger({ module: "trueNasIntegration" });
|
||||||
|
|
||||||
const NETWORK_MULTIPLIER = 100;
|
const NETWORK_MULTIPLIER = 100;
|
||||||
|
|
||||||
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||||
private static webSocketMap = new Map<string, WebSocket>();
|
private client?: TrueNasClient;
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
|
await this.getClient().testAsync(input.options);
|
||||||
return { success: true };
|
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> {
|
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
|
||||||
const systemInformation = await this.getSystemInformationAsync();
|
const systemInformation = await this.getSystemInformationAsync();
|
||||||
const reporting = await this.getReportingAsync();
|
const reporting = await this.getReportingAsync();
|
||||||
@@ -272,7 +39,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
|
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,
|
memAvailableInBytes: systemInformation.physmem,
|
||||||
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
|
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
|
||||||
fileSystem: datasets.map((dataset) => ({
|
fileSystem: datasets.map((dataset) => ({
|
||||||
@@ -301,87 +68,164 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private getClient(): TrueNasClient {
|
||||||
* Send a request through websocket and return response
|
return (this.client ??= this.createClient());
|
||||||
* 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 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);
|
return data.reduce((acc, current) => acc + (current.data.at(-1)?.at(index) ?? 0), 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
private extractLatestReportingData(data: ReportingItem[], key: ReportingItem["identifier"]) {
|
private extractLatestReportingData(data: ReportingItem[], identifier: ReportingItem["identifier"]) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
const item = data.find((reportingItem) => reportingItem.identifier === identifier);
|
||||||
const dataObject = data.find((item) => item.identifier === key)!;
|
// Reporting rows are [timestamp, ...values] in ascending time order, so the last row is the most recent reading.
|
||||||
// TODO: check why the below sorting is done, because right now it compares number[] with number[]?
|
const latest = item?.data.at(-1);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
if (!latest) throw new Error(`TrueNAS returned no "${identifier}" reporting data`);
|
||||||
return dataObject.data.sort((item1, item2) => (item1 > item2 ? -1 : 1))[0]!;
|
return latest;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/network-controller-summary/network-controller-summary-types";
|
||||||
export * from "./interfaces/firewall-summary/firewall-summary-types";
|
export * from "./interfaces/firewall-summary/firewall-summary-types";
|
||||||
export * from "./interfaces/health-monitoring/health-monitoring-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/indexer-manager/indexer-manager-types";
|
||||||
export * from "./interfaces/media-requests/media-request-types";
|
export * from "./interfaces/media-requests/media-request-types";
|
||||||
export * from "./base/searchable-integration";
|
export * from "./base/searchable-integration";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NotificationData } from "@mantine/notifications";
|
import type { NotificationData } from "@mantine/notifications";
|
||||||
import { notifications } 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) =>
|
export const showSuccessNotification = (props: NotificationData) =>
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -15,3 +15,10 @@ export const showErrorNotification = (props: NotificationData) =>
|
|||||||
color: "red",
|
color: "red",
|
||||||
icon: <IconX size={20} />,
|
icon: <IconX size={20} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const showWarningNotification = (props: NotificationData) =>
|
||||||
|
notifications.show({
|
||||||
|
...props,
|
||||||
|
color: "yellow",
|
||||||
|
icon: <IconInfoCircle size={20} />,
|
||||||
|
});
|
||||||
@@ -13,8 +13,6 @@ export {
|
|||||||
handshakeAsync,
|
handshakeAsync,
|
||||||
createSubPubChannel,
|
createSubPubChannel,
|
||||||
createGetSetChannel,
|
createGetSetChannel,
|
||||||
createQueryCacheChannel,
|
|
||||||
getAllQueryCacheAsync,
|
|
||||||
} from "./lib/channel";
|
} from "./lib/channel";
|
||||||
|
|
||||||
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
|
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
|
||||||
|
|||||||
@@ -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.
|
* Creates a new cache channel.
|
||||||
* @param name name of the channel
|
* @param name name of the channel
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
|||||||
import { db, like, or } from "@homarr/db";
|
import { db, like, or } from "@homarr/db";
|
||||||
import { icons } from "@homarr/db/schema";
|
import { icons } from "@homarr/db/schema";
|
||||||
import { extractContainerImageName } from "@homarr/definitions";
|
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 { dockerLabels, DockerSingleton } from "@homarr/docker";
|
||||||
|
|
||||||
import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
|
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 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({
|
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
|
||||||
queryKey: "dockerContainersResult",
|
queryKey: "dockerContainersResult",
|
||||||
widgetKind: "dockerContainers",
|
widgetKind: "dockerContainers",
|
||||||
async requestAsync() {
|
async requestAsync() {
|
||||||
|
if (isDemoMode) {
|
||||||
|
return mockContainers;
|
||||||
|
}
|
||||||
return await getContainersWithStatsAsync();
|
return await getContainersWithStatsAsync();
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(20, "seconds"),
|
cacheDuration: dayjs.duration(20, "seconds"),
|
||||||
|
|||||||
@@ -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 ?? {};
|
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
|
||||||
setMediaRequestSearchScope({ integrationIds });
|
setMediaRequestSearchScope({ integrationIds });
|
||||||
setMode("media");
|
setMode("media");
|
||||||
setChildrenStack([]);
|
clearChildrenStack();
|
||||||
setQuery(query ?? "");
|
setQuery(query ?? "");
|
||||||
spotlightActions.open();
|
spotlightActions.open();
|
||||||
setTimeout(() => selectAction(0, spotlightStore));
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
@@ -98,7 +98,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
||||||
};
|
};
|
||||||
}, [setMediaRequestSearchScope, setMode, setQuery]);
|
}, [setMediaRequestSearchScope, setMode, setQuery, clearChildrenStack]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "media") {
|
if (mode !== "media") {
|
||||||
|
|||||||
@@ -55,8 +55,7 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
|
|||||||
|
|
||||||
const { result } = option;
|
const { result } = option;
|
||||||
const hasAvailabilityLabel =
|
const hasAvailabilityLabel =
|
||||||
result.availability &&
|
result.availability && (availabilityLabelKeys as readonly string[]).includes(result.availability);
|
||||||
(availabilityLabelKeys as readonly string[]).includes(result.availability);
|
|
||||||
const badgeLabel = hasAvailabilityLabel
|
const badgeLabel = hasAvailabilityLabel
|
||||||
? tMedia(`availability.${result.availability as AvailabilityWithLabel}`)
|
? tMedia(`availability.${result.availability as AvailabilityWithLabel}`)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -132,6 +132,7 @@
|
|||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"label": "Contrasenya",
|
"label": "Contrasenya",
|
||||||
|
"suggestionsTitle": "",
|
||||||
"suggestion": {
|
"suggestion": {
|
||||||
"length": "Inclou com a mínim 8 caràcters",
|
"length": "Inclou com a mínim 8 caràcters",
|
||||||
"lowercase": "Inclou minúscules",
|
"lowercase": "Inclou minúscules",
|
||||||
@@ -1405,6 +1406,9 @@
|
|||||||
"openNewTab": {
|
"openNewTab": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"withBorder": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"add": ""
|
"add": ""
|
||||||
|
|||||||
@@ -132,6 +132,7 @@
|
|||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"label": "密码",
|
"label": "密码",
|
||||||
|
"suggestionsTitle": "",
|
||||||
"suggestion": {
|
"suggestion": {
|
||||||
"length": "至少包含 8 个字符",
|
"length": "至少包含 8 个字符",
|
||||||
"lowercase": "包含小写字母",
|
"lowercase": "包含小写字母",
|
||||||
@@ -1405,6 +1406,9 @@
|
|||||||
"openNewTab": {
|
"openNewTab": {
|
||||||
"label": "在新标签页中打开"
|
"label": "在新标签页中打开"
|
||||||
},
|
},
|
||||||
|
"withBorder": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"label": "书签",
|
"label": "书签",
|
||||||
"add": "添加书签"
|
"add": "添加书签"
|
||||||
|
|||||||
@@ -132,12 +132,13 @@
|
|||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"label": "crwdns26:0crwdne26:0",
|
"label": "crwdns26:0crwdne26:0",
|
||||||
|
"suggestionsTitle": "crwdns4261:0crwdne4261:0",
|
||||||
"suggestion": {
|
"suggestion": {
|
||||||
"length": "crwdns28:0crwdne28:0",
|
"length": "crwdns4263:0crwdne4263:0",
|
||||||
"lowercase": "crwdns30:0crwdne30:0",
|
"lowercase": "crwdns4265:0crwdne4265:0",
|
||||||
"uppercase": "crwdns32:0crwdne32:0",
|
"uppercase": "crwdns4267:0crwdne4267:0",
|
||||||
"number": "crwdns34:0crwdne34:0",
|
"number": "crwdns4269:0crwdne4269:0",
|
||||||
"special": "crwdns36:0crwdne36:0"
|
"special": "crwdns4271:0crwdne4271:0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"passwordConfirm": {
|
"passwordConfirm": {
|
||||||
@@ -1405,6 +1406,9 @@
|
|||||||
"openNewTab": {
|
"openNewTab": {
|
||||||
"label": "crwdns2686:0crwdne2686:0"
|
"label": "crwdns2686:0crwdne2686:0"
|
||||||
},
|
},
|
||||||
|
"withBorder": {
|
||||||
|
"label": "crwdns4273:0crwdne4273:0"
|
||||||
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"label": "crwdns764:0crwdne764:0",
|
"label": "crwdns764:0crwdne764:0",
|
||||||
"add": "crwdns766:0crwdne766:0"
|
"add": "crwdns766:0crwdne766:0"
|
||||||
|
|||||||
@@ -132,6 +132,7 @@
|
|||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"label": "Heslo",
|
"label": "Heslo",
|
||||||
|
"suggestionsTitle": "",
|
||||||
"suggestion": {
|
"suggestion": {
|
||||||
"length": "Obsahuje alespoň 8 znaků",
|
"length": "Obsahuje alespoň 8 znaků",
|
||||||
"lowercase": "Obsahuje malé písmeno",
|
"lowercase": "Obsahuje malé písmeno",
|
||||||
@@ -1405,6 +1406,9 @@
|
|||||||
"openNewTab": {
|
"openNewTab": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"withBorder": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"add": ""
|
"add": ""
|
||||||
|
|||||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user