Compare commits

...
Author SHA1 Message Date
Thomas 79cb9277e6 Merge branch 'dev' into feat/sqlite-import-export 2026-06-10 18:26:00 +02:00
Thomas 484b8cf9f1 fix: gracefully skip widgets with unknown kind instead of crashing (#5915) 2026-06-10 18:19:13 +02:00
homarr-renovate[bot] b3cdab579b chore(deps): update dependency isomorphic-dompurify to ^3.16.0 (#5913)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-10 14:31:02 +00:00
homarr-renovate[bot] 44a0c6ce54 chore(deps): update dependency semver-parser to ^4.2.1 (#5868)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 12:47:00 +00:00
homarr-renovate[bot] 7f05e0325d chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.61.1 (#5906)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 09:53:16 +00:00
homarr-renovate[bot] f71b145403 chore(deps): update tiptap monorepo to v3.25.0 (#5898)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 05:17:18 +00:00
homarr-renovate[bot] 607fd498f4 chore(deps): update tanstack-query monorepo to ^5.101.0 (#5894)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 00:17:40 +00:00
homarr-renovate[bot] 3f2fb54cdf chore(deps): update mantine monorepo to ^9.3.0 (#5890)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 22:39:48 +00:00
Thomasandhomarr-releases[bot] 69412f2d10 chore(release): 1.64.0 [skip ci] (#5902)
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-08 22:25:40 +02:00
Afonso Jorge Ramos f6d5f97933 fix(integrations): support truenas json-rpc api, certificate trust, and api key auth (#5861)
* chore(integrations): add ws dependency for the truenas client

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

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

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

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

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

* refactor(integrations): extract truenas connection handling into a client
2026-06-08 22:20:42 +02:00
homarr-renovate[bot] a447a9f615 chore(deps): update dependency axios@>=1.0.0 <1.8.2 to >=1.17.0 (#5897)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 14:15:50 +00:00
homarr-renovate[bot] 87d3f6a0a1 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.4 (#5896)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 09:30:40 +00:00
Thomas Camlong 3d2c4979a6 fix: audit fixes — memory leak, input validation, deduplication
- Fix WASM DB memory leak: close database in finally block so errors
  during analysis don't leave it open until manual reset
- Validate metadata.encryptionKey is a non-empty string before use
- Extract shared findMigrationsFolder to backup/shared.ts (was
  duplicated in import and migrations routes)
- Wrap export card error response JSON parse to handle non-JSON
- Check migration exit code in run.sh — abort startup on failure
2026-06-07 01:18:22 +02:00
Thomas Camlong 79b27815fe fix: harden backup/restore error handling and security
- Stop analysis on WASM migration failure instead of continuing silently
- Fail import when secrets exist but no encryption key is provided
- Fail import on malformed secret values instead of skipping them
- Tighten onboarding auth: missing onboarding row no longer grants access
- Wrap WAL/SHM cleanup in try/catch so post-rename failure doesn't mask success
- Show API errors immediately instead of waiting for animation to finish
- Handle non-JSON server responses during restore (e.g. restart mid-request)
- Surface fetchMigrations() failures as errors instead of empty array
- Wrap migrations route I/O in try/catch with structured error response
- Add server-side console.error logging to all backup API catch blocks
- Fix export finally block: cleanup wrapped in try/catch to avoid masking
- Fix misleading "Up to date" badge shown when migrations are pending
- Remove dead PHASE_ICONS constant
2026-06-07 01:15:07 +02:00
homarr-renovate[bot] f2705eeb57 chore(deps): update dependency oxfmt to ^0.53.0 (#5883)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 23:12:23 +00:00
Thomas Camlong 76eacf8353 feat: expand migration phase into per-migration timeline steps
Show each migration as its own 100ms step in the restore progress
timeline instead of a single generic "Applying migrations" phase.
2026-06-07 01:06:45 +02:00
Thomas Camlong 0c14e715a0 feat: add SQLite backup & restore with WASM preview and onboarding integration
Full database backup/restore for SQLite instances with client-side WASM
migration preview, type-to-confirm safety dialogs, and graceful server
restart without container recycling.

- Export/import API routes with encryption key re-encryption
- Client-side backup analysis via sql.js WASM (served through API route)
- Drizzle migrations applied in-browser with animated progress timeline
- "Type I understand" confirmation for both export and import
- Onboarding flow integration for restoring during initial setup
- SQLite-only guards on sidebar, page, onboarding, and all API routes
- run.sh restart loop: only Next.js restarts, nginx/redis stay up
- Backward-compatible with older backups using encryption-key.txt
- Version injected at build time via next.config.ts
2026-06-07 00:20:13 +02:00
homarr-renovate[bot] ac37d2c73f chore(deps): update react monorepo (#5886)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 21:13:36 +00:00
homarr-renovate[bot] e00ea624cd chore(deps): update nextjs monorepo to v16.2.7 (#5885)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 19:32:30 +00:00
homarr-renovate[bot] 20b62c42e3 chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.61.0 (#5878)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 13:54:21 +00:00
homarr-renovate[bot] 59971991c7 chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.16 (#5880)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 11:36:28 +00:00
homarr-renovate[bot] a629681ae5 chore(deps): update vitest monorepo to ^4.1.8 (#5879)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 09:34:48 +00:00
homarr-renovate[bot] 572d5fb5ec chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.15 (#5877)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 07:01:54 +00:00
Thomasandhomarr-releases[bot] 91a1858743 chore: merge main into dev
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-06 07:47:59 +02:00
homarr-renovate[bot] f00d3627c9 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.3 (#5873)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-05 18:04:10 +00:00
homarr-releases[bot] 7760e01ccb chore: update bug report template (#5821)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-05 17:47:57 +00:00
Releases Homarr 8a1ead3674 chore(release): 1.64.0 [skip ci] 2026-06-05 17:35:36 +00:00
homarr-releases[bot] 9c05f44916 chore(release): automatic release v1.64.0 2026-06-05 17:34:47 +00:00
Thomas Camlong efd37dca93 fix(db): log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding
Warn on startup seed when mock integration env flag is set, matching demo mode visibility.
2026-06-05 18:43:33 +02:00
Thomas Camlong 796c679140 chore: merge main into dev 2026-06-05 18:42:31 +02:00
homarr-update-contributors[bot] 76fd4c5fb1 chore: update contributors (#5871) 2026-06-05 18:08:47 +02:00
Thomas Camlong 68f24e34d5 fix: disable onboarding tours when demo mode is enabled
Return completed tour status in demo mode so board and manage tours do not start for demo users.
2026-06-05 18:02:08 +02:00
homarr-update-contributors[bot] 22d7ebab50 chore: update integrations list readme (#5872)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-06-05 15:45:46 +00:00
Thomas Camlong 4f5f3e14a1 fix: sync docs sitemap and remove stale coolify ts-expect-error 2026-06-05 17:39:03 +02:00
Thomasandcopilot-swe-agent[bot] dbf021e04a feat(docker): add container logs modal and stats columns (#4807)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-05 16:57:26 +02:00
CopilotandThomas cc24f070db fix: redirect to login after credentials logout instead of crashing on current page (#5819)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-06-05 16:56:24 +02:00
Thomas 0b663a7fc6 feat(onboarding): guided management and board tours (#5756) 2026-06-05 16:54:50 +02:00
Releases Homarr ff1e4c1381 chore(release): 1.63.0 [skip ci] 2026-05-29 21:07:15 +00:00
homarr-releases[bot] 21bb20ce0c chore(release): automatic release v1.63.0 2026-05-29 21:06:26 +00:00
Releases Homarr 92b75cb924 chore(release): 1.62.0 [skip ci] 2026-05-22 20:48:36 +00:00
Meier Lukas 1ea008b71c chore(release): automatic release v1.62.0 2026-05-22 22:47:05 +02:00
96 changed files with 11914 additions and 1562 deletions

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.64.0
- 1.62.0
- 1.61.0
- 1.60.1-beta.1
+64
View File
@@ -1,3 +1,67 @@
## [1.64.0](https://github.com/homarr-labs/homarr/compare/v1.63.0...v1.64.0) (2026-06-05)
### Features
* add comprehensive demo mode with mock integrations ([#5870](https://github.com/homarr-labs/homarr/issues/5870)) ([0a64679](https://github.com/homarr-labs/homarr/commit/0a6467979a5e3b3e3eae6252c31fca1d9af54dc1))
* add PeaNUT integration and UPS monitoring widget ([#5862](https://github.com/homarr-labs/homarr/issues/5862)) ([0dc2558](https://github.com/homarr-labs/homarr/commit/0dc2558fafedcbba246c2e968d00cc541ab9f84e))
* **docker:** add container logs modal and stats columns ([#4807](https://github.com/homarr-labs/homarr/issues/4807)) ([dbf021e](https://github.com/homarr-labs/homarr/commit/dbf021e04a785b44ad0e2ce45634de371d76de6d))
* media request search as first-class spotlight mode ([#5823](https://github.com/homarr-labs/homarr/issues/5823)) ([3d3e305](https://github.com/homarr-labs/homarr/commit/3d3e3059a98b36b6e64e3aad90f715e334baff3e))
* **onboarding:** enhance integration setup with Docker discovery and URL templating ([#5607](https://github.com/homarr-labs/homarr/issues/5607)) ([dd7a490](https://github.com/homarr-labs/homarr/commit/dd7a4908a36a71c1d0d1e41524eb8fd9bf53ca4d))
* **onboarding:** guided management and board tours ([#5756](https://github.com/homarr-labs/homarr/issues/5756)) ([0b663a7](https://github.com/homarr-labs/homarr/commit/0b663a7fc6c017b9fbe78c019f06d46ee95f165f))
* **widgets:** add in-place app editing in widget edit modal ([#5809](https://github.com/homarr-labs/homarr/issues/5809)) ([99346f2](https://github.com/homarr-labs/homarr/commit/99346f288bcfc701c41fecaab6f7bc0438d80563))
### Bug Fixes
* add bookmark widget option to toggle card borders ([#5854](https://github.com/homarr-labs/homarr/issues/5854)) ([41fe592](https://github.com/homarr-labs/homarr/commit/41fe5928f19207d02b04e64c2caac49d896f72ce))
* **auth:** relax password complexity requirements to suggestions-only ([#5764](https://github.com/homarr-labs/homarr/issues/5764)) ([fd10788](https://github.com/homarr-labs/homarr/commit/fd107888385557ededcf51576c6320a1960690f2))
* **db:** log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding ([efd37dc](https://github.com/homarr-labs/homarr/commit/efd37dca93be7f5f362331e9b5c2a95a87e24379))
* disable onboarding tours when demo mode is enabled ([68f24e3](https://github.com/homarr-labs/homarr/commit/68f24e34d580e3a155c37ca37c3ffd61f296bd0c))
* **docker:** restore container selection toolbar broken by Mantine v9 ([#5855](https://github.com/homarr-labs/homarr/issues/5855)) ([fe4598f](https://github.com/homarr-labs/homarr/commit/fe4598f7221a2861b8c5ae0cfd267e8c4fbf48df))
* **immich:** respect app color scheme in server-stats widget ([#5859](https://github.com/homarr-labs/homarr/issues/5859)) ([2c6b7a8](https://github.com/homarr-labs/homarr/commit/2c6b7a818e9d32b414b024d711a3b64533e61cb9))
* redirect to login after credentials logout instead of crashing on current page ([#5819](https://github.com/homarr-labs/homarr/issues/5819)) ([cc24f07](https://github.com/homarr-labs/homarr/commit/cc24f070db26337a4a2d87d68c7f142ed1118584))
* sync docs sitemap and remove stale coolify ts-expect-error ([4f5f3e1](https://github.com/homarr-labs/homarr/commit/4f5f3e14a129a94295d4b8ffb7d3702fdb4f6a74))
* **ui:** restore pre-v9 light variant colors ([#5856](https://github.com/homarr-labs/homarr/issues/5856)) ([a755692](https://github.com/homarr-labs/homarr/commit/a755692d81b80b5ff83051a3b7261c01ac10321b))
## [1.63.0](https://github.com/homarr-labs/homarr/compare/v1.62.0...v1.63.0) (2026-05-29)
### Features
* **auth:** support custom token endpoint auth methods ([#5806](https://github.com/homarr-labs/homarr/issues/5806)) ([be61602](https://github.com/homarr-labs/homarr/commit/be61602576a8bec4bb7e4928d36e0854e926264d))
* auto-open widget settings on add with all integrations pre-selected ([#5767](https://github.com/homarr-labs/homarr/issues/5767)) ([f10bb3a](https://github.com/homarr-labs/homarr/commit/f10bb3ae23fefc9779332733706aa567cc40ba48))
* **board:** persist category collapse state in localStorage for guests ([#5714](https://github.com/homarr-labs/homarr/issues/5714)) ([66212b4](https://github.com/homarr-labs/homarr/commit/66212b4e475738e61d2ea78f3a7e72d7a610bf12)), closes [#3355](https://github.com/homarr-labs/homarr/issues/3355)
* **manage:** unified management page layout ([#5758](https://github.com/homarr-labs/homarr/issues/5758)) ([c252b4f](https://github.com/homarr-labs/homarr/commit/c252b4f9accc4523f222354c73f687714640701f))
* **users:** rework general settings ([#4915](https://github.com/homarr-labs/homarr/issues/4915)) ([2991777](https://github.com/homarr-labs/homarr/commit/2991777b0a0ae72522d0769a86a91250d618dbe6))
### Bug Fixes
* matine ui fixes for concistency ([#5733](https://github.com/homarr-labs/homarr/issues/5733)) ([4454e3f](https://github.com/homarr-labs/homarr/commit/4454e3fb7afa624ee2f1eaec015f2725e664ac46))
* parse Speedtest Tracker created_at as local time ([#5751](https://github.com/homarr-labs/homarr/issues/5751)) ([b19549b](https://github.com/homarr-labs/homarr/commit/b19549b601636bcbc2fadaa270a77a9fdb48b77a))
* prevent GitHub releases check from blocking app on API failure ([#5807](https://github.com/homarr-labs/homarr/issues/5807)) ([6cc2ac5](https://github.com/homarr-labs/homarr/commit/6cc2ac56a920563c47aaaab1b33b19a9b6d5ecee))
* **ui:** use imported theme instead of undefined createTheme ([4a67bf0](https://github.com/homarr-labs/homarr/commit/4a67bf0ef30ff8d10b06d2d3719ccab792395669))
### Performance Improvements
* **boards:** parallelize board page data fetching ([#5736](https://github.com/homarr-labs/homarr/issues/5736)) ([8f03a38](https://github.com/homarr-labs/homarr/commit/8f03a38de5b9b385da7ffcc9a963be00710acb5e))
## [1.62.0](https://github.com/homarr-labs/homarr/compare/v1.61.1...v1.62.0) (2026-05-22)
### Features
* **seed:** add demo mode with pre-seeded user ([#5739](https://github.com/homarr-labs/homarr/issues/5739)) ([dade477](https://github.com/homarr-labs/homarr/commit/dade477b3226765eb614e66588ebee84c45cb688))
### Bug Fixes
* **deps:** update dependency better-sqlite3 to ^12.10.0 ([#5727](https://github.com/homarr-labs/homarr/issues/5727)) ([044ad9e](https://github.com/homarr-labs/homarr/commit/044ad9edf9c1dc9b50b9591dea0b5f0cce4be01a))
* **docker:** improve icon matching ([#5693](https://github.com/homarr-labs/homarr/issues/5693)) ([7cd30b6](https://github.com/homarr-labs/homarr/commit/7cd30b640cbf47e4678abda18f83c0924f039b1b))
* exclude .env files from Docker build context ([#5738](https://github.com/homarr-labs/homarr/issues/5738)) ([fa73763](https://github.com/homarr-labs/homarr/commit/fa737638a812372d904207fe7af5bd7c6f53d159))
* **tracearr:** support absolute image urls ([#5654](https://github.com/homarr-labs/homarr/issues/5654)) ([7a12721](https://github.com/homarr-labs/homarr/commit/7a1272146c597c1f9df89d74b8389f70e7cdd61a))
### Performance Improvements
* (next branch) reduce idle memory on startup ([#5637](https://github.com/homarr-labs/homarr/issues/5637)) ([5ee84f8](https://github.com/homarr-labs/homarr/commit/5ee84f8cf464c308729281cb4db572950ba85a82))
* **boards:** remove loading overlay and show board immediately ([#5735](https://github.com/homarr-labs/homarr/issues/5735)) ([df4b57b](https://github.com/homarr-labs/homarr/commit/df4b57bd414d4402522f9210b1470d0561c37274))
* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e))
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
### Bug Fixes
+16 -1
View File
@@ -5,9 +5,21 @@ import "@homarr/common/env";
import "@homarr/core/infrastructure/logs/env";
import "@homarr/docker/env";
import { readFileSync } from "fs";
import type { NextConfig } from "next";
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
const withNextIntl = createNextIntlPlugin({
experimental: {
@@ -17,6 +29,9 @@ const withNextIntl = createNextIntlPlugin({
});
const nextConfig: NextConfig = {
env: {
HOMARR_VERSION: homarrVersion,
},
output: "standalone",
reactStrictMode: true,
// 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
* 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: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
+8
View File
@@ -18,6 +18,7 @@
"@dnd-kit/modifiers": "catalog:",
"@dnd-kit/sortable": "catalog:",
"@dnd-kit/utilities": "catalog:",
"@gfazioli/mantine-onboarding-tour": "catalog:",
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
@@ -71,9 +72,13 @@
"clsx": "catalog:",
"dayjs": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "catalog:",
"flag-icons": "catalog:",
"glob": "catalog:",
"adm-zip": "catalog:",
"better-sqlite3": "catalog:",
"isomorphic-dompurify": "catalog:",
"jszip": "catalog:",
"jotai": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
@@ -84,6 +89,7 @@
"react-error-boundary": "catalog:",
"react-simple-code-editor": "catalog:",
"sass": "catalog:",
"sql.js": "catalog:",
"superjson": "catalog:",
"swagger-ui-react": "catalog:",
"use-deep-compare-effect": "catalog:",
@@ -92,6 +98,8 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "catalog:",
"@types/better-sqlite3": "catalog:",
"@types/chroma-js": "catalog:",
"@types/node": "catalog:",
"@types/prismjs": "catalog:",
BIN
View File
Binary file not shown.
@@ -38,6 +38,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
// eslint-disable-next-line no-restricted-syntax
page: async ({ params }: { params: Promise<TParams> }) => {
@@ -2,7 +2,8 @@
import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Group, Menu, ScrollArea } from "@mantine/core";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import {
IconBox,
@@ -57,9 +58,11 @@ export const BoardContentHeaderActions = () => {
<EditModeMenu demoMode={demoMode ?? false} />
{!demoMode && (
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</OnboardingTour.Target>
)}
<SelectBoardsMenu />
@@ -195,9 +198,11 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
usePreventLeaveWithDirty(isEditMode);
return (
<HeaderButton onClick={toggle} loading={isPending}>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton>
<OnboardingTour.Target id="board-edit-mode">
<HeaderButton onClick={toggle} loading={isPending}>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton>
</OnboardingTour.Target>
);
};
@@ -205,27 +210,31 @@ const SelectBoardsMenu = () => {
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<ScrollArea.Autosize mah={300}>
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
<OnboardingTour.Target id="board-switcher">
<Box>
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<ScrollArea.Autosize mah={300}>
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
</Box>
</OnboardingTour.Target>
);
};
@@ -266,7 +275,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return; // Allow to reload in development
if (env.NODE_ENV === "development") return;
event.preventDefault();
event.returnValue = true;
@@ -11,6 +11,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { BoardTourProvider } from "~/components/onboarding/board-tour";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import type { Params } from "./(content)/_creator";
@@ -20,14 +21,21 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" });
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
if (!hasSession) return <>{children}</>;
return <BoardTourProvider>{children}</BoardTourProvider>;
};
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
withTour?: boolean;
}
export const createBoardLayout = <TParams extends Params>({
headerActions,
getInitialBoardAsync: getInitialBoard,
withTour = false,
}: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({
params,
@@ -61,14 +69,16 @@ export const createBoardLayout = <TParams extends Params>({
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
<BoardTourWrapper hasSession={withTour && !!session}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardTourWrapper>
</BoardMantineProvider>
</EditModeProvider>
</BoardReadyProvider>
@@ -2,13 +2,16 @@ import { Stack, Text } from "@mantine/core";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { InitStepCard } from "../../_components/init-step-card";
import { InitSqliteRestore } from "./sqlite-restore";
import { InitStartButton } from "./next-button";
export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start");
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
return (
<InitStepCard>
@@ -27,6 +30,7 @@ export const InitStart = async () => {
>
{tStart("action.importOldmarr")}
</InitStartButton>
{isSqlite && <InitSqliteRestore />}
</Stack>
</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>
);
};
+1
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "@gfazioli/mantine-onboarding-tour/styles.css";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
@@ -11,6 +11,7 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results";
@@ -45,9 +46,11 @@ export default async function AppsPage(props: AppsPageProps) {
title={t("page.list.title")}
primaryAction={
canCreate ? (
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
</MobileAffixButton>
<TourTarget id="manage-apps-create">
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
</MobileAffixButton>
</TourTarget>
) : undefined
}
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
@@ -56,11 +59,13 @@ export default async function AppsPage(props: AppsPageProps) {
>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
<Stack gap="sm">
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</Stack>
<TourTarget id="manage-apps-list">
<Stack gap="sm">
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</Stack>
</TourTarget>
)}
</ManagePageLayout>
);
@@ -28,6 +28,7 @@ import { getScopedI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
@@ -42,18 +43,26 @@ export default async function ManageBoardsPage() {
return (
<ManagePageLayout
title={t("title")}
primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined}
primaryAction={
canCreateBoards ? (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
) : undefined
}
floatingPrimaryAction={canCreateBoards}
>
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
{boards.length > 0 && (
<Grid>
{boards.map((board) => (
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
<BoardCard board={board} />
</GridCol>
))}
</Grid>
<TourTarget id="manage-boards-list">
<Grid>
{boards.map((board) => (
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
<BoardCard board={board} />
</GridCol>
))}
</Grid>
</TourTarget>
)}
</ManagePageLayout>
);
@@ -29,6 +29,7 @@ import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results";
@@ -60,14 +61,18 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
title={t("page.list.title")}
primaryAction={
canCreateIntegrations ? (
<MobileAffixButton component={Link} href="/manage/integrations/new">
{t("action.create")}
</MobileAffixButton>
<TourTarget id="manage-integrations-create">
<MobileAffixButton component={Link} href="/manage/integrations/new">
{t("action.create")}
</MobileAffixButton>
</TourTarget>
) : undefined
}
floatingPrimaryAction={canCreateIntegrations}
>
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
<TourTarget id="manage-integrations-list">
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
</TourTarget>
</ManagePageLayout>
);
}
+24 -1
View File
@@ -10,6 +10,7 @@ import {
IconBrandTablerFilled,
IconCertificate,
IconClipboardListFilled,
IconDatabaseExport,
IconDirectionsFilled,
IconGitFork,
IconHelpSquareRoundedFilled,
@@ -28,6 +29,7 @@ import {
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { createDocumentationLink } from "@homarr/definitions";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
@@ -36,6 +38,7 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
export default async function ManageLayout({ children }: PropsWithChildren) {
const t = await getScopedI18n("management.navbar");
@@ -45,11 +48,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.home"),
icon: IconHomeFilled,
href: "/manage",
"data-onboarding-tour-id": "manage-welcome",
},
{
icon: IconLayoutDashboardFilled,
href: "/manage/boards",
label: t("items.boards"),
"data-onboarding-tour-id": "manage-boards",
},
{
icon: IconBox,
@@ -59,12 +64,14 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
iconProps: {
strokeWidth: 2.5,
},
"data-onboarding-tour-id": "manage-apps",
},
{
icon: IconAffiliateFilled,
href: "/manage/integrations",
label: t("items.integrations"),
hidden: !session,
"data-onboarding-tour-id": "manage-integrations",
},
{
icon: IconSearch,
@@ -74,17 +81,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
iconProps: {
strokeWidth: 2.5,
},
"data-onboarding-tour-id": "manage-search-engines",
},
{
icon: IconPhotoFilled,
href: "/manage/medias",
label: t("items.medias"),
hidden: !session,
"data-onboarding-tour-id": "manage-medias",
},
{
icon: IconUserFilled,
label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-users",
items: [
{
label: t("items.users.items.manage"),
@@ -146,6 +156,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/tools/tasks",
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",
},
],
},
{
@@ -153,6 +169,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/settings",
icon: IconSettingsFilled,
hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-settings",
},
{
label: t("items.help.label"),
@@ -191,11 +208,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
];
return (
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const shell = (
<ClientShell hasNavigation>
<MainHeader></MainHeader>
<MainNavigation links={navigationLinks}></MainNavigation>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
);
if (!session) return shell;
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
}
@@ -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>
);
}
@@ -1,23 +1,26 @@
"use client";
import { useRouter } from "next/navigation";
import type { MantineColor } from "@mantine/core";
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
import {
IconCategoryPlus,
IconDots,
IconFileText,
IconPlayerPlay,
IconPlayerStop,
IconRefresh,
IconRotateClockwise,
IconTrash,
} from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap } from "@homarr/docker/shared";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -27,25 +30,30 @@ import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
const createColumns = (
t: TranslationFunction,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
{
accessorKey: "name",
header: t("docker.field.name.label"),
Cell({ renderedCellValue, row }) {
return (
<Group gap="xs">
<Group gap="xs" wrap="nowrap">
<Avatar
variant="outline"
radius="lg"
radius="sm"
size="md"
styles={{ image: { objectFit: "contain" } }}
src={row.original.iconUrl}
>
{row.original.name.at(0)?.toUpperCase()}
</Avatar>
<Text>{renderedCellValue}</Text>
<Text truncate="end" maw={500}>
{renderedCellValue}
</Text>
</Group>
);
},
@@ -76,11 +84,11 @@ const createColumns = (
maxSize: 200,
Cell({ renderedCellValue, cell }) {
return (
<Box maw={200}>
<Text truncate="end" title={cell.row.original.image}>
{renderedCellValue}
</Text>
</Box>
<Tooltip label={cell.row.original.image} multiline maw={400}>
<Box maw={200}>
<Text truncate="end">{renderedCellValue}</Text>
</Box>
</Tooltip>
);
},
},
@@ -95,13 +103,57 @@ const createColumns = (
);
},
},
{
id: "cpuUsage",
accessorKey: "cpuUsage",
size: 120,
header: t("docker.field.stats.cpu.label"),
enableHiding: true,
sortingFn: (rowA, rowB) => {
const cpuUsageA = safeValue(rowA.original.cpuUsage);
const cpuUsageB = safeValue(rowB.original.cpuUsage);
return cpuUsageA - cpuUsageB;
},
Cell({ row }) {
const cpuUsage = safeValue(row.original.cpuUsage);
return (
<Text size="sm" c={cpuUsageColor(cpuUsage, row.original.state)}>
{cpuUsage.toFixed(2)}%
</Text>
);
},
},
{
id: "memoryUsage",
accessorKey: "memoryUsage",
size: 140,
header: t("docker.field.stats.memory.label"),
enableHiding: true,
sortingFn: (rowA, rowB) => {
const memoryUsageA = safeValue(rowA.original.memoryUsage);
const memoryUsageB = safeValue(rowB.original.memoryUsage);
return memoryUsageA - memoryUsageB;
},
Cell({ row }) {
const bytesUsage = safeValue(row.original.memoryUsage);
return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
</Text>
);
},
},
];
export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) {
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
initialData,
initialData: { containers, timestamp },
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
@@ -131,6 +183,8 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
enableColumnFilters: false,
enablePagination: false,
enableRowSelection: true,
enableRowActions: true,
positionActionsColumn: "last",
positionToolbarAlertBanner: "top",
enableTableFooter: false,
enableBottomToolbar: false,
@@ -141,7 +195,10 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
autoFocus: true,
},
initialState: { density: "xs", showGlobalFilter: true },
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
renderTopToolbarCustomActions: () => (
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
{tDocker("action.refresh.label")}
@@ -174,28 +231,104 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
);
}
const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon: TablerIcon; color: MantineColor }[] = [
{ action: "start", icon: IconPlayerPlay, color: "green" },
{ action: "stop", icon: IconPlayerStop, color: "red" },
{ action: "restart", icon: IconRotateClockwise, color: "orange" },
{ action: "remove", icon: IconTrash, color: "red" },
];
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
const t = useScopedI18n("docker.action");
const router = useRouter();
const utils = clientApi.useUtils();
const { openModal } = useModalAction(AddDockerAppToHomarr);
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
clientApi.docker[`${action}All`].useMutation({
async onSettled() {
await utils.docker.getContainers.invalidate();
},
onSuccess() {
showSuccessNotification({
title: t(`${action}.notification.success.title`),
message: t(`${action}.notification.success.message`),
});
},
onError() {
showErrorNotification({
title: t(`${action}.notification.error.title`),
message: t(`${action}.notification.error.message`),
});
},
});
const mutations = {
start: useContainerAction("start"),
stop: useContainerAction("stop"),
restart: useContainerAction("restart"),
remove: useContainerAction("remove"),
};
return (
<Menu withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" onClick={(e) => e.stopPropagation()}>
<IconDots size="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconFileText size="1rem" />}
onClick={() => router.push(createContainerLogsPath(container))}
>
{t("logs.label")}
</Menu.Item>
<Menu.Divider />
{containerActions.map(({ action, icon: Icon, color }) => (
<Menu.Item
key={action}
leftSection={<Icon size="1rem" />}
color={color}
onClick={() => mutations[action].mutate({ ids: [container.id] })}
>
{t(`${action}.label`)}
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item
leftSection={<IconCategoryPlus size="1rem" />}
color="red"
onClick={() => openModal({ selectedContainers: [container] })}
>
{t("addToHomarr.label")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
interface ContainerActionBarProps {
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
selectedContainers: DockerContainer[];
}
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
const t = useScopedI18n("docker.action");
const { openModal } = useModalAction(AddDockerAppToHomarr);
const handleClick = () => {
openModal({
selectedContainers,
});
};
const selectedIds = selectedContainers.map((container) => container.id);
return (
<Group gap="xs">
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light">
{containerActions.map(({ action, icon, color }) => (
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
))}
<Button
leftSection={<IconCategoryPlus />}
color="red"
onClick={() => openModal({ selectedContainers })}
variant="light"
>
{t("addToHomarr.label")}
</Button>
</Group>
@@ -0,0 +1,10 @@
"use client";
import dynamic from "next/dynamic";
export const ClientSideDockerLogsTerminal = dynamic(
() => import("./terminal").then(({ DockerLogsTerminal }) => DockerLogsTerminal),
{
ssr: false,
},
);
@@ -0,0 +1,47 @@
import { notFound } from "next/navigation";
import { Box, Group } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import "@xterm/xterm/css/xterm.css";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { ClientSideDockerLogsTerminal } from "./client";
interface DockerContainerLogsPageProps {
params: Promise<{ containerId: string }>;
searchParams: Promise<{ name?: string }>;
}
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER)) {
notFound();
}
const { containerId } = await params;
const { name } = await searchParams;
try {
await api.docker.logs({ id: containerId, tail: 1 });
} catch {
notFound();
}
return (
<>
<Group justify="space-between" align="center" wrap="nowrap">
<DynamicBreadcrumb
dynamicMappings={new Map([[containerId, name ?? containerId]])}
nonInteractable={[containerId]}
/>
</Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideDockerLogsTerminal containerId={containerId} />
</Box>
</>
);
}
@@ -0,0 +1,3 @@
.outerTerminal > div {
height: 100%;
}
@@ -0,0 +1,81 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { Box } from "@mantine/core";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { clientApi } from "@homarr/api/client";
import classes from "./terminal.module.css";
interface DockerLogsTerminalProps {
containerId: string;
}
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
const ref = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const pendingLogsRef = useRef<string[]>([]);
const isTerminalReadyRef = useRef(false);
const writeToTerminal = useCallback((data: string) => {
if (!isTerminalReadyRef.current || !terminalRef.current) {
pendingLogsRef.current.push(data);
return;
}
terminalRef.current.write(data);
terminalRef.current.refresh(0, terminalRef.current.rows - 1);
}, []);
clientApi.docker.subscribeLogs.useSubscription(
{ id: containerId, tail: 200 },
{
onData: writeToTerminal,
onError(err) {
writeToTerminal(`\r\n\x1b[31m${err.message}\x1b[0m`);
},
},
);
useEffect(() => {
if (!ref.current) {
return () => undefined;
}
const canvasAddon = new CanvasAddon();
terminalRef.current = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize: 14,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
isTerminalReadyRef.current = true;
const terminal = terminalRef.current;
pendingLogsRef.current.forEach((data) => terminal.write(data));
pendingLogsRef.current = [];
terminal.refresh(0, terminal.rows - 1);
setTimeout(() => {
const fitAddon = new FitAddon();
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
return () => {
isTerminalReadyRef.current = false;
pendingLogsRef.current = [];
canvasAddon.dispose();
terminalRef.current?.dispose();
terminalRef.current = null;
};
}, [writeToTerminal]);
return <Box ref={ref} className={classes.outerTerminal} h="100%" />;
};
@@ -57,8 +57,9 @@ export const TerminalComponent = () => {
});
return () => {
terminalRef.current?.dispose();
canvasAddon.dispose();
terminalRef.current?.dispose();
terminalRef.current = null;
};
}, []);
@@ -0,0 +1,53 @@
"use client";
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
export const ResetTours = () => {
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
onSuccess() {
showSuccessNotification({
title: t("resetSuccess.title"),
message: t("resetSuccess.message"),
});
void utils.user.getTourStatus.invalidate();
},
});
const tours = [
{ key: "management" as const, completed: tourStatus?.completedManageTour ?? false },
{ key: "dashboard" as const, completed: tourStatus?.completedBoardTour ?? false },
];
return (
<Stack gap="xs">
<Group gap="xs">
{tours.map(({ key, completed }) => (
<Badge key={key} color={completed ? "green" : "gray"} variant="light">
{t(`name.${key}`)}: {completed ? t("status.completed") : t("status.notStarted")}
</Badge>
))}
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Button
leftSection={<IconRefresh size={16} />}
variant="light"
size="sm"
w="fit-content"
onClick={() => resetTours()}
loading={isPending}
>
{t("reset")}
</Button>
</Stack>
);
};
@@ -13,6 +13,7 @@ import { canAccessUserEditPage } from "../access";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { ResetTours } from "./_components/_reset-tours";
interface Props {
params: Promise<{
@@ -87,6 +88,13 @@ export default async function EditUserPage(props: Props) {
</Box>
</Group>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
<ResetTours />
</Stack>
)}
<DangerZoneRoot>
<DangerZoneItem
label={t("user.action.delete.label")}
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
import { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access";
@@ -26,7 +26,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
const session = await auth();
const t = await getI18n();
const tUser = await getScopedI18n("management.page.user");
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound).catch(catchTrpcUnauthorized);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
import { canAccessUserEditPage } from "../access";
import { ChangePasswordForm } from "./_components/_change-password-form";
@@ -23,7 +23,8 @@ export default async function UserSecurityPage(props: Props) {
.getById({
userId: params.userId,
})
.catch(catchTrpcNotFound);
.catch(catchTrpcNotFound)
.catch(catchTrpcUnauthorized);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
@@ -6,6 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { createMetaTitle } from "~/metadata";
@@ -38,14 +39,18 @@ export default async function UsersPage() {
title={t("list.title")}
primaryAction={
credentialsProviderEnabled ? (
<MobileAffixButton component={Link} href="/manage/users/create">
{t("create.title")}
</MobileAffixButton>
<TourTarget id="manage-users-create">
<MobileAffixButton component={Link} href="/manage/users/create">
{t("create.title")}
</MobileAffixButton>
</TourTarget>
) : undefined
}
floatingPrimaryAction={credentialsProviderEnabled}
>
<UserListComponent initialUserList={userList} />
<TourTarget id="manage-users-list">
<UserListComponent initialUserList={userList} />
</TourTarget>
</ManagePageLayout>
);
}
@@ -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 });
}
}
+12
View File
@@ -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 };
};
+7 -2
View File
@@ -6,6 +6,7 @@ import { Link } from "@homarr/ui";
import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
@@ -25,11 +26,15 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
</Group>
<DesktopSearchInput />
<TourTarget id="board-search">
<DesktopSearchInput />
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{actions}
<MobileSearchButton />
<UserButton />
<TourTarget id="board-user-menu">
<UserButton />
</TourTarget>
</Group>
</Group>
<Spotlight />
@@ -0,0 +1,11 @@
"use client";
import type { ReactNode } from "react";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box } from "@mantine/core";
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
<OnboardingTour.Target id={id}>
<Box data-tour-target={id}>{children}</Box>
</OnboardingTour.Target>
);
@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { NavLink } from "@mantine/core";
import { Link } from "@homarr/ui";
@@ -9,9 +10,15 @@ import { Link } from "@homarr/ui";
export const CommonNavLink = (props: ClientNavigationLink) =>
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
if (!id) return <>{children}</>;
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
};
const NavLinkHref = (props: NavigationLinkHref) => {
const pathname = usePathname();
return props.external ? (
const tourId = props["data-onboarding-tour-id"];
const link = props.external ? (
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
) : (
<NavLink
@@ -19,26 +26,29 @@ const NavLinkHref = (props: NavigationLinkHref) => {
label={props.label}
leftSection={props.icon}
href={props.href}
active={pathname === props.href}
active={pathname === props.href || pathname.startsWith(`${props.href}/`)}
/>
);
return <TourTarget id={tourId}>{link}</TourTarget>;
};
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const pathname = usePathname();
const isActive = props.items.some((item) => item.href === pathname);
return (
const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
const nav = (
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
{props.items.map((item) => (
<NavLinkHref key={item.label} {...item} />
))}
</NavLink>
);
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
};
interface CommonNavigationLinkProps {
label: string;
icon: ReactNode;
"data-onboarding-tour-id"?: string;
}
interface NavigationLinkHref extends CommonNavigationLinkProps {
@@ -27,7 +27,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
return null;
}
const { icon: TablerIcon, iconProps, ...props } = link;
const { icon: TablerIcon, iconProps, "data-onboarding-tour-id": tourId, ...props } = link;
const Icon =
typeof TablerIcon === "string" ? (
<Image src={TablerIcon} w={20} h={20} />
@@ -38,17 +38,20 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
if ("items" in props) {
clientLink = {
...props,
"data-onboarding-tour-id": tourId,
items: props.items
.filter((item) => !item.hidden)
.map((item) => {
const { "data-onboarding-tour-id": itemTourId, ...rest } = item;
return {
...item,
...rest,
"data-onboarding-tour-id": itemTourId,
icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
};
}),
} as ClientNavigationLink;
} else {
clientLink = props as ClientNavigationLink;
clientLink = { ...props, "data-onboarding-tour-id": tourId } as ClientNavigationLink;
}
return <CommonNavLink key={index} {...clientLink} icon={Icon} />;
})}
@@ -63,6 +66,7 @@ interface CommonNavigationLinkProps {
icon: TablerIcon | string;
iconProps?: TablerIconProps;
hidden?: boolean;
"data-onboarding-tour-id"?: string;
}
interface NavigationLinkHref extends CommonNavigationLinkProps {
@@ -58,19 +58,23 @@ export const DynamicBreadcrumb = ({
}
const href = `/${pathnameParts.slice(0, index + 1).join("/")}`;
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
const mappedValue = dynamicMappings?.get(pathnamePart);
if (mappedValue) {
if (nonInteractable?.includes(pathnamePart)) {
return <Text key={href}>{mappedValue}</Text>;
}
return (
<Anchor key={href} href={href}>
{mappedValue}
</Anchor>
);
}
if (nonInteractable?.includes(pathnamePart)) {
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
}
if (dynamicMappings?.has(pathnamePart)) {
return (
<Anchor key={href} href={href}>
{dynamicMappings.get(pathnamePart)}
</Anchor>
);
}
return (
<Anchor key={href} href={href}>
{t(`${translationKey}.label` as TranslationKeys)}
@@ -0,0 +1,108 @@
"use client";
import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { TourShell } from "./tour-shell";
import { TourStepContent } from "./tour-step-content";
export const BoardTourProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("onboardingTour.board");
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
onSuccess() {
void utils.user.getTourStatus.invalidate();
},
});
const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
const handleEnd = () => {
utils.user.getTourStatus.setData(undefined, {
completedManageTour: tourStatus?.completedManageTour ?? false,
completedBoardTour: true,
});
completeTour({ tour: "board" });
};
const steps = useMemo(() => {
const allSteps: (OnboardingTourStep & { requiresChangeAccess?: boolean })[] = [
{
id: "board-search",
title: t("welcome.title"),
content: (
<TourStepContent
description={t("welcome.description")}
documentationHref={createDocumentationLink(
"/docs/getting-started/after-the-installation",
"#creating-your-first-board",
)}
/>
),
},
{
id: "board-edit-mode",
title: t("editMode.title"),
requiresChangeAccess: true,
content: (
<TourStepContent
description={t("editMode.description")}
documentationHref={createDocumentationLink(
"/docs/getting-started/after-the-installation",
"#arrange-and-organize-your-board",
)}
/>
),
},
{
id: "board-settings",
title: t("settings.title"),
requiresChangeAccess: true,
content: (
<TourStepContent
description={t("settings.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
/>
),
},
{
id: "board-switcher",
title: t("switcher.title"),
content: (
<TourStepContent
description={t("switcher.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
/>
),
},
{
id: "board-user-menu",
title: t("userMenu.title"),
content: (
<TourStepContent
description={t("userMenu.description")}
documentationHref={createDocumentationLink("/docs/management/users")}
/>
),
},
];
return allSteps.filter((step) => !step.requiresChangeAccess || hasChangeAccess) as OnboardingTourStep[];
}, [hasChangeAccess, t]);
return (
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
{children}
</TourShell>
);
};
@@ -0,0 +1,220 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { useMediaQuery } from "@mantine/hooks";
import {
IconAffiliateFilled,
IconAppsFilled,
IconLayoutDashboardFilled,
IconPlus,
IconUserFilled,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { TourShell } from "./tour-shell";
import { TourStepContent } from "./tour-step-content";
interface ManageTourProviderProps extends PropsWithChildren {
isAdmin: boolean;
}
const stepRoutes: Record<string, string> = {
"manage-welcome": "/manage",
"manage-boards-list": "/manage/boards",
"manage-boards-create": "/manage/boards",
"manage-apps-list": "/manage/apps",
"manage-apps-create": "/manage/apps",
"manage-integrations-list": "/manage/integrations",
"manage-integrations-create": "/manage/integrations",
"manage-users-list": "/manage/users",
"manage-users-create": "/manage/users",
};
const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
withReveal: false,
popoverProps: {
position: "bottom",
middlewares: { shift: { padding: 16 }, flip: true },
},
};
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
const t = useScopedI18n("onboardingTour.manage");
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
onSuccess() {
void utils.user.getTourStatus.invalidate();
},
});
const isMobile = useMediaQuery("(max-width: 48em)");
const pathname = usePathname();
const router = useRouter();
const isManageHome = /^(\/[^/]+)?\/manage\/?$/.test(pathname);
const isManageSection = /^(\/[^/]+)?\/manage(\/.*)?$/.test(pathname);
const [tourActive, setTourActive] = useState(false);
const [tourDismissed, setTourDismissed] = useState(false);
const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour);
useEffect(() => {
if (tourDismissed) return;
if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
setTourActive(true);
}
}, [isManageHome, tourStatus, isMobile, tourDismissed]);
useEffect(() => {
if (!isManageSection) {
setTourActive(false);
}
}, [isManageSection]);
useEffect(() => {
const wasCompleted = wasManageTourCompletedRef.current;
const isCompleted = tourStatus?.completedManageTour;
if (wasCompleted === true && isCompleted === false) {
setTourDismissed(false);
}
wasManageTourCompletedRef.current = isCompleted;
}, [tourStatus?.completedManageTour]);
const handleEnd = useCallback(() => {
setTourDismissed(true);
setTourActive(false);
utils.user.getTourStatus.setData(undefined, {
completedManageTour: true,
completedBoardTour: tourStatus?.completedBoardTour ?? false,
});
completeTour({ tour: "manage" });
router.push("/manage");
}, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
const steps = useMemo(() => {
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
{
id: "manage-welcome",
title: t("welcome.title"),
content: (
<TourStepContent
description={t("welcome.description")}
documentationHref={createDocumentationLink("/docs/category/management")}
/>
),
},
{
id: "manage-boards-list",
title: t("boardsList.title"),
content: (
<TourStepContent
description={t("boardsList.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
icon={<IconLayoutDashboardFilled size={18} />}
/>
),
},
{
id: "manage-boards-create",
title: t("boardsCreate.title"),
content: (
<TourStepContent
description={t("boardsCreate.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
icon={<IconPlus size={18} />}
/>
),
},
{
id: "manage-apps-list",
title: t("appsList.title"),
content: (
<TourStepContent
description={t("appsList.description")}
documentationHref={createDocumentationLink("/docs/management/apps")}
icon={<IconAppsFilled size={18} />}
/>
),
},
{
id: "manage-apps-create",
title: t("appsCreate.title"),
content: (
<TourStepContent
description={t("appsCreate.description")}
documentationHref={createDocumentationLink("/docs/management/apps")}
icon={<IconPlus size={18} />}
/>
),
},
{
id: "manage-integrations-list",
title: t("integrationsList.title"),
content: (
<TourStepContent
description={t("integrationsList.description")}
documentationHref={createDocumentationLink("/docs/management/integrations")}
icon={<IconAffiliateFilled size={18} />}
/>
),
},
{
id: "manage-integrations-create",
title: t("integrationsCreate.title"),
content: (
<TourStepContent
description={t("integrationsCreate.description")}
documentationHref={createDocumentationLink("/docs/management/integrations")}
icon={<IconPlus size={18} />}
/>
),
},
{
id: "manage-users-list",
title: t("usersList.title"),
adminOnly: true,
focusRevealProps: usersStepFocusRevealProps,
content: (
<TourStepContent
description={t("usersList.description")}
documentationHref={createDocumentationLink("/docs/management/users")}
icon={<IconUserFilled size={18} />}
/>
),
},
{
id: "manage-users-create",
title: t("usersCreate.title"),
adminOnly: true,
focusRevealProps: usersStepFocusRevealProps,
content: (
<TourStepContent
description={t("usersCreate.description")}
documentationHref={createDocumentationLink("/docs/management/users")}
icon={<IconPlus size={18} />}
/>
),
},
];
return allSteps.filter((step) => !step.adminOnly || isAdmin) as OnboardingTourStep[];
}, [isAdmin, t]);
return (
<TourShell
steps={steps}
started={tourActive}
onEnd={handleEnd}
stepRoutes={stepRoutes}
position={{ base: "bottom", sm: "right" }}
>
{children}
</TourShell>
);
};
@@ -0,0 +1,197 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import type { OnboardingTourController, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
import type { FloatingPosition } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
interface TourShellProps extends PropsWithChildren {
steps: OnboardingTourStep[];
started: boolean;
onEnd: () => void;
stepRoutes?: Record<string, string>;
position?: FloatingPosition | Record<string, FloatingPosition>;
}
type TourController = OnboardingTourController;
const stepIndexFromController = (controller: TourController) => controller.currentStepIndex ?? 0;
const stepAtOffset = (controller: TourController, offset: number) => {
const index = stepIndexFromController(controller) + offset;
return controller.tour[index];
};
const pollForElement = (targetId: string, callback: () => void) => {
const interval = setInterval(() => {
const targetElement = document.querySelector(`[data-tour-target="${targetId}"]`);
if (targetElement) {
clearInterval(interval);
callback();
}
}, 50);
const timeout = setTimeout(() => clearInterval(interval), 5000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
};
const isTypingTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
};
interface TourForwardButtonProps {
label: string;
onClick: () => void;
}
const TourForwardButton = ({ label, onClick }: TourForwardButtonProps) => {
return (
<Button size="sm" variant="light" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
{label}
</Button>
);
};
interface TourDoneButtonProps {
label: string;
onClick: () => void;
}
const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
return (
<Button size="sm" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
{label}
</Button>
);
};
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
const t = useI18n();
const router = useRouter();
const forwardActionRef = useRef<(() => void) | null>(null);
const navigateAndAdvance = useCallback(
(currentStepId: string | undefined, targetStepId: string | undefined, advance: () => void) => {
if (!stepRoutes || !targetStepId) {
advance();
return;
}
const currentRoute = currentStepId ? stepRoutes[currentStepId] : undefined;
const targetRoute = stepRoutes[targetStepId];
if (targetRoute && targetRoute !== currentRoute) {
router.push(targetRoute);
pollForElement(targetStepId, advance);
} else {
advance();
}
},
[router, stepRoutes],
);
const bindForwardAction = useCallback((controller: TourController, action: () => void) => {
forwardActionRef.current = action;
return action;
}, []);
useEffect(() => {
if (!started) {
forwardActionRef.current = null;
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
if (isTypingTarget(event.target)) return;
event.preventDefault();
forwardActionRef.current?.();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [started]);
return (
<OnboardingTour
tour={steps}
started={started}
onOnboardingTourEnd={onEnd}
withStepper={false}
header={(controller) => (
<>
<Group justify="center" gap={4} py={4}>
<Text size="sm" fw={600}>
{stepIndexFromController(controller) + 1}
</Text>
<Text size="sm" c="dimmed">
/
</Text>
<Text size="sm" c="dimmed">
{controller.tour.length}
</Text>
</Group>
{stepIndexFromController(controller) === 0 && (
<Center py="xs">
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" />
</Center>
)}
</>
)}
nextStepNavigation={(controller) => {
const nextStep = stepAtOffset(controller, 1);
const action = bindForwardAction(controller, () => {
navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep());
});
return <TourForwardButton label={t("onboardingTour.next")} onClick={action} />;
}}
endStepNavigation={(controller) => {
const action = bindForwardAction(controller, () => controller.endTour());
return <TourDoneButton label={t("onboardingTour.done")} onClick={action} />;
}}
prevStepNavigation={(controller) => (
<Button
size="sm"
variant="default"
onClick={() => {
const prevStep = stepAtOffset(controller, -1);
navigateAndAdvance(controller.currentStep?.id, prevStep?.id, () => controller.prevStep());
}}
>
{t("onboardingTour.prev")}
</Button>
)}
skipNavigation={(controller) => (
<Button size="sm" variant="subtle" color="gray" onClick={() => controller.skipTour()}>
{t("onboardingTour.skip")}
</Button>
)}
withPrevButton
focusRevealProps={{
disableTargetInteraction: true,
popoverProps: {
position: position ?? { base: "bottom", sm: "right" },
width: 420,
shadow: "xl",
radius: "lg",
middlewares: { shift: { padding: 16 }, flip: true },
},
}}
cutoutPadding={12}
cutoutRadius={12}
>
{children}
</OnboardingTour>
);
};
@@ -0,0 +1,39 @@
"use client";
import type { ReactNode } from "react";
import { Anchor, Divider, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface TourStepContentProps {
description: string;
documentationHref: string;
icon?: ReactNode;
}
export const TourStepContent = ({ description, documentationHref, icon }: TourStepContentProps) => {
const t = useI18n();
return (
<Stack gap="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
{icon && (
<ThemeIcon variant="light" size="lg" radius="md" mt={2}>
{icon}
</ThemeIcon>
)}
<Text size="sm" lh={1.7} style={{ flex: 1 }}>
{description}
</Text>
</Group>
<Divider />
<Anchor href={documentationHref} target="_blank" size="xs" fw={500}>
<Group gap={4} wrap="nowrap">
<IconExternalLink size={14} />
{t("onboardingTour.learnMore")}
</Group>
</Anchor>
</Stack>
);
};
@@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
window.location.assign(logoutUrl);
return;
}
router.refresh();
router.push("/auth/login");
},
});
}, [logoutUrl, openModal, router]);
+1
View File
@@ -2,3 +2,4 @@ declare module "*.png";
declare module "*.svg";
declare module "*.jpeg";
declare module "*.jpg";
declare module "sql.js";
+17 -10
View File
@@ -249,13 +249,20 @@
</a>
</td>
<td align="center">
<a href="null" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg" alt="PeaNUT" width="90" height="90" />
<br/>
<p align="center">PeaNUT</p>
</a>
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
<br/>
<p align="center">Pi-hole</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
<br/>
@@ -296,15 +303,15 @@
<br/>
<p align="center">Radarr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/readarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg" alt="Readarr" width="90" height="90" />
<br/>
<p align="center">Readarr</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
<br/>
@@ -345,15 +352,15 @@
<br/>
<p align="center">Speedtest<br/>Tracker</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
<br/>
<p align="center">Tdarr</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
<br/>
+1 -1
View File
@@ -23,7 +23,7 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
client_max_body_size 32M;
client_max_body_size 256M;
}
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.61.1",
"version": "1.64.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -78,7 +78,7 @@
"overrides": {
"@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.7",
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
"axios@>=1.0.0 <1.8.2": ">=1.17.0",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
"esbuild@<=0.24.2": ">=0.28.0",
@@ -88,13 +88,13 @@
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.24.6",
"react-is": "^19.2.6",
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
"react-is": "^19.2.7",
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
"sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
"tmp@<=0.2.3": ">=0.2.7",
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
"vite@>=5.0.0 <=5.4.18": ">=8.0.16"
},
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch",
+19 -17
View File
@@ -1604,22 +1604,24 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
collapsed: collapseStates.at(0)?.collapsed ?? false,
}),
),
items: items.map(({ integrations: itemIntegrations, ...item }) =>
parseItem({
...item,
layouts: item.layouts.map((layout) => ({
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
),
items: items
.map(({ integrations: itemIntegrations, ...item }) =>
parseItem({
...item,
layouts: item.layouts.map((layout) => ({
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
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);
if (!result.success) {
throw new Error(result.error.message);
return null;
}
return result.data;
};
@@ -4,7 +4,11 @@ import { z } from "zod/v4";
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
import { DockerSingleton } from "@homarr/docker";
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
import {
dockerContainersRequestHandler,
getContainerLogsAsync,
streamContainerLogsAsync,
} from "@homarr/request-handler/docker";
import { dockerMiddleware } from "../../middlewares/docker";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
@@ -104,6 +108,84 @@ export const dockerRouter = createTRPCRouter({
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
logs: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.input(
z.object({
id: z.string(),
tail: z.number().min(1).max(1000).optional(),
}),
)
.query(async ({ input }) => {
const logs = await getContainerLogsAsync(input.id, input.tail ?? 200);
if (!logs) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Container not found",
});
}
return {
logs,
};
}),
subscribeLogs: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.input(
z.object({
id: z.string(),
tail: z.number().min(1).max(1000).optional(),
}),
)
.subscription(({ input }) => {
return observable<string>((emit) => {
let cleanupFn: (() => void) | undefined;
let isSubscribed = true;
const initializeStreamAsync = async () => {
try {
cleanupFn = await streamContainerLogsAsync(
input.id,
input.tail ?? 200,
(data) => {
if (isSubscribed) {
emit.next(data);
}
},
(err) => {
if (isSubscribed) {
emit.error(
new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: err.message,
}),
);
}
},
);
} catch (err) {
if (isSubscribed) {
emit.error(
new TRPCError({
code: "NOT_FOUND",
message: err instanceof Error ? err.message : "Container not found",
}),
);
}
}
};
void initializeStreamAsync();
return () => {
isSubscribed = false;
cleanupFn?.();
};
});
}),
});
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
@@ -13,6 +13,14 @@ import { dockerRouter } from "../../docker/docker-router";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("@homarr/request-handler/docker", () => ({
getContainerLogsAsync: async () => {
await Promise.resolve();
return "logs";
},
streamContainerLogsAsync: async () => {
await Promise.resolve();
return () => undefined;
},
dockerContainersRequestHandler: {
handler: () => ({
getCachedOrUpdatedDataAsync: async () => {
@@ -65,6 +73,8 @@ const validInputs: {
restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] },
invalidate: undefined,
logs: { id: "1", tail: 200 },
subscribeLogs: { id: "1", tail: 200 },
};
describe("All procedures should only be accessible for users with admin permission", () => {
+45
View File
@@ -32,6 +32,7 @@ import {
import { convertIntersectionToZodObject } from "../schema-merger";
import {
createTRPCRouter,
isDemoMode,
onboardingProcedure,
permissionRequiredProcedure,
protectedProcedure,
@@ -278,6 +279,8 @@ export const userRouter = createTRPCRouter({
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
completedManageTour: true,
completedBoardTour: true,
}),
)
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
@@ -304,6 +307,8 @@ export const userRouter = createTRPCRouter({
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
completedManageTour: true,
completedBoardTour: true,
},
where: eq(users.id, input.userId),
});
@@ -605,6 +610,46 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.id));
}),
completeTour: protectedProcedure
.input(z.object({ tour: z.enum(["manage", "board"]) }))
.mutation(async ({ input, ctx }) => {
const columnByTour = {
manage: { completedManageTour: true },
board: { completedBoardTour: true },
} as const;
await ctx.db.update(users).set(columnByTour[input.tour]).where(eq(users.id, ctx.session.user.id));
}),
resetTours: protectedProcedure.mutation(async ({ ctx }) => {
await ctx.db
.update(users)
.set({
completedManageTour: false,
completedBoardTour: false,
})
.where(eq(users.id, ctx.session.user.id));
}),
getTourStatus: protectedProcedure.query(async ({ ctx }) => {
if (isDemoMode) {
return {
completedManageTour: true,
completedBoardTour: true,
};
}
const user = await ctx.db.query.users.findFirst({
columns: {
completedManageTour: true,
completedBoardTour: true,
},
where: eq(users.id, ctx.session.user.id),
});
return {
completedManageTour: user?.completedManageTour ?? false,
completedBoardTour: user?.completedBoardTour ?? false,
};
}),
});
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
@@ -0,0 +1,2 @@
ALTER TABLE `user` ADD `completed_manage_tour` boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `user` ADD `completed_board_tour` boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -274,6 +274,13 @@
"when": 1775400000000,
"tag": "0038_add_user_ddg_bangs",
"breakpoints": true
},
{
"idx": 39,
"version": "5",
"when": 1779199104477,
"tag": "0039_add_user_onboarding_tour_status",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE "user" ADD COLUMN "completed_manage_tour" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "completed_board_tour" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -36,6 +36,13 @@
"when": 1775400000000,
"tag": "0004_add_user_ddg_bangs",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1779199097716,
"tag": "0005_add_user_onboarding_tour_status",
"breakpoints": true
}
]
}
+10 -1
View File
@@ -41,7 +41,16 @@ import {
} from "../schema";
import type { Integration } from "../schema";
const isTruthyEnv = (value: string | undefined) =>
["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
export const seedDataAsync = async (db: Database) => {
if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) {
console.warn(
"UNSAFE_ENABLE_MOCK_INTEGRATION is enabled: mock integration is available in the UI. Disable by setting UNSAFE_ENABLE_MOCK_INTEGRATION=false.",
);
}
await seedEveryoneGroupAsync(db);
await seedOnboardingAsync(db);
await seedServerSettingsAsync(db);
@@ -51,7 +60,7 @@ export const seedDataAsync = async (db: Database) => {
await seedDefaultBoardAsync(db);
await seedBoardWidgetsAsync(db);
if (["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase())) {
if (isTruthyEnv(process.env.DEMO_MODE)) {
await seedDemoUserAsync(db);
}
};
@@ -0,0 +1,2 @@
ALTER TABLE `user` ADD `completed_manage_tour` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `user` ADD `completed_board_tour` integer DEFAULT false NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -260,6 +260,13 @@
"when": 1775400000000,
"tag": "0036_add_user_ddg_bangs",
"breakpoints": true
},
{
"idx": 37,
"version": "6",
"when": 1779199046805,
"tag": "0037_add_user_onboarding_tour_status",
"breakpoints": true
}
]
}
+2
View File
@@ -80,6 +80,8 @@ export const users = mysqlTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
});
export const accounts = mysqlTable(
+2
View File
@@ -79,6 +79,8 @@ export const users = pgTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
});
export const accounts = pgTable(
+2
View File
@@ -62,6 +62,8 @@ export const users = sqliteTable("user", {
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
});
export const accounts = sqliteTable(
@@ -157,6 +157,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/anchor"
| "/docs/integrations/aria2"
| "/docs/integrations/codeberg"
| "/docs/integrations/coolify"
| "/docs/integrations/dash-dot"
| "/docs/integrations/deluge"
| "/docs/integrations/docker-hub"
@@ -181,6 +182,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/open-media-vault"
| "/docs/integrations/opnsense"
| "/docs/integrations/overseerr"
| "/docs/integrations/peanut"
| "/docs/integrations/pi-hole"
| "/docs/integrations/plex"
| "/docs/integrations/prowlarr"
@@ -217,6 +219,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/bookmarks"
| "/docs/widgets/calendar"
| "/docs/widgets/clock"
| "/docs/widgets/coolify"
| "/docs/widgets/dns-hole-controls"
| "/docs/widgets/dns-hole-summary"
| "/docs/widgets/docker-containers"
@@ -247,6 +250,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/timetable"
| "/docs/widgets/tracearr"
| "/docs/widgets/umami"
| "/docs/widgets/ups"
| "/docs/widgets/video"
| "/docs/widgets/weather"
| ""
+1 -2
View File
@@ -364,7 +364,7 @@ export const integrationDefs = {
},
truenas: {
name: "TrueNAS",
secretKinds: [["username", "password"]],
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
@@ -383,7 +383,6 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg",
category: ["healthMonitoring"],
// @ts-expect-error - docs page will be created when integration is merged
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
defaultPort: 8000,
},
+20
View File
@@ -11,3 +11,23 @@ export const containerStateColorMap = {
removing: "pink",
dead: "dark",
} satisfies Record<ContainerState, MantineColor>;
export const memoryUsageColor = (number: number, state: string): MantineColor => {
const mbUsage = number / 1024 / 1024;
if (mbUsage === 0 && state !== "running") return "red";
if (mbUsage < 128) return "green";
if (mbUsage < 256) return "yellow";
if (mbUsage < 512) return "orange";
return "red";
};
export const cpuUsageColor = (number: number, state: string): MantineColor => {
if (number === 0 && state !== "running") return "red";
if (number < 40) return "green";
if (number < 60) return "yellow";
if (number < 90) return "orange";
return "red";
};
export const safeValue = (value?: number, fallback = 0): number =>
value !== undefined && !isNaN(value) ? value : fallback;
+2
View File
@@ -47,12 +47,14 @@
"timezones-ical-library": "catalog:",
"tsdav": "catalog:",
"undici": "catalog:",
"ws": "catalog:",
"xml2js": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-unifi": "catalog:",
"@types/ws": "catalog:",
"@types/xml2js": "catalog:",
"typescript": "catalog:"
}
@@ -85,6 +85,16 @@ export abstract class Integration {
return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams);
}
protected webSocketUrl(
path: `/${string}`,
queryParams?: Record<string, string | Date | number | boolean | null | undefined>,
) {
const url = this.url(path, queryParams);
// http -> ws, https -> wss
url.protocol = url.protocol.replace("http", "ws");
return url;
}
public async testConnectionAsync(): Promise<TestingResult> {
try {
const url = new URL(this.integration.url);
@@ -0,0 +1,79 @@
import { describe, expect, test } from "vitest";
import { trueNasApis } from "../truenas-api";
describe("trueNasApis descriptors", () => {
test("expose the expected endpoints and handshake requirements", () => {
expect(trueNasApis.jsonRpc).toMatchObject({
kind: "jsonRpc",
path: "/api/current",
requiresSessionHandshake: false,
});
expect(trueNasApis.legacy).toMatchObject({
kind: "legacy",
path: "/websocket",
requiresSessionHandshake: true,
});
});
});
describe("jsonRpc api", () => {
const { buildRequest, matchResponse } = trueNasApis.jsonRpc;
test("builds a JSON-RPC 2.0 envelope", () => {
expect(buildRequest("id-1", "system.info", [])).toStrictEqual({
jsonrpc: "2.0",
id: "id-1",
method: "system.info",
params: [],
});
});
test("matches a result for the requested id", () => {
const result = matchResponse({ jsonrpc: "2.0", id: "id-1", result: true }, "id-1");
expect(result).toStrictEqual({ result: true });
});
test("returns the error when the response carries one", () => {
const error = { code: -32001, message: "Not authenticated" };
expect(matchResponse({ jsonrpc: "2.0", id: "id-1", error }, "id-1")).toStrictEqual({ error });
});
test("ignores responses for a different id", () => {
expect(matchResponse({ jsonrpc: "2.0", id: "other", result: true }, "id-1")).toBeUndefined();
});
test("ignores non JSON-RPC messages", () => {
expect(matchResponse({ msg: "result", id: "id-1", result: true }, "id-1")).toBeUndefined();
});
});
describe("legacy api", () => {
const { buildRequest, matchResponse } = trueNasApis.legacy;
test("builds a DDP method envelope", () => {
expect(buildRequest("id-1", "auth.login", ["user", "pass"])).toStrictEqual({
id: "id-1",
msg: "method",
method: "auth.login",
params: ["user", "pass"],
});
});
test("matches a result message for the requested id", () => {
expect(matchResponse({ msg: "result", id: "id-1", result: false }, "id-1")).toStrictEqual({ result: false });
});
test("returns the error when the result message carries one", () => {
const error = { error: 13, reason: "boom" };
expect(matchResponse({ msg: "result", id: "id-1", error }, "id-1")).toStrictEqual({ error });
});
test("ignores responses for a different id", () => {
expect(matchResponse({ msg: "result", id: "other", result: true }, "id-1")).toBeUndefined();
});
test("ignores non-result messages such as the session handshake", () => {
expect(matchResponse({ msg: "connected", session: "abc" }, "id-1")).toBeUndefined();
});
});
@@ -0,0 +1,264 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { IntegrationRequestError } from "../../base/errors/http/integration-request-error";
import type { IntegrationSecret } from "../../base/types";
const ws = vi.hoisted(() => {
const sentPayloads: Record<string, unknown>[] = [];
const constructedUrls: string[] = [];
// A failure with a `code` emits a coded TLS-style error; without one it mimics a missing endpoint (HTTP 404).
const failures: { path: string; code?: string }[] = [];
let responder: (method: string, params: unknown[]) => unknown = () => undefined;
type Listener = (...args: unknown[]) => void;
class FakeWebSocket {
static readonly OPEN = 1;
public readyState = FakeWebSocket.OPEN;
private readonly listeners: Record<string, Listener[]> = {};
constructor(url: URL | string) {
constructedUrls.push(url.toString());
setTimeout(() => {
const failure = failures.find((candidate) => url.toString().includes(candidate.path));
if (failure) {
const error: Error & { code?: string } = new Error(failure.code ?? "Unexpected server response: 404");
if (failure.code) error.code = failure.code;
this.dispatch("error", error);
} else {
this.dispatch("open");
}
}, 0);
}
on(event: string, listener: Listener) {
(this.listeners[event] ??= []).push(listener);
return this;
}
once(event: string, listener: Listener) {
const wrapper: Listener = (...args) => {
this.off(event, wrapper);
listener(...args);
};
return this.on(event, wrapper);
}
off(event: string, listener: Listener) {
this.listeners[event] = (this.listeners[event] ?? []).filter((candidate) => candidate !== listener);
return this;
}
send(raw: string) {
const payload = JSON.parse(raw) as Record<string, unknown>;
sentPayloads.push(payload);
if (payload.msg === "connect") {
this.respond(JSON.stringify({ msg: "connected", session: "test-session" }));
return;
}
const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
const envelope =
payload.jsonrpc === "2.0"
? { jsonrpc: "2.0", id: payload.id, result }
: { msg: "result", id: payload.id, result };
this.respond(JSON.stringify(envelope));
}
close() {
this.readyState = 3;
}
private respond(message: string) {
setTimeout(() => this.dispatch("message", message), 0);
}
private dispatch(event: string, ...args: unknown[]) {
// `off` replaces the array rather than mutating it, so iterating it directly is safe.
for (const listener of this.listeners[event] ?? []) listener(...args);
}
}
return {
FakeWebSocket,
sentPayloads,
constructedUrls,
failures,
setResponder: (fn: (method: string, params: unknown[]) => unknown) => {
responder = fn;
},
};
});
vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket }));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
}));
vi.mock("@homarr/core/infrastructure/http", () => ({
createCustomCheckServerIdentity: () => () => undefined,
}));
import { TrueNasIntegration } from "../truenas-integration";
const happyResponder = (method: string) => {
switch (method) {
case "auth.login":
case "auth.login_with_api_key":
return true;
case "system.info":
return {
version: "TrueNAS-SCALE-24.10",
hostname: "nas",
physmem: 16_000_000_000,
model: "Intel Xeon",
uptime_seconds: 3600,
};
case "reporting.get_data":
return [
{
name: "cpu",
identifier: "cpu",
aggregations: emptyAggregations(),
start: 0,
end: 1,
legend: [],
data: [[1000, 12]],
},
{
name: "memory",
identifier: "memory",
aggregations: emptyAggregations(),
start: 0,
end: 1,
legend: [],
data: [[1000, 8_000_000_000]],
},
{
name: "cputemp",
identifier: "cputemp",
aggregations: emptyAggregations(),
start: 0,
end: 1,
legend: [],
data: [[1000, 45]],
},
];
case "pool.query":
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
case "interface.query":
return [{ id: "eth0", name: "eth0" }];
case "reporting.netdata_get_data":
return [{ name: "interface", identifier: "eth0", data: [[1000, 100, 200]] }];
default:
return null;
}
};
const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
let integrationCounter = 0;
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
new TrueNasIntegration({
id: `truenas-${(integrationCounter += 1)}`,
name: "TrueNAS Test",
url: "https://truenas.local",
externalUrl: null,
decryptedSecrets,
});
const credentials: IntegrationSecret[] = [
{ kind: "username", value: "root" },
{ kind: "password", value: "secret" },
];
describe("TrueNasIntegration", () => {
beforeEach(() => {
ws.sentPayloads.length = 0;
ws.constructedUrls.length = 0;
ws.failures.length = 0;
ws.setResponder(happyResponder);
});
test("fetches and maps system info over the JSON-RPC API", async () => {
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(ws.constructedUrls).toContain("wss://truenas.local/api/current");
// JSON-RPC does not use the legacy "connect" session handshake.
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
expect(result).toMatchObject({
version: "TrueNAS-SCALE-24.10",
cpuModelName: "Intel Xeon",
cpuTemp: 45,
memAvailableInBytes: 16_000_000_000,
memUsedInBytes: 8_000_000_000,
uptime: 3600,
rebootRequired: false,
availablePkgUpdates: 0,
loadAverage: null,
gpu: [],
network: { up: 20_000, down: 10_000 },
fileSystem: [{ deviceName: "tank", available: "1000", used: "500", percentage: 50 }],
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
});
});
test("authenticates with an API key when one is configured", async () => {
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
await integration.getSystemInfoAsync();
const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."));
expect(authPayload).toMatchObject({ method: "auth.login_with_api_key", params: ["api-key-123"] });
});
test("authenticates with username and password when no API key is configured", async () => {
const integration = createIntegration(credentials);
await integration.getSystemInfoAsync();
const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."));
expect(authPayload).toMatchObject({ method: "auth.login", params: ["root", "secret"] });
});
test("falls back to the legacy websocket API when the JSON-RPC endpoint is unavailable", async () => {
ws.failures.push({ path: "/api/current" });
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(ws.constructedUrls).toContain("wss://truenas.local/api/current");
expect(ws.constructedUrls).toContain("wss://truenas.local/websocket");
// The legacy API requires the "connect" session handshake before authenticating.
expect(ws.sentPayloads[0]).toMatchObject({ msg: "connect" });
expect(ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."))).toMatchObject({
msg: "method",
method: "auth.login",
});
expect(result.version).toBe("TrueNAS-SCALE-24.10");
});
test("rejects when authentication is not successful", async () => {
ws.setResponder((method) => (method.startsWith("auth.") ? false : happyResponder(method)));
const integration = createIntegration(credentials);
await expect(integration.getSystemInfoAsync()).rejects.toThrow();
});
test("surfaces a self-signed certificate as a certificate request error without falling back", async () => {
ws.failures.push({ path: "/api/current", code: "DEPTH_ZERO_SELF_SIGNED_CERT" });
ws.failures.push({ path: "/websocket", code: "DEPTH_ZERO_SELF_SIGNED_CERT" });
const integration = createIntegration(credentials);
const error = await integration.getSystemInfoAsync().catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(IntegrationRequestError);
expect((error as IntegrationRequestError).cause).toMatchObject({ type: "certificate", reason: "untrusted" });
// A certificate failure is endpoint-independent, so the legacy API must not be tried.
expect(ws.constructedUrls).not.toContain("wss://truenas.local/websocket");
});
});
@@ -0,0 +1,50 @@
export type TrueNasApiKind = "jsonRpc" | "legacy";
export type TrueNasResponse = { result: unknown } | { error: unknown };
export interface TrueNasApi {
kind: TrueNasApiKind;
/** Websocket path appended to the integration URL. */
path: `/${string}`;
/** The legacy DDP API requires a "connect" handshake before authentication, the JSON-RPC API does not. */
requiresSessionHandshake: boolean;
/** Builds the request envelope for a method call. */
buildRequest: (id: string, method: string, params: unknown[]) => Record<string, unknown>;
/**
* Returns the response payload when `message` is the reply to `id`,
* otherwise `undefined` so the caller keeps waiting for a later message.
*/
matchResponse: (message: Record<string, unknown>, id: string) => TrueNasResponse | undefined;
}
/**
* JSON-RPC 2.0 API introduced in TrueNAS SCALE 24.10 (Electric Eel) and the
* legacy DDP-style websocket API used before it. Both expose the same
* middleware methods, so only the transport envelope differs.
* @see https://www.truenas.com/docs/api/scale_rest_api.html
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
export const trueNasApis = {
jsonRpc: {
kind: "jsonRpc",
path: "/api/current",
requiresSessionHandshake: false,
buildRequest: (id: string, method: string, params: unknown[]) => ({ jsonrpc: "2.0", id, method, params }),
matchResponse: (message: Record<string, unknown>, id: string) => {
if (message.id !== id || message.jsonrpc !== "2.0") return undefined;
if (message.error != null) return { error: message.error };
return { result: message.result };
},
},
legacy: {
kind: "legacy",
path: "/websocket",
requiresSessionHandshake: true,
buildRequest: (id: string, method: string, params: unknown[]) => ({ id, msg: "method", method, params }),
matchResponse: (message: Record<string, unknown>, id: string) => {
if (message.id !== id || message.msg !== "result") return undefined;
if (message.error != null) return { error: message.error };
return { result: message.result };
},
},
} satisfies Record<TrueNasApiKind, TrueNasApi>;
@@ -0,0 +1,315 @@
import type { RawData } from "ws";
import { WebSocket } from "ws";
import z from "zod";
import { createId } from "@homarr/common";
import { matchErrorCode, RequestError, ResponseError } from "@homarr/common/server";
import {
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/core/infrastructure/certificates";
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import type { TrueNasApi } from "./truenas-api";
import { trueNasApis } from "./truenas-api";
const logger = createLogger({ module: "trueNasClient" });
const REQUEST_TIMEOUT_MS = 5000;
type CertificateOptions = IntegrationTestingInput["options"];
export type TrueNasCredentials = { apiKey: string } | { username: string; password: string };
/**
* Handles talking to a TrueNAS server: opening a websocket with Homarr's
* trusted certificates, negotiating the API variant, authenticating, and
* sending method requests. Connections are reused across the per-request
* integration instances and reconnected transparently when the server drops
* an idle socket.
*/
export class TrueNasClient {
// Keyed by integration id. The value is the in-flight connection promise so concurrent
// callers reuse a single socket instead of racing to open duplicates.
private static readonly connectionMap = new Map<string, Promise<TrueNasSocket>>();
private static readonly apiMap = new Map<string, TrueNasApi>();
constructor(
private readonly id: string,
private readonly webSocketUrl: (path: `/${string}`) => URL,
private readonly credentials: TrueNasCredentials,
) {}
/** Sends an authenticated request, reusing the cached connection while it is open. */
public async requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
const socket = await this.getSocketAsync();
return socket.requestAsync(method, params);
}
/**
* Opens a connection with the provided certificate options, authenticates,
* and closes it. Used by the connection test, which may run with updated
* credentials, so any cached connection is dropped and the API re-detected.
*/
public async testAsync(certificate: CertificateOptions): Promise<void> {
this.evictConnection();
TrueNasClient.apiMap.delete(this.id);
const socket = await this.openSocketAsync(certificate);
try {
await this.authenticateAsync(socket);
} finally {
socket.close();
}
}
private getSocketAsync(): Promise<TrueNasSocket> {
let connection = TrueNasClient.connectionMap.get(this.id);
if (!connection) {
connection = this.connectAndAuthenticateAsync();
TrueNasClient.connectionMap.set(this.id, connection);
const evict = () => {
if (TrueNasClient.connectionMap.get(this.id) === connection) {
TrueNasClient.connectionMap.delete(this.id);
}
};
// Reconnect on the next request once this attempt fails or the server drops the socket.
connection.then((socket) => socket.onClose(evict), evict);
}
return connection;
}
private async connectAndAuthenticateAsync(): Promise<TrueNasSocket> {
const socket = await this.openSocketAsync(await resolveCertificateOptionsAsync());
try {
await this.authenticateAsync(socket);
} catch (error) {
// Never keep an unauthenticated socket, it would be reused without re-authenticating.
socket.close();
throw error;
}
return socket;
}
private evictConnection() {
const connection = TrueNasClient.connectionMap.get(this.id);
if (!connection) return;
TrueNasClient.connectionMap.delete(this.id);
connection.then((socket) => socket.close()).catch(() => undefined);
}
/**
* Connects using the JSON-RPC API (TrueNAS 24.10+) when available and falls
* back to the legacy websocket API otherwise. A missing JSON-RPC endpoint
* surfaces as a plain protocol error; certificate, connection, DNS and
* timeout errors are endpoint-independent and are never retried.
*/
private async openSocketAsync(certificate: CertificateOptions): Promise<TrueNasSocket> {
const cachedApi = TrueNasClient.apiMap.get(this.id);
if (cachedApi) {
return openTrueNasSocketAsync(this.webSocketUrl(cachedApi.path), cachedApi, certificate);
}
try {
const socket = await openTrueNasSocketAsync(
this.webSocketUrl(trueNasApis.jsonRpc.path),
trueNasApis.jsonRpc,
certificate,
);
TrueNasClient.apiMap.set(this.id, trueNasApis.jsonRpc);
return socket;
} catch (error) {
if (error instanceof RequestError) throw error;
logger.debug("JSON-RPC API unavailable, falling back to the legacy websocket API", {
error: error instanceof Error ? error.message : undefined,
});
const socket = await openTrueNasSocketAsync(
this.webSocketUrl(trueNasApis.legacy.path),
trueNasApis.legacy,
certificate,
);
TrueNasClient.apiMap.set(this.id, trueNasApis.legacy);
return socket;
}
}
/**
* Authenticates with an API key when one is configured, otherwise with the
* username and password. Both methods work on either API.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
private async authenticateAsync(socket: TrueNasSocket): Promise<void> {
const response =
"apiKey" in this.credentials
? await socket.requestAsync("auth.login_with_api_key", [this.credentials.apiKey])
: await socket.requestAsync("auth.login", [this.credentials.username, this.credentials.password]);
const success = await z.boolean().parseAsync(response);
if (!success) throw new ResponseError({ status: 401 });
}
}
/**
* Wraps a single websocket connection and speaks whichever TrueNAS API
* ({@link trueNasApis}) was negotiated when it was opened.
*/
class TrueNasSocket {
constructor(
private readonly webSocket: WebSocket,
private readonly api: TrueNasApi,
) {}
public close() {
this.webSocket.close();
}
public onClose(listener: () => void) {
this.webSocket.once("close", listener);
}
/**
* The legacy DDP API requires a "connect" handshake to obtain a session
* before any method can be called.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
public registerSessionAsync(): Promise<void> {
return new Promise((resolve, reject) => {
const handler = (raw: RawData) => {
const message = parseMessage(raw);
if (message?.msg === "connected") {
this.webSocket.off("message", handler);
resolve();
} else if (message?.msg === "failed") {
this.webSocket.off("message", handler);
reject(new Error("Unable to establish TrueNAS session"));
}
};
this.webSocket.on("message", handler);
this.webSocket.send(JSON.stringify({ msg: "connect", version: "1", support: ["1"] }));
});
}
/**
* Sends a method request and resolves with its result. Rejects after
* {@link REQUEST_TIMEOUT_MS} when no matching response was received.
*/
public requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
return new Promise((resolve, reject) => {
const id = createId();
const cleanup = () => {
clearTimeout(timeoutId);
this.webSocket.off("message", handler);
this.webSocket.off("close", onClose);
this.webSocket.off("error", onError);
};
const handler = (raw: RawData) => {
const message = parseMessage(raw);
if (!message) return;
const response = this.api.matchResponse(message, id);
if (!response) return;
cleanup();
if ("error" in response) {
reject(new Error(`TrueNAS method "${method}" failed: ${JSON.stringify(response.error)}`));
return;
}
logger.debug("Received method response", { id, method, api: this.api.kind });
resolve(response.result);
};
// A dropped connection would otherwise leave the request hanging until the timeout below.
const onClose = () => {
cleanup();
reject(
new RequestError(
{ type: "connection", reason: "reset", code: "ECONNRESET" },
{ cause: new Error("Connection closed before a response was received") },
),
);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const timeoutId = setTimeout(() => {
cleanup();
reject(
new RequestError(
{
type: "timeout",
reason: "aborted",
code: "ECONNABORTED",
},
{ cause: new Error("Canceled request after 5 seconds") },
),
);
}, REQUEST_TIMEOUT_MS);
this.webSocket.on("message", handler);
this.webSocket.once("close", onClose);
this.webSocket.once("error", onError);
logger.debug("Sending method request", { id, method, api: this.api.kind });
this.webSocket.send(JSON.stringify(this.api.buildRequest(id, method, params)));
});
}
}
/**
* Opens a websocket using Homarr's trusted certificates and performs the
* session handshake when the API requires it. TLS, connection, DNS and timeout
* failures are translated into {@link RequestError}s so the connection test can
* offer to trust a self-signed certificate, exactly like the HTTP integrations.
*/
const openTrueNasSocketAsync = async (
url: URL,
api: TrueNasApi,
certificate: CertificateOptions,
): Promise<TrueNasSocket> => {
logger.debug("Connecting to TrueNAS websocket", { url: url.toString(), api: api.kind });
const webSocket = await new Promise<WebSocket>((resolve, reject) => {
const socket = new WebSocket(url, {
ca: certificate.ca,
// The @types/ws contract types this as returning a boolean, but ws forwards it to
// tls.connect which uses Node's `Error | undefined` contract that our helper follows.
checkServerIdentity: certificate.checkServerIdentity as never,
});
socket.once("open", () => resolve(socket));
socket.once("error", (error: Error & { code?: string }) => {
const matched = error.code ? matchErrorCode(error.code) : undefined;
reject(matched ? new RequestError(matched, { cause: error }) : error);
});
});
const socket = new TrueNasSocket(webSocket, api);
if (api.requiresSessionHandshake) await socket.registerSessionAsync();
return socket;
};
const resolveCertificateOptionsAsync = async (): Promise<CertificateOptions> => ({
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
});
const parseMessage = (raw: RawData): Record<string, unknown> | undefined => {
try {
const parsed: unknown = JSON.parse(raw.toString());
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
};
@@ -1,8 +1,6 @@
import dayjs from "dayjs";
import z from "zod";
import { createId } from "@homarr/common";
import { RequestError, ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
@@ -10,252 +8,21 @@ import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
import type { TrueNasCredentials } from "./truenas-client";
import { TrueNasClient } from "./truenas-client";
const logger = createLogger({ module: "trueNasIntegration" });
const NETWORK_MULTIPLIER = 100;
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
private static webSocketMap = new Map<string, WebSocket>();
private wsUrl() {
const url = super.url("/websocket");
url.protocol = url.protocol.replace("http", "ws");
return url;
}
private get webSocket() {
return TrueNasIntegration.webSocketMap.get(this.integration.id) ?? null;
}
protected async testingAsync(_input: IntegrationTestingInput): Promise<TestingResult> {
const webSocket = await this.connectWebSocketAsync();
await this.registerSessionAsync(webSocket);
await this.authenticateWebSocketAsync(webSocket);
// Remove current socket connection so we can authenticate with updated credentials
TrueNasIntegration.webSocketMap.delete(this.integration.id);
private client?: TrueNasClient;
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await this.getClient().testAsync(input.options);
return { success: true };
}
/**
* TrueNAS API uses WebSocket. This function connects to the socket
* and resolves the promise if the connection was successful.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html
*/
private async connectWebSocketAsync(): Promise<WebSocket> {
logger.debug("Connecting to websocket server", {
url: this.wsUrl(),
});
const webSocket = new WebSocket(this.wsUrl());
return new Promise((resolve, reject) => {
webSocket.onopen = () => {
logger.debug("Connected to websocket server", {
url: this.wsUrl(),
});
resolve(webSocket);
};
webSocket.onerror = () => {
reject(new Error("Failed to connect"));
};
});
}
/**
* Before authentication, a session must be obtained from the server using the "connect" event.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
private async registerSessionAsync(webSocket: WebSocket): Promise<void> {
return new Promise((resolve, reject) => {
const subscribe = (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as { msg: string };
if (data.msg === "connected") {
webSocket.removeEventListener("message", subscribe);
resolve();
} else if (data.msg === "failed") {
webSocket.removeEventListener("message", subscribe);
reject(new Error("Unable to establish connection"));
}
};
webSocket.addEventListener("message", subscribe);
webSocket.send(
JSON.stringify({
msg: "connect",
version: "1", // this must be number, not string
support: ["1"], // this must be number, not string
}),
);
});
}
/**
* After a session was obtained, the session can be authenticated.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
private async authenticateWebSocketAsync(webSocket?: WebSocket): Promise<void> {
logger.debug("Authenticating with username and password", {
url: this.wsUrl(),
});
const response = await this.requestAsync(
"auth.login",
[this.getSecretValue("username"), this.getSecretValue("password")],
webSocket,
);
const result = await z.boolean().parseAsync(response);
if (!result) throw new ResponseError({ status: 401 });
logger.debug("Authenticated successfully with username and password", {
url: this.wsUrl(),
});
}
private async getPoolsAsync() {
logger.debug("Retrieving pools", {
url: this.wsUrl(),
});
const response = await this.requestAsync("pool.query", [
[],
{
extra: {
is_upgraded: true,
},
},
]);
const result = await poolSchema.parseAsync(response);
// Offline / exported pools have null values for allocated, size and free, so we filter them out
// See https://github.com/homarr-labs/homarr/issues/5194
const activePools = result
.map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return {
...pool,
allocated: pool.allocated,
size: pool.size,
free: pool.free,
};
}
return null;
})
.filter((pool) => pool !== null);
logger.debug("Retrieved pools", {
url: this.wsUrl(),
totalCount: result.length,
activeCount: activePools.length,
});
return activePools;
}
/**
* Retrieves data using the reporting method
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingAsync(): Promise<ReportingItem[]> {
logger.debug("Retrieving reporting data", {
url: this.wsUrl(),
});
const response = await this.requestAsync("reporting.get_data", [
[
{
name: "cpu",
},
{
name: "memory",
},
{
name: "cputemp",
},
],
{
aggregate: true,
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await z.array(reportingItemSchema).parseAsync(response);
logger.debug("Retrieved reporting data", {
url: this.wsUrl(),
count: result.length,
});
return result;
}
/**
* Retrieves a list of all available network interfaces
* @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface
*/
private async getNetworkInterfacesAsync(): Promise<z.infer<typeof networkInterfaceSchema>> {
logger.debug("Retrieving available network-interfaces", {
url: this.wsUrl(),
});
const response = await this.requestAsync("interface.query", [
[], // no filters
{},
]);
const result = await networkInterfaceSchema.parseAsync(response);
logger.debug("Retrieved available network-interfaces", {
url: this.wsUrl(),
count: result.length,
});
return result;
}
/**
* Retrieves reporting network data of the last 5 minutes
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingNetdataAsync(): Promise<z.infer<typeof reportingNetDataSchema>> {
const networkInterfaces = await this.getNetworkInterfacesAsync();
logger.debug("Retrieving reporting network data", {
url: this.wsUrl(),
});
const response = await this.requestAsync("reporting.netdata_get_data", [
networkInterfaces.map((networkInterface) => ({
name: "interface",
identifier: networkInterface.id,
})),
{
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await reportingNetDataSchema.parseAsync(response);
logger.debug("Retrieved reporting-network-data", {
url: this.wsUrl(),
count: result.length,
});
return result;
}
/**
* Retrieves information about the system
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#system
*/
private async getSystemInformationAsync(): Promise<z.infer<typeof systemInfoSchema>> {
logger.debug("Retrieving system-information", {
url: this.wsUrl(),
});
const response = await this.requestAsync("system.info");
const result = await systemInfoSchema.parseAsync(response);
logger.debug("Retrieved system-information", {
url: this.wsUrl(),
});
return result;
}
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const systemInformation = await this.getSystemInformationAsync();
const reporting = await this.getReportingAsync();
@@ -272,7 +39,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
return {
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
cpuTemp: Math.max(...cpuTempData.filter((_item, i) => i > 0)),
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
memAvailableInBytes: systemInformation.physmem,
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
fileSystem: datasets.map((dataset) => ({
@@ -301,87 +68,164 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
};
}
/**
* Send a request through websocket and return response
* Times out after 5 seconds when no response was received.
* @param method json-rpc method to call
* @param params array of parameters
* @param webSocketOverride override of webSocket, helpful for not storing the connection
* @returns result of json-rpc call
*/
private async requestAsync(method: string, params: unknown[] = [], webSocketOverride?: WebSocket) {
let webSocket = webSocketOverride ?? this.webSocket;
if (webSocket?.readyState !== WebSocket.OPEN) {
logger.debug("Connecting to websocket", {
url: this.wsUrl(),
});
// We can only land here with static webSocket
webSocket = await this.connectWebSocketAsync();
await this.registerSessionAsync(webSocket);
TrueNasIntegration.webSocketMap.set(this.integration.id, webSocket);
await this.authenticateWebSocketAsync();
}
return await new Promise((resolve, reject) => {
const id = createId();
const handler = (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as Record<string, unknown>;
if (data.msg !== "result") return;
if (data.id !== id) return;
clearTimeout(timeoutId);
webSocket.removeEventListener("message", handler);
logger.debug("Received method response", {
id,
method,
url: this.wsUrl(),
});
resolve(data.result);
};
const timeoutId = setTimeout(() => {
webSocket.removeEventListener("message", handler);
reject(
new RequestError(
{
type: "timeout",
reason: "aborted",
code: "ECONNABORTED",
},
{ cause: new Error("Canceled request after 5 seconds") },
),
);
}, 5000);
webSocket.addEventListener("message", handler);
logger.debug("Sending method request", {
id,
method,
url: this.wsUrl(),
});
webSocket.send(
JSON.stringify({
id,
msg: "method",
method,
params,
}),
);
});
private getClient(): TrueNasClient {
return (this.client ??= this.createClient());
}
private extractNetworkTrafficData = (data: z.infer<typeof reportingNetDataSchema>, index = 1 | 2) => {
private createClient(): TrueNasClient {
const credentials: TrueNasCredentials = this.hasSecretValue("apiKey")
? { apiKey: this.getSecretValue("apiKey") }
: { username: this.getSecretValue("username"), password: this.getSecretValue("password") };
return new TrueNasClient(this.integration.id, (path) => this.webSocketUrl(path), credentials);
}
private async requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
return this.getClient().requestAsync(method, params);
}
private async getPoolsAsync() {
logger.debug("Retrieving pools", { url: this.integration.url });
const response = await this.requestAsync("pool.query", [
[],
{
extra: {
is_upgraded: true,
},
},
]);
const result = await poolSchema.parseAsync(response);
// Offline / exported pools have null values for allocated, size and free, so we filter them out
// See https://github.com/homarr-labs/homarr/issues/5194
const activePools = result
.map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return {
...pool,
allocated: pool.allocated,
size: pool.size,
free: pool.free,
};
}
return null;
})
.filter((pool) => pool !== null);
logger.debug("Retrieved pools", {
url: this.integration.url,
totalCount: result.length,
activeCount: activePools.length,
});
return activePools;
}
/**
* Retrieves data using the reporting method
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingAsync(): Promise<ReportingItem[]> {
logger.debug("Retrieving reporting data", { url: this.integration.url });
const response = await this.requestAsync("reporting.get_data", [
[
{
name: "cpu",
},
{
name: "memory",
},
{
name: "cputemp",
},
],
{
aggregate: true,
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await z.array(reportingItemSchema).parseAsync(response);
logger.debug("Retrieved reporting data", {
url: this.integration.url,
count: result.length,
});
return result;
}
/**
* Retrieves a list of all available network interfaces
* @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface
*/
private async getNetworkInterfacesAsync(): Promise<z.infer<typeof networkInterfaceSchema>> {
logger.debug("Retrieving available network-interfaces", { url: this.integration.url });
const response = await this.requestAsync("interface.query", [
[], // no filters
{},
]);
const result = await networkInterfaceSchema.parseAsync(response);
logger.debug("Retrieved available network-interfaces", {
url: this.integration.url,
count: result.length,
});
return result;
}
/**
* Retrieves reporting network data of the last 5 minutes
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingNetdataAsync(): Promise<z.infer<typeof reportingNetDataSchema>> {
const networkInterfaces = await this.getNetworkInterfacesAsync();
logger.debug("Retrieving reporting network data", { url: this.integration.url });
const response = await this.requestAsync("reporting.netdata_get_data", [
networkInterfaces.map((networkInterface) => ({
name: "interface",
identifier: networkInterface.id,
})),
{
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await reportingNetDataSchema.parseAsync(response);
logger.debug("Retrieved reporting-network-data", {
url: this.integration.url,
count: result.length,
});
return result;
}
/**
* Retrieves information about the system
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#system
*/
private async getSystemInformationAsync(): Promise<z.infer<typeof systemInfoSchema>> {
logger.debug("Retrieving system-information", { url: this.integration.url });
const response = await this.requestAsync("system.info");
const result = await systemInfoSchema.parseAsync(response);
logger.debug("Retrieved system-information", { url: this.integration.url });
return result;
}
private extractNetworkTrafficData = (data: z.infer<typeof reportingNetDataSchema>, index: 1 | 2) => {
return data.reduce((acc, current) => acc + (current.data.at(-1)?.at(index) ?? 0), 0);
};
private extractLatestReportingData(data: ReportingItem[], key: ReportingItem["identifier"]) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const dataObject = data.find((item) => item.identifier === key)!;
// TODO: check why the below sorting is done, because right now it compares number[] with number[]?
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return dataObject.data.sort((item1, item2) => (item1 > item2 ? -1 : 1))[0]!;
private extractLatestReportingData(data: ReportingItem[], identifier: ReportingItem["identifier"]) {
const item = data.find((reportingItem) => reportingItem.identifier === identifier);
// Reporting rows are [timestamp, ...values] in ascending time order, so the last row is the most recent reading.
const latest = item?.data.at(-1);
if (!latest) throw new Error(`TrueNAS returned no "${identifier}" reporting data`);
return latest;
}
}
@@ -0,0 +1,53 @@
import { describe, expect, test } from "vitest";
import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
const encodeDockerLogFrame = (streamType: number, message: string) => {
const payload = Buffer.from(message, "utf-8");
const header = Buffer.alloc(8);
header.writeUInt8(streamType, 0);
header.writeUInt32BE(payload.length, 4);
return Buffer.concat([header, payload]);
};
describe("decodeDockerLogs", () => {
test("decodes multiplexed docker log frames", () => {
const logs = Buffer.concat([encodeDockerLogFrame(1, "hello "), encodeDockerLogFrame(2, "world")]);
expect(decodeDockerLogs(logs)).toBe("hello world");
});
test("returns plain text logs unchanged", () => {
expect(decodeDockerLogs("plain text log")).toBe("plain text log");
});
test("falls back to raw buffer text for non-multiplexed output", () => {
expect(decodeDockerLogs(Buffer.from("raw docker output", "utf-8"))).toBe("raw docker output");
});
});
describe("createDockerLogStreamProcessor", () => {
test("emits decoded chunks from a stream", () => {
const messages: string[] = [];
const processChunk = createDockerLogStreamProcessor(
(data) => messages.push(data),
() => undefined,
1024,
);
const chunk = encodeDockerLogFrame(1, "streamed log");
expect(processChunk(chunk)).toBe(true);
expect(messages).toEqual(["streamed log"]);
});
test("returns false when message size exceeds limit", () => {
const processChunk = createDockerLogStreamProcessor(
() => undefined,
() => undefined,
4,
);
const chunk = encodeDockerLogFrame(1, "this message is too long");
expect(processChunk(chunk)).toBe(false);
});
});
@@ -0,0 +1,89 @@
const DOCKER_STREAM_HEADER_SIZE = 8;
const DOCKER_STREAM_TYPES = new Set([1, 2]);
export const decodeDockerLogs = (logs: Buffer | string) => {
if (typeof logs === "string") {
return logs;
}
if (logs.length === 0) {
return "";
}
let cursor = 0;
const parts: string[] = [];
while (cursor < logs.length) {
if (cursor + DOCKER_STREAM_HEADER_SIZE > logs.length) {
break;
}
const streamType = logs.readUInt8(cursor);
if (!DOCKER_STREAM_TYPES.has(streamType)) {
return logs.toString("utf-8");
}
const length = logs.readUInt32BE(cursor + 4);
const start = cursor + DOCKER_STREAM_HEADER_SIZE;
const end = start + length;
if (end > logs.length) {
break;
}
parts.push(logs.subarray(start, end).toString("utf-8"));
cursor = end;
}
const decoded = parts.join("");
if (decoded.length === 0) {
return logs.toString("utf-8");
}
return decoded;
};
export const createDockerLogStreamProcessor = (
onData: (data: string) => void,
onError: (err: Error) => void,
maxMessageSize: number,
) => {
let buffer = Buffer.alloc(0);
const processChunk = (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length > 0) {
if (buffer.length < DOCKER_STREAM_HEADER_SIZE) {
break;
}
const streamType = buffer.readUInt8(0);
if (!DOCKER_STREAM_TYPES.has(streamType)) {
onData(buffer.toString("utf-8"));
buffer = Buffer.alloc(0);
break;
}
const length = buffer.readUInt32BE(4);
if (length > maxMessageSize) {
onError(
new Error(`Docker log message size (${length} bytes) exceeds maximum allowed (${maxMessageSize} bytes)`),
);
return false;
}
const fullMessageSize = DOCKER_STREAM_HEADER_SIZE + length;
if (buffer.length < fullMessageSize) {
break;
}
const payload = buffer.subarray(DOCKER_STREAM_HEADER_SIZE, fullMessageSize);
onData(payload.toString("utf-8"));
buffer = buffer.subarray(fullMessageSize);
}
return true;
};
return processChunk;
};
+81 -3
View File
@@ -1,5 +1,6 @@
import type { Readable } from "node:stream";
import dayjs from "dayjs";
import type { ContainerInfo, ContainerStats } from "dockerode";
import type { Container, ContainerInfo, ContainerStats } from "dockerode";
import type Dockerode from "dockerode";
import { bestMatch } from "@homarr/common";
@@ -11,6 +12,7 @@ import { extractContainerImageName } from "@homarr/definitions";
import type { ContainerState, Port } from "@homarr/docker";
import { dockerLabels, DockerSingleton } from "@homarr/docker";
import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
const logger = createLogger({ module: "dockerRequestHandler" });
@@ -183,6 +185,84 @@ export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image);
const findContainerByIdAsync = async (id: string) => {
const dockerInstances = DockerSingleton.getInstances();
const containers = await Promise.all(
dockerInstances.map(async ({ instance }) => {
const container = instance.getContainer(id);
return await new Promise<Container | null>((resolve) => {
container.inspect((err, data) => {
if (err || !data) {
resolve(null);
} else {
resolve(container);
}
});
});
}),
);
return containers.find((container) => container) ?? null;
};
export const getContainerLogsAsync = async (id: string, tail = 200) => {
const container = await findContainerByIdAsync(id);
if (!container) {
return null;
}
const rawLogs = await container.logs({
tail,
stdout: true,
stderr: true,
follow: false,
});
return decodeDockerLogs(rawLogs);
};
export const streamContainerLogsAsync = async (
id: string,
tail: number,
onData: (data: string) => void,
onError: (err: Error) => void,
) => {
const container = await findContainerByIdAsync(id);
if (!container) {
onError(new Error("Container not found"));
return () => undefined;
}
const stream = (await container.logs({
tail,
stdout: true,
stderr: true,
follow: true,
})) as Readable;
const MAX_MESSAGE_SIZE = 1024 * 1024;
const processChunk = createDockerLogStreamProcessor(onData, onError, MAX_MESSAGE_SIZE);
const handleChunk = (chunk: Buffer) => {
const shouldContinue = processChunk(chunk);
if (!shouldContinue) {
stream.removeListener("data", handleChunk);
stream.removeListener("error", onError);
stream.destroy();
}
};
stream.on("data", handleChunk);
stream.on("error", onError);
return () => {
stream.removeListener("data", handleChunk);
stream.removeListener("error", onError);
stream.destroy();
};
};
async function getContainersWithStatsAsync() {
const dockerInstances = DockerSingleton.getInstances();
const results = await Promise.allSettled(
@@ -275,8 +355,6 @@ export function calculateMemoryUsage(stats: ContainerStats): number {
return 0;
}
// memory usage by default includes cache, which should not be shown as it is also not shown with docker stats command
// See https://docs.docker.com/reference/cli/docker/container/stats/ how it is / was calculated
return (
stats.memory_stats.usage -
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+220 -4
View File
@@ -7,7 +7,10 @@
"description": "To get started, please select how you want to set up your Homarr instance.",
"action": {
"scratch": "Start from scratch",
"importOldmarr": "Import from Homarr before 1.0"
"importOldmarr": "Import from Homarr before 1.0",
"restoreSqlite": "Restore from SQLite backup",
"restoreSqliteDescription": "Upload a full SQLite database backup from a previous Homarr instance. This will restore all boards, users, apps, integrations, and settings.",
"backToOptions": "← Back to setup options"
}
},
"import": {
@@ -3498,7 +3501,8 @@
"logs": "Logs",
"api": "API",
"certificates": "Certificates",
"tasks": "Tasks"
"tasks": "Tasks",
"backup": "Backup"
}
},
"settings": "Settings",
@@ -3611,7 +3615,24 @@
},
"search": "Search",
"firstDayOfWeek": "First day of the week",
"accessibility": "Accessibility"
"accessibility": "Accessibility",
"onboardingTours": {
"title": "Onboarding Tours",
"description": "Reset the onboarding tours to see them again on your next visit to the management area or dashboard.",
"reset": "Reset onboarding tours",
"resetSuccess": {
"title": "Tours reset",
"message": "Onboarding tours will show again on your next visit"
},
"status": {
"completed": "Completed",
"notStarted": "Not started"
},
"name": {
"management": "Management",
"dashboard": "Dashboard"
}
}
}
},
"security": {
@@ -4021,6 +4042,125 @@
}
}
}
},
"backup": {
"title": "Full Database Backup",
"metaTitle": "Backup & Restore",
"export": {
"title": "Export Database",
"description": "Download a full backup of your SQLite database including all settings, users, boards, and encrypted integration secrets. The backup is bundled as a ZIP file with the encryption key and metadata.",
"button": "Download Backup",
"error": "Export failed",
"confirm": {
"title": "Sensitive data export",
"warningTitle": "This export contains your entire database.",
"warningBody": "The backup includes all users, integration secrets (encrypted), API keys, board configurations, and app data. Store the exported file securely and do not share it.",
"typePrompt": "Type \"{phrase}\" to continue",
"submit": "Export Database",
"cancel": "Cancel"
}
},
"import": {
"title": "Restore Database",
"description": "Upload a previously exported backup ZIP to restore your database. This will replace your entire database. If the backup was created with a different encryption key, secrets will be automatically re-encrypted.",
"warning": "Restoring a backup will completely replace your current database and restart the application. This action cannot be undone.",
"dropzone": {
"title": "Drop your backup ZIP here or click to browse",
"description": "Only .zip files from a Homarr SQLite backup are accepted"
},
"changeFile": "Change file",
"button": "Restore Database",
"success": {
"title": "Restore Complete",
"message": "Database has been restored successfully. The application is restarting..."
},
"error": "Import failed"
},
"preview": {
"title": "Backup Contents",
"exported": "Exported {date}",
"loading": "Analyzing backup file...",
"errorTitle": "Preview Error",
"entity": "Entity",
"count": "Count",
"boards": "Boards",
"entities": {
"board": "Boards",
"user": "Users",
"app": "Apps",
"integration": "Integrations",
"item": "Widgets",
"media": "Media",
"search_engine": "Search Engines"
},
"analyzeError": "Failed to analyze backup file",
"invalidMissingMetadata": "Invalid backup: missing metadata.json",
"invalidMissingDb": "Invalid backup: missing db.sqlite"
},
"restore": {
"dropzone": {
"title": "Drop your backup ZIP here",
"description": "Accepts .zip files from a Homarr SQLite backup (max 256 MB)"
},
"analyzing": "Analyzing backup...",
"changeFile": "Change file",
"continueToRestore": "Continue to restore",
"tryAgain": "Try again",
"analysisError": "Analysis Error",
"preview": {
"title": "Backup Contents",
"exported": "Exported {date}",
"entity": "Entity",
"count": "Count",
"boards": "Boards",
"entities": {
"board": "Boards",
"user": "Users",
"app": "Apps",
"integration": "Integrations",
"item": "Widgets",
"media": "Media",
"search_engine": "Search Engines"
},
"migrationsApplied": "{count} migration applied",
"migrationsAppliedPlural": "{count} migrations applied",
"migrationsOlderVersion": "This backup was from an older Homarr version. {count} schema migration was applied to bring it up to date.",
"migrationsOlderVersionPlural": "This backup was from an older Homarr version. {count} schema migrations were applied to bring it up to date.",
"migrationsUpToDate": "Database schema is up to date ({count} migrations applied)",
"upToDate": "Up to date",
"migrationsPending": "{count} pending"
},
"migration": {
"title": "Applying migrations ({current}/{total})",
"migrationLabel": "Migration {index}"
},
"confirm": {
"title": "Destructive action",
"warningTitle": "This will permanently replace your entire database.",
"warningBody": "All current boards, users, apps, integrations, settings, and sessions will be lost. This action cannot be undone. The application will restart after the restore.",
"typePrompt": "Type \"{phrase}\" to continue",
"cancel": "Cancel",
"submit": "Restore Database"
},
"progress": {
"title": "Restoring database...",
"extracting": "Extracting backup archive",
"migrating": "Applying database migrations",
"encrypting": "Re-encrypting integration secrets",
"swapping": "Replacing database file",
"restarting": "Restarting server"
},
"complete": {
"title": "Restore Complete",
"message": "Database has been restored successfully. The server is restarting — please refresh the page in a few seconds."
},
"failed": {
"title": "Restore Failed"
},
"invalidMissingMetadata": "Invalid backup: missing metadata.json",
"invalidMissingDb": "Invalid backup: missing db.sqlite",
"analyzeError": "Failed to analyze backup"
}
}
},
"about": {
@@ -4106,6 +4246,12 @@
},
"action": {
"title": "Actions",
"logs": {
"label": "View logs",
"page": {
"title": "{name} logs"
}
},
"start": {
"label": "Start",
"notification": {
@@ -4518,7 +4664,10 @@
"label": "Tasks"
},
"docker": {
"label": "Docker"
"label": "Docker",
"logs": {
"label": "Logs"
}
},
"kubernetes": {
"label": "Kubernetes",
@@ -5192,5 +5341,72 @@
"error": "Error"
}
}
},
"onboardingTour": {
"learnMore": "Read the docs",
"next": "Next",
"prev": "Back",
"done": "Done",
"skip": "Skip tour",
"manage": {
"welcome": {
"title": "Welcome to Homarr!",
"description": "This is your control center. From here you manage boards, apps, integrations, users, and server settings. Let's take a tour of each section."
},
"boardsList": {
"title": "Your Boards",
"description": "Here are all your dashboards. Each board has its own layout, background, and access settings. You can see who created each one and whether it's public or private."
},
"boardsCreate": {
"title": "Create a Board",
"description": "Click here to create a new dashboard or import one from a previous Homarr installation. Each board gets its own URL you can share."
},
"appsList": {
"title": "Your Apps",
"description": "These are all the apps you've added — links to your self-hosted services. Each app has an icon, optional health check, and a status badge on the dashboard."
},
"appsCreate": {
"title": "Add an App",
"description": "Register a new service here. Give it a name, URL, and icon — it will appear as a tile on your boards with live status monitoring."
},
"integrationsList": {
"title": "Your Integrations",
"description": "These are the services connected to Homarr — Sonarr, Radarr, Plex, Jellyfin, and 50+ more. All credentials are encrypted at rest."
},
"integrationsCreate": {
"title": "Add an Integration",
"description": "Connect a new service here. Pick the type, enter the URL and API key — Homarr validates the connection before saving."
},
"usersList": {
"title": "Your Users",
"description": "See everyone who has access. Organize users into groups and control who can view or edit each board."
},
"usersCreate": {
"title": "Create a User",
"description": "Add a new user account here. You can also use invite links for easy onboarding. That's the tour — you're all set!"
}
},
"board": {
"welcome": {
"title": "This is your board",
"description": "A drag-and-drop dashboard where you arrange apps and widgets. Press Ctrl+K anytime to search for apps, boards, and actions."
},
"editMode": {
"title": "Edit Mode",
"description": "Click here to enter edit mode. You can then add widgets, apps, and sections — drag to reposition, resize with the corner handle. Click again to save your changes."
},
"settings": {
"title": "Board Settings",
"description": "Change this board's name, columns, background, colors, and who can access it. Each board can be public or private."
},
"switcher": {
"title": "Switch Boards",
"description": "Jump between boards. Create separate dashboards for different purposes — media, networking, per-user, or anything you like."
},
"userMenu": {
"title": "Your Profile",
"description": "Change your color scheme, language, home board, and preferences. You can also jump to the management area from here."
}
}
}
}
+1
View File
@@ -1,4 +1,5 @@
@import "@mantine/core/styles.css";
@import "@mantine/charts/styles.css";
@import "@mantine/dates/styles.css";
@import "@gfazioli/mantine-onboarding-tour/styles.css";
@import "mantine-react-table/styles.css";
+1 -20
View File
@@ -11,7 +11,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap } from "@homarr/docker/shared";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
@@ -28,25 +28,6 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
);
};
const memoryUsageColor = (number: number, state: string) => {
const mbUsage = number / 1024 / 1024;
if (mbUsage === 0 && state !== "running") return "red";
if (mbUsage < 128) return "green";
if (mbUsage < 256) return "yellow";
if (mbUsage < 512) return "orange";
return "red";
};
const cpuUsageColor = (number: number, state: string) => {
if (number === 0 && state !== "running") return "red";
if (number < 40) return "green";
if (number < 60) return "yellow";
if (number < 90) return "orange";
return "red";
};
const safeValue = (value?: number, fallback = 0) => (value !== undefined && !isNaN(value) ? value : fallback);
const actionIconIconStyle: IconProps["style"] = {
height: "var(--ai-icon-size)",
width: "var(--ai-icon-size)",
+1104 -1012
View File
File diff suppressed because it is too large. Load diff
+44 -41
View File
@@ -14,24 +14,25 @@ catalog:
"@dnd-kit/utilities": ^3.2.2
"@drizzle-team/brocli": ^0.12.0
"@extractus/feed-extractor": 7.2.1
'@gfazioli/mantine-onboarding-tour': ^4.0.4
"@gitbeaker/rest": ^43.8.0
"@homarr/gridstack": ^1.12.0
"@homarr/node-unifi": ^2.6.0
"@immich/sdk": ^2.7.5
"@jellyfin/sdk": ^0.13.0
"@kubernetes/client-node": ^1.4.0
"@mantine/charts": ^9.2.2
"@mantine/colors-generator": ^9.2.2
"@mantine/core": ^9.2.2
"@mantine/dates": ^9.2.2
"@mantine/dropzone": ^9.2.2
"@mantine/form": ^9.2.2
"@mantine/hooks": ^9.2.2
"@mantine/modals": ^9.2.2
"@mantine/notifications": ^9.2.2
"@mantine/spotlight": ^9.2.2
"@mantine/tiptap": ^9.2.2
"@next/eslint-plugin-next": 16.2.6
"@mantine/charts": ^9.3.0
"@mantine/colors-generator": ^9.3.0
"@mantine/core": ^9.3.0
"@mantine/dates": ^9.3.0
"@mantine/dropzone": ^9.3.0
"@mantine/form": ^9.3.0
"@mantine/hooks": ^9.3.0
"@mantine/modals": ^9.3.0
"@mantine/notifications": ^9.3.0
"@mantine/spotlight": ^9.3.0
"@mantine/tiptap": ^9.3.0
"@next/eslint-plugin-next": 16.2.7
"@octokit/auth-app": ^8.2.0
"@paralleldrive/cuid2": ^3.3.0
"@playwright/test": ^1.60.0
@@ -43,28 +44,28 @@ catalog:
"@semantic-release/release-notes-generator": ^14.1.1
"@t3-oss/env-nextjs": ^0.13.11
"@tabler/icons-react": ^3.44.0
"@tanstack/react-query": ^5.100.14
"@tanstack/react-query-devtools": ^5.100.14
"@tanstack/react-query-next-experimental": ^5.100.14
"@tanstack/react-query": ^5.101.0
"@tanstack/react-query-devtools": ^5.101.0
"@tanstack/react-query-next-experimental": ^5.101.0
"@testcontainers/mysql": ^12.0.1
"@testcontainers/postgresql": ^12.0.1
"@testcontainers/redis": ^12.0.1
"@tiptap/extension-color": 3.24.0
"@tiptap/extension-highlight": 3.24.0
"@tiptap/extension-image": 3.24.0
"@tiptap/extension-link": ^3.24.0
"@tiptap/extension-placeholder": ^3.24.0
"@tiptap/extension-table": 3.24.0
"@tiptap/extension-table-cell": 3.24.0
"@tiptap/extension-table-header": 3.24.0
"@tiptap/extension-table-row": 3.24.0
"@tiptap/extension-task-item": 3.24.0
"@tiptap/extension-task-list": 3.24.0
"@tiptap/extension-text-align": 3.24.0
"@tiptap/extension-text-style": 3.24.0
"@tiptap/extension-underline": 3.24.0
"@tiptap/react": ^3.24.0
"@tiptap/starter-kit": ^3.24.0
"@tiptap/extension-color": 3.25.0
"@tiptap/extension-highlight": 3.25.0
"@tiptap/extension-image": 3.25.0
"@tiptap/extension-link": ^3.25.0
"@tiptap/extension-placeholder": ^3.25.0
"@tiptap/extension-table": 3.25.0
"@tiptap/extension-table-cell": 3.25.0
"@tiptap/extension-table-header": 3.25.0
"@tiptap/extension-table-row": 3.25.0
"@tiptap/extension-task-item": 3.25.0
"@tiptap/extension-task-list": 3.25.0
"@tiptap/extension-text-align": 3.25.0
"@tiptap/extension-text-style": 3.25.0
"@tiptap/extension-underline": 3.25.0
"@tiptap/react": ^3.25.0
"@tiptap/starter-kit": ^3.25.0
"@trpc/client": ^11.17.0
"@trpc/next": ^11.17.0
"@trpc/react-query": ^11.17.0
@@ -83,7 +84,7 @@ catalog:
"@types/node-unifi": ^2.5.1
"@types/pg": ^8.20.0
"@types/prismjs": ^1.26.6
"@types/react": 19.2.15
"@types/react": 19.2.16
"@types/react-dom": 19.2.3
"@types/swagger-ui-react": ^5.18.0
"@types/video.js": ^7.3.58
@@ -91,12 +92,14 @@ catalog:
"@types/xml2js": ^0.4.14
"@umami/node": ^0.4.0
"@vitejs/plugin-react": ^5.2.0
"@vitest/coverage-v8": ^4.1.7
"@vitest/ui": ^4.1.7
"@vitest/coverage-v8": ^4.1.8
"@vitest/ui": ^4.1.8
"@xterm/addon-canvas": ^0.7.0
"@xterm/addon-fit": 0.11.0
"@xterm/xterm": ^6.0.0
adm-zip: 0.5.17
jszip: ^3.10.1
sql.js: ^1.12.0
babel-plugin-react-compiler: ^1.0.0
bcrypt: ^6.0.0
better-sqlite3: ^12.10.0
@@ -125,7 +128,7 @@ catalog:
glob: ^13.0.6
ical.js: ^2.2.1
ioredis: 5.11.0
isomorphic-dompurify: ^3.15.0
isomorphic-dompurify: ^3.16.0
jotai: ^2.20.0
jsdom: ^29.1.1
json5: ^2.2.3
@@ -134,19 +137,19 @@ catalog:
mantine-react-table: npm:mantine-react-table-open@9.0.3
maria2: ^0.4.1
mysql2: 3.22.4
next: 16.2.6
next: 16.2.7
next-auth: 5.0.0-beta.31
next-intl: 4.13.0
node-loader: ^2.1.0
octokit: ^5.0.5
oxfmt: ^0.52.0
oxfmt: ^0.53.0
oxlint: ^1.67.0
pg: ^8.21.0
postcss-preset-mantine: ^1.18.0
prismjs: ^1.30.0
proxmox-api: 1.1.1
react: 19.2.6
react-dom: 19.2.6
react: 19.2.7
react-dom: 19.2.7
react-error-boundary: ^6.1.2
react-markdown: ^10.1.0
react-quill-new: ^3.8.3
@@ -154,7 +157,7 @@ catalog:
recharts: ^3.8.1
sass: ^1.100.0
semantic-release: ^25.0.3
semver-parser: ^4.2.0
semver-parser: ^4.2.1
superjson: 2.2.6
svgson: ^5.3.1
swagger-ui-react: ^5.32.6
@@ -169,7 +172,7 @@ catalog:
use-deep-compare-effect: ^1.8.1
video.js: ^8.23.7
vite-tsconfig-paths: ^6.1.1
vitest: ^4.1.7
vitest: ^4.1.8
winston: 3.19.0
ws: ^8.21.0
xml2js: ^0.6.2
+28 -13
View File
@@ -10,8 +10,11 @@ if [ "$DB_MIGRATIONS_DISABLED" = "true" ]; then
echo "DB migrations are disabled, skipping"
else
echo "Running DB migrations"
# We disable redis logs during migration as the redis client is not yet started
DISABLE_REDIS_LOGS=true node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
if [ $? -ne 0 ]; then
echo "ERROR: DB migrations failed, aborting startup"
exit 1
fi
fi
# Auth secret is generated every time the container starts as it is required, but not used because we don't need JWTs or Mail hashing
@@ -36,26 +39,38 @@ else
REDIS_PID=$!
fi
node apps/nextjs/server.js &
NEXTJS_PID=$!
SHUTTING_DOWN=false
# Function to handle SIGTERM and shut down services
terminate() {
echo "Received SIGTERM. Shutting down..."
kill -TERM $NGINX_PID $NEXTJS_PID 2>/dev/null
wait
# kill redis-server last because of logging of other services and only if $REDIS_PID is set
SHUTTING_DOWN=true
echo "Shutting down..."
kill -TERM $NEXTJS_PID 2>/dev/null
wait $NEXTJS_PID 2>/dev/null
kill -TERM $NGINX_PID 2>/dev/null
wait $NGINX_PID 2>/dev/null
if [ -n "$REDIS_PID" ]; then
kill -TERM $REDIS_PID 2>/dev/null
wait
wait $REDIS_PID 2>/dev/null
fi
echo "Shutdown complete."
exit 0
}
# When SIGTERM (docker stop <container>) / SIGINT (ctrl+c) is received, run the terminate function
trap terminate TERM INT
# Wait for all processes
wait $NEXTJS_PID
terminate
node apps/nextjs/server.js &
NEXTJS_PID=$!
while true; do
wait $NEXTJS_PID
EXIT_CODE=$?
if [ "$SHUTTING_DOWN" = true ]; then
break
fi
echo "Next.js exited with code $EXIT_CODE, restarting in 1s..."
sleep 1
node apps/nextjs/server.js &
NEXTJS_PID=$!
done
+1 -1
View File
@@ -1 +1 @@
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"alotlikebeans","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontLine truncated
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"alotlikebeans","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontLine truncated
+1 -1
View File
@@ -1 +1 @@
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/249b02e3368752cb175446082015737c_default.png"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"httpsLine truncated
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/249b02e3368752cb175446082015737c_default.png"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"httpsLine truncated