Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79cb9277e6 | ||
|
|
484b8cf9f1 | ||
|
|
b3cdab579b | ||
|
|
44a0c6ce54 | ||
|
|
7f05e0325d | ||
|
|
f71b145403 | ||
|
|
607fd498f4 | ||
|
|
3f2fb54cdf | ||
|
|
69412f2d10 | ||
|
|
f6d5f97933 | ||
|
|
a447a9f615 | ||
|
|
87d3f6a0a1 | ||
|
|
3d2c4979a6 | ||
|
|
79b27815fe | ||
|
|
f2705eeb57 | ||
|
|
76eacf8353 | ||
|
|
0c14e715a0 | ||
|
|
ac37d2c73f | ||
|
|
e00ea624cd | ||
|
|
20b62c42e3 | ||
|
|
59971991c7 | ||
|
|
a629681ae5 | ||
|
|
572d5fb5ec | ||
|
|
91a1858743 | ||
|
|
f00d3627c9 | ||
|
|
7760e01ccb | ||
|
|
8a1ead3674 | ||
|
|
9c05f44916 | ||
|
|
efd37dca93 | ||
|
|
796c679140 | ||
|
|
76fd4c5fb1 | ||
|
|
68f24e34d5 | ||
|
|
22d7ebab50 | ||
|
|
4f5f3e14a1 | ||
|
|
dbf021e04a | ||
|
|
cc24f070db | ||
|
|
0b663a7fc6 | ||
|
|
0dc2558faf | ||
|
|
dd7a4908a3 | ||
|
|
0a6467979a | ||
|
|
25d964a197 | ||
|
|
3d3e3059a9 | ||
|
|
9ae8cb2768 | ||
|
|
99346f288b | ||
|
|
ff1e4c1381 | ||
|
|
21bb20ce0c | ||
|
|
92b75cb924 | ||
|
|
1ea008b71c |
No files matched your search
@@ -1,2 +0,0 @@
|
|||||||
-P ubuntu-latest=catthehacker/ubuntu:act-latest
|
|
||||||
--container-architecture linux/amd64
|
|
||||||
@@ -33,6 +33,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
# The below comment is used to insert a new version with on-release.yml
|
# The below comment is used to insert a new version with on-release.yml
|
||||||
#NEXT_VERSION#
|
#NEXT_VERSION#
|
||||||
|
- 1.64.0
|
||||||
- 1.62.0
|
- 1.62.0
|
||||||
- 1.61.0
|
- 1.61.0
|
||||||
- 1.60.1-beta.1
|
- 1.60.1-beta.1
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional information
|
label: Additional information
|
||||||
description: |
|
description: Add any other information related to the integration.
|
||||||
Add any other information related to the integration.
|
|
||||||
|
|
||||||
After your integration is merged to `dev`, a bot PR updates [CODEOWNERS](https://github.com/homarr-labs/homarr/blob/dev/CODEOWNERS) so you are requested on future PRs touching your folders. Run `pnpm scripts:generate-codeowners` locally for faster updates. Add email mappings in `scripts/codeowners-overrides.json` if the generator cannot resolve your GitHub user.
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
attributes:
|
attributes:
|
||||||
label: Public API available?
|
label: Public API available?
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional information
|
label: Additional information
|
||||||
description: |
|
description: Add any other information related to the widget.
|
||||||
Add any other information related to the widget.
|
|
||||||
|
|
||||||
After your widget is merged to `dev`, a bot PR updates [CODEOWNERS](https://github.com/homarr-labs/homarr/blob/dev/CODEOWNERS) so you are requested on future PRs touching your folders. Run `pnpm scripts:generate-codeowners` locally for faster updates. Add email mappings in `scripts/codeowners-overrides.json` if the generator cannot resolve your GitHub user.
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
attributes:
|
attributes:
|
||||||
label: Are you willing to contribute this yourself?
|
label: Are you willing to contribute this yourself?
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
name: Update CODEOWNERS
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch: {}
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- CODEOWNERS
|
|
||||||
- packages/widgets/src/**
|
|
||||||
- packages/integrations/src/**
|
|
||||||
- packages/api/src/router/widgets/**
|
|
||||||
- packages/cron-jobs/src/jobs/**
|
|
||||||
- scripts/generate-codeowners.mts
|
|
||||||
- scripts/validate-codeowners.mts
|
|
||||||
- scripts/codeowners-overrides.json
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- packages/widgets/src/**
|
|
||||||
- packages/integrations/src/**
|
|
||||||
- packages/api/src/router/widgets/**
|
|
||||||
- packages/cron-jobs/src/jobs/**
|
|
||||||
- scripts/generate-codeowners.mts
|
|
||||||
- scripts/validate-codeowners.mts
|
|
||||||
- scripts/codeowners-overrides.json
|
|
||||||
branches:
|
|
||||||
- dev
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
validate:
|
|
||||||
name: Validate CODEOWNERS
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version-file: .nvmrc
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Check CODEOWNERS is up to date
|
|
||||||
run: pnpm exec tsx ./scripts/generate-codeowners.mts --check
|
|
||||||
|
|
||||||
- name: Validate CODEOWNERS
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
run: pnpm exec tsx ./scripts/validate-codeowners.mts
|
|
||||||
|
|
||||||
update:
|
|
||||||
name: Open CODEOWNERS update PR
|
|
||||||
needs: validate
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
concurrency:
|
|
||||||
group: update-codeowners
|
|
||||||
cancel-in-progress: false
|
|
||||||
steps:
|
|
||||||
- name: Obtain token
|
|
||||||
id: obtainToken
|
|
||||||
uses: actions/create-github-app-token@v3
|
|
||||||
with:
|
|
||||||
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
|
|
||||||
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
|
||||||
permission-contents: write
|
|
||||||
permission-pull-requests: write
|
|
||||||
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
|
||||||
|
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version-file: .nvmrc
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Generate CODEOWNERS
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
|
||||||
run: pnpm scripts:generate-codeowners
|
|
||||||
|
|
||||||
- name: Validate CODEOWNERS
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
run: pnpm exec tsx ./scripts/validate-codeowners.mts
|
|
||||||
|
|
||||||
- name: Create Pull Request
|
|
||||||
id: create-pull-request
|
|
||||||
uses: peter-evans/create-pull-request@v8
|
|
||||||
with:
|
|
||||||
token: ${{ steps.obtainToken.outputs.token }}
|
|
||||||
branch: update-codeowners
|
|
||||||
base: dev
|
|
||||||
title: "chore: update widget/integration CODEOWNERS"
|
|
||||||
delete-branch: true
|
|
||||||
commit-message: "chore: update widget/integration CODEOWNERS"
|
|
||||||
body: |
|
|
||||||
This PR updates per-widget and per-integration CODEOWNERS entries from git history.
|
|
||||||
|
|
||||||
Unmapped authors can be added in `scripts/codeowners-overrides.json`.
|
|
||||||
|
|
||||||
- name: Install GitHub CLI
|
|
||||||
run: sudo apt-get install -y gh
|
|
||||||
|
|
||||||
- name: Enable auto-merge
|
|
||||||
if: steps.create-pull-request.outputs.pull-request-number != ''
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
|
||||||
run: |
|
|
||||||
gh pr merge ${{ steps.create-pull-request.outputs.pull-request-number }} --auto --squash
|
|
||||||
@@ -1,3 +1,67 @@
|
|||||||
|
## [1.64.0](https://github.com/homarr-labs/homarr/compare/v1.63.0...v1.64.0) (2026-06-05)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add comprehensive demo mode with mock integrations ([#5870](https://github.com/homarr-labs/homarr/issues/5870)) ([0a64679](https://github.com/homarr-labs/homarr/commit/0a6467979a5e3b3e3eae6252c31fca1d9af54dc1))
|
||||||
|
* add PeaNUT integration and UPS monitoring widget ([#5862](https://github.com/homarr-labs/homarr/issues/5862)) ([0dc2558](https://github.com/homarr-labs/homarr/commit/0dc2558fafedcbba246c2e968d00cc541ab9f84e))
|
||||||
|
* **docker:** add container logs modal and stats columns ([#4807](https://github.com/homarr-labs/homarr/issues/4807)) ([dbf021e](https://github.com/homarr-labs/homarr/commit/dbf021e04a785b44ad0e2ce45634de371d76de6d))
|
||||||
|
* media request search as first-class spotlight mode ([#5823](https://github.com/homarr-labs/homarr/issues/5823)) ([3d3e305](https://github.com/homarr-labs/homarr/commit/3d3e3059a98b36b6e64e3aad90f715e334baff3e))
|
||||||
|
* **onboarding:** enhance integration setup with Docker discovery and URL templating ([#5607](https://github.com/homarr-labs/homarr/issues/5607)) ([dd7a490](https://github.com/homarr-labs/homarr/commit/dd7a4908a36a71c1d0d1e41524eb8fd9bf53ca4d))
|
||||||
|
* **onboarding:** guided management and board tours ([#5756](https://github.com/homarr-labs/homarr/issues/5756)) ([0b663a7](https://github.com/homarr-labs/homarr/commit/0b663a7fc6c017b9fbe78c019f06d46ee95f165f))
|
||||||
|
* **widgets:** add in-place app editing in widget edit modal ([#5809](https://github.com/homarr-labs/homarr/issues/5809)) ([99346f2](https://github.com/homarr-labs/homarr/commit/99346f288bcfc701c41fecaab6f7bc0438d80563))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add bookmark widget option to toggle card borders ([#5854](https://github.com/homarr-labs/homarr/issues/5854)) ([41fe592](https://github.com/homarr-labs/homarr/commit/41fe5928f19207d02b04e64c2caac49d896f72ce))
|
||||||
|
* **auth:** relax password complexity requirements to suggestions-only ([#5764](https://github.com/homarr-labs/homarr/issues/5764)) ([fd10788](https://github.com/homarr-labs/homarr/commit/fd107888385557ededcf51576c6320a1960690f2))
|
||||||
|
* **db:** log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding ([efd37dc](https://github.com/homarr-labs/homarr/commit/efd37dca93be7f5f362331e9b5c2a95a87e24379))
|
||||||
|
* disable onboarding tours when demo mode is enabled ([68f24e3](https://github.com/homarr-labs/homarr/commit/68f24e34d580e3a155c37ca37c3ffd61f296bd0c))
|
||||||
|
* **docker:** restore container selection toolbar broken by Mantine v9 ([#5855](https://github.com/homarr-labs/homarr/issues/5855)) ([fe4598f](https://github.com/homarr-labs/homarr/commit/fe4598f7221a2861b8c5ae0cfd267e8c4fbf48df))
|
||||||
|
* **immich:** respect app color scheme in server-stats widget ([#5859](https://github.com/homarr-labs/homarr/issues/5859)) ([2c6b7a8](https://github.com/homarr-labs/homarr/commit/2c6b7a818e9d32b414b024d711a3b64533e61cb9))
|
||||||
|
* redirect to login after credentials logout instead of crashing on current page ([#5819](https://github.com/homarr-labs/homarr/issues/5819)) ([cc24f07](https://github.com/homarr-labs/homarr/commit/cc24f070db26337a4a2d87d68c7f142ed1118584))
|
||||||
|
* sync docs sitemap and remove stale coolify ts-expect-error ([4f5f3e1](https://github.com/homarr-labs/homarr/commit/4f5f3e14a129a94295d4b8ffb7d3702fdb4f6a74))
|
||||||
|
* **ui:** restore pre-v9 light variant colors ([#5856](https://github.com/homarr-labs/homarr/issues/5856)) ([a755692](https://github.com/homarr-labs/homarr/commit/a755692d81b80b5ff83051a3b7261c01ac10321b))
|
||||||
|
|
||||||
|
## [1.63.0](https://github.com/homarr-labs/homarr/compare/v1.62.0...v1.63.0) (2026-05-29)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **auth:** support custom token endpoint auth methods ([#5806](https://github.com/homarr-labs/homarr/issues/5806)) ([be61602](https://github.com/homarr-labs/homarr/commit/be61602576a8bec4bb7e4928d36e0854e926264d))
|
||||||
|
* auto-open widget settings on add with all integrations pre-selected ([#5767](https://github.com/homarr-labs/homarr/issues/5767)) ([f10bb3a](https://github.com/homarr-labs/homarr/commit/f10bb3ae23fefc9779332733706aa567cc40ba48))
|
||||||
|
* **board:** persist category collapse state in localStorage for guests ([#5714](https://github.com/homarr-labs/homarr/issues/5714)) ([66212b4](https://github.com/homarr-labs/homarr/commit/66212b4e475738e61d2ea78f3a7e72d7a610bf12)), closes [#3355](https://github.com/homarr-labs/homarr/issues/3355)
|
||||||
|
* **manage:** unified management page layout ([#5758](https://github.com/homarr-labs/homarr/issues/5758)) ([c252b4f](https://github.com/homarr-labs/homarr/commit/c252b4f9accc4523f222354c73f687714640701f))
|
||||||
|
* **users:** rework general settings ([#4915](https://github.com/homarr-labs/homarr/issues/4915)) ([2991777](https://github.com/homarr-labs/homarr/commit/2991777b0a0ae72522d0769a86a91250d618dbe6))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* matine ui fixes for concistency ([#5733](https://github.com/homarr-labs/homarr/issues/5733)) ([4454e3f](https://github.com/homarr-labs/homarr/commit/4454e3fb7afa624ee2f1eaec015f2725e664ac46))
|
||||||
|
* parse Speedtest Tracker created_at as local time ([#5751](https://github.com/homarr-labs/homarr/issues/5751)) ([b19549b](https://github.com/homarr-labs/homarr/commit/b19549b601636bcbc2fadaa270a77a9fdb48b77a))
|
||||||
|
* prevent GitHub releases check from blocking app on API failure ([#5807](https://github.com/homarr-labs/homarr/issues/5807)) ([6cc2ac5](https://github.com/homarr-labs/homarr/commit/6cc2ac56a920563c47aaaab1b33b19a9b6d5ecee))
|
||||||
|
* **ui:** use imported theme instead of undefined createTheme ([4a67bf0](https://github.com/homarr-labs/homarr/commit/4a67bf0ef30ff8d10b06d2d3719ccab792395669))
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **boards:** parallelize board page data fetching ([#5736](https://github.com/homarr-labs/homarr/issues/5736)) ([8f03a38](https://github.com/homarr-labs/homarr/commit/8f03a38de5b9b385da7ffcc9a963be00710acb5e))
|
||||||
|
|
||||||
|
## [1.62.0](https://github.com/homarr-labs/homarr/compare/v1.61.1...v1.62.0) (2026-05-22)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **seed:** add demo mode with pre-seeded user ([#5739](https://github.com/homarr-labs/homarr/issues/5739)) ([dade477](https://github.com/homarr-labs/homarr/commit/dade477b3226765eb614e66588ebee84c45cb688))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **deps:** update dependency better-sqlite3 to ^12.10.0 ([#5727](https://github.com/homarr-labs/homarr/issues/5727)) ([044ad9e](https://github.com/homarr-labs/homarr/commit/044ad9edf9c1dc9b50b9591dea0b5f0cce4be01a))
|
||||||
|
* **docker:** improve icon matching ([#5693](https://github.com/homarr-labs/homarr/issues/5693)) ([7cd30b6](https://github.com/homarr-labs/homarr/commit/7cd30b640cbf47e4678abda18f83c0924f039b1b))
|
||||||
|
* exclude .env files from Docker build context ([#5738](https://github.com/homarr-labs/homarr/issues/5738)) ([fa73763](https://github.com/homarr-labs/homarr/commit/fa737638a812372d904207fe7af5bd7c6f53d159))
|
||||||
|
* **tracearr:** support absolute image urls ([#5654](https://github.com/homarr-labs/homarr/issues/5654)) ([7a12721](https://github.com/homarr-labs/homarr/commit/7a1272146c597c1f9df89d74b8389f70e7cdd61a))
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* (next branch) reduce idle memory on startup ([#5637](https://github.com/homarr-labs/homarr/issues/5637)) ([5ee84f8](https://github.com/homarr-labs/homarr/commit/5ee84f8cf464c308729281cb4db572950ba85a82))
|
||||||
|
* **boards:** remove loading overlay and show board immediately ([#5735](https://github.com/homarr-labs/homarr/issues/5735)) ([df4b57b](https://github.com/homarr-labs/homarr/commit/df4b57bd414d4402522f9210b1470d0561c37274))
|
||||||
|
* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e))
|
||||||
|
|
||||||
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
|
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
-83
@@ -11,86 +11,3 @@ pnpm-lock.yaml
|
|||||||
pnpm-workspace.yaml
|
pnpm-workspace.yaml
|
||||||
Dockerfile
|
Dockerfile
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|
||||||
# BEGIN GENERATED widget/integration owners
|
|
||||||
/packages/api/src/router/widgets/anchor-notes.ts /packages/integrations/src/anchor/ /packages/widgets/src/anchor-note/ @omariqbal
|
|
||||||
/packages/api/src/router/widgets/app.ts /packages/widgets/src/app/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/calendar.ts /packages/widgets/src/calendar/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/coolify.ts /packages/integrations/src/coolify/ /packages/widgets/src/coolify/ @Rhiz3K
|
|
||||||
/packages/api/src/router/widgets/dns-hole.ts /packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/widgets/src/dns-hole/controls/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/dns-hole.ts /packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/widgets/src/dns-hole/summary/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/downloads.ts /packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/widgets/src/downloads/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/firewall.ts /packages/cron-jobs/src/jobs/integrations/firewall.ts /packages/widgets/src/firewall/ @Oupsman
|
|
||||||
/packages/api/src/router/widgets/health-monitoring.ts /packages/cron-jobs/src/jobs/integrations/health-monitoring.ts /packages/widgets/src/health-monitoring/ @dgshue
|
|
||||||
/packages/api/src/router/widgets/immich.ts /packages/integrations/src/immich/ /packages/widgets/src/immich/album-carousel/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/immich.ts /packages/integrations/src/immich/ /packages/widgets/src/immich/server-stats/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/indexer-manager.ts /packages/cron-jobs/src/jobs/integrations/indexer-manager.ts /packages/widgets/src/indexer-manager/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/media-release.ts /packages/widgets/src/media-releases/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/media-requests.ts /packages/cron-jobs/src/jobs/integrations/media-requests.ts /packages/widgets/src/media-requests/list/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/media-requests.ts /packages/cron-jobs/src/jobs/integrations/media-requests.ts /packages/widgets/src/media-requests/stats/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/media-server.ts /packages/cron-jobs/src/jobs/integrations/media-server.ts /packages/widgets/src/media-server/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/media-transcoding.ts /packages/cron-jobs/src/jobs/integrations/media-transcoding.ts /packages/integrations/src/media-transcoding/ /packages/widgets/src/media-transcoding/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/minecraft.ts /packages/widgets/src/minecraft/server-status/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/network-controller.ts /packages/cron-jobs/src/jobs/integrations/network-controller.ts /packages/widgets/src/network-controller/network-status/ @pitschi
|
|
||||||
/packages/api/src/router/widgets/network-controller.ts /packages/cron-jobs/src/jobs/integrations/network-controller.ts /packages/widgets/src/network-controller/summary/ @pitschi
|
|
||||||
/packages/api/src/router/widgets/notebook.ts /packages/widgets/src/notebook/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/notifications.ts /packages/cron-jobs/src/jobs/integrations/notifications.ts /packages/widgets/src/notifications/ @itzTheMeow
|
|
||||||
/packages/api/src/router/widgets/releases.ts /packages/widgets/src/releases/ @Aandree5
|
|
||||||
/packages/api/src/router/widgets/rssFeed.ts /packages/widgets/src/rssFeed/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/smart-home.ts /packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/widgets/src/smart-home/entity-state/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/smart-home.ts /packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/widgets/src/smart-home/execute-automation/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/speedtest-tracker.ts /packages/integrations/src/speedtest-tracker/ /packages/widgets/src/speedtest-tracker/ @kylegrieder
|
|
||||||
/packages/api/src/router/widgets/stocks.ts /packages/widgets/src/stocks/ @jackwellerreal
|
|
||||||
/packages/api/src/router/widgets/timetable.ts /packages/cron-jobs/src/jobs/integrations/timetable.ts /packages/widgets/src/timetable/ @manuel-rw
|
|
||||||
/packages/api/src/router/widgets/tracearr.ts /packages/cron-jobs/src/jobs/integrations/tracearr.ts /packages/integrations/src/tracearr/ /packages/widgets/src/tracearr/ @StrandedTurtle
|
|
||||||
/packages/api/src/router/widgets/umami.ts /packages/cron-jobs/src/jobs/integrations/umami.ts /packages/integrations/src/umami/ /packages/widgets/src/umami/ @febrile42
|
|
||||||
/packages/api/src/router/widgets/weather.ts /packages/cron-jobs/src/jobs/weather.ts /packages/widgets/src/weather/ @manuel-rw
|
|
||||||
/packages/cron-jobs/src/jobs/docker.ts /packages/widgets/src/docker/ @ashtonbatty
|
|
||||||
/packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/integrations/src/pi-hole/ @manuel-rw
|
|
||||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/aria2/ @manuel-rw
|
|
||||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/deluge/ @manuel-rw
|
|
||||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/nzbget/ @manuel-rw
|
|
||||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/qbittorrent/ @manuel-rw
|
|
||||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/sabnzbd/ @manuel-rw
|
|
||||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/slskd/ @manuel-rw
|
|
||||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/transmission/ @manuel-rw
|
|
||||||
/packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/integrations/src/homeassistant/ @manuel-rw
|
|
||||||
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/lidarr/ @NicolasNewman
|
|
||||||
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/radarr/ @manuel-rw
|
|
||||||
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/readarr/ @manuel-rw
|
|
||||||
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/sonarr/ @manuel-rw
|
|
||||||
/packages/integrations/src/adguard-home/ @manuel-rw
|
|
||||||
/packages/integrations/src/codeberg/ @Aandree5
|
|
||||||
/packages/integrations/src/dashdot/ @dgshue
|
|
||||||
/packages/integrations/src/docker-hub/ @Aandree5
|
|
||||||
/packages/integrations/src/emby/ @manuel-rw
|
|
||||||
/packages/integrations/src/github-container-registry/ @manuel-rw
|
|
||||||
/packages/integrations/src/github/ @manuel-rw
|
|
||||||
/packages/integrations/src/gitlab/ @Aandree5
|
|
||||||
/packages/integrations/src/glances/ @dgshue
|
|
||||||
/packages/integrations/src/ical/ @NicolasNewman
|
|
||||||
/packages/integrations/src/jellyfin/ @manuel-rw
|
|
||||||
/packages/integrations/src/jellyseerr/ @manuel-rw
|
|
||||||
/packages/integrations/src/linuxserverio/ @Aandree5
|
|
||||||
/packages/integrations/src/nextcloud/ @manuel-rw
|
|
||||||
/packages/integrations/src/npm/ @Aandree5
|
|
||||||
/packages/integrations/src/ntfy/ @manuel-rw
|
|
||||||
/packages/integrations/src/openmediavault/ @dgshue
|
|
||||||
/packages/integrations/src/opnsense/ @manuel-rw
|
|
||||||
/packages/integrations/src/overseerr/ @manuel-rw
|
|
||||||
/packages/integrations/src/plex/ @manuel-rw
|
|
||||||
/packages/integrations/src/prowlarr/ @manuel-rw
|
|
||||||
/packages/integrations/src/proxmox/ @manuel-rw
|
|
||||||
/packages/integrations/src/quay/ @Aandree5
|
|
||||||
/packages/integrations/src/search-ch/ @manuel-rw
|
|
||||||
/packages/integrations/src/seerr/ @manuel-rw
|
|
||||||
/packages/integrations/src/truenas/ @dgshue
|
|
||||||
/packages/integrations/src/unifi-controller/ @manuel-rw
|
|
||||||
/packages/integrations/src/unraid/ @dgshue
|
|
||||||
/packages/widgets/src/bookmarks/ @manuel-rw
|
|
||||||
/packages/widgets/src/clock/ @manuel-rw
|
|
||||||
/packages/widgets/src/iframe/ @manuel-rw
|
|
||||||
/packages/widgets/src/system-disks/ @manuel-rw
|
|
||||||
/packages/widgets/src/system-resources/ @kylegrieder
|
|
||||||
/packages/widgets/src/video/ @manuel-rw
|
|
||||||
# END GENERATED widget/integration owners
|
|
||||||
@@ -5,9 +5,21 @@ import "@homarr/common/env";
|
|||||||
import "@homarr/core/infrastructure/logs/env";
|
import "@homarr/core/infrastructure/logs/env";
|
||||||
import "@homarr/docker/env";
|
import "@homarr/docker/env";
|
||||||
|
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
|
const homarrVersion = (() => {
|
||||||
|
for (const candidate of ["../../package.json", "./package.json"]) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
||||||
|
if (pkg.name === "homarr") return pkg.version as string;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
})();
|
||||||
|
|
||||||
// Package path does not work... so we need to use relative path
|
// Package path does not work... so we need to use relative path
|
||||||
const withNextIntl = createNextIntlPlugin({
|
const withNextIntl = createNextIntlPlugin({
|
||||||
experimental: {
|
experimental: {
|
||||||
@@ -17,6 +29,9 @@ const withNextIntl = createNextIntlPlugin({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
env: {
|
||||||
|
HOMARR_VERSION: homarrVersion,
|
||||||
|
},
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// react compiler breaks mantine-react-table, so disabled for now
|
// react compiler breaks mantine-react-table, so disabled for now
|
||||||
@@ -27,7 +42,7 @@ const nextConfig: NextConfig = {
|
|||||||
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
||||||
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
||||||
*/
|
*/
|
||||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
|
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||||
turbopackFileSystemCacheForDev: true,
|
turbopackFileSystemCacheForDev: true,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@dnd-kit/modifiers": "catalog:",
|
"@dnd-kit/modifiers": "catalog:",
|
||||||
"@dnd-kit/sortable": "catalog:",
|
"@dnd-kit/sortable": "catalog:",
|
||||||
"@dnd-kit/utilities": "catalog:",
|
"@dnd-kit/utilities": "catalog:",
|
||||||
|
"@gfazioli/mantine-onboarding-tour": "catalog:",
|
||||||
"@homarr/analytics": "workspace:^0.1.0",
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
@@ -71,9 +72,13 @@
|
|||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
|
"drizzle-orm": "catalog:",
|
||||||
"flag-icons": "catalog:",
|
"flag-icons": "catalog:",
|
||||||
"glob": "catalog:",
|
"glob": "catalog:",
|
||||||
|
"adm-zip": "catalog:",
|
||||||
|
"better-sqlite3": "catalog:",
|
||||||
"isomorphic-dompurify": "catalog:",
|
"isomorphic-dompurify": "catalog:",
|
||||||
|
"jszip": "catalog:",
|
||||||
"jotai": "catalog:",
|
"jotai": "catalog:",
|
||||||
"mantine-react-table": "catalog:",
|
"mantine-react-table": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
@@ -84,6 +89,7 @@
|
|||||||
"react-error-boundary": "catalog:",
|
"react-error-boundary": "catalog:",
|
||||||
"react-simple-code-editor": "catalog:",
|
"react-simple-code-editor": "catalog:",
|
||||||
"sass": "catalog:",
|
"sass": "catalog:",
|
||||||
|
"sql.js": "catalog:",
|
||||||
"superjson": "catalog:",
|
"superjson": "catalog:",
|
||||||
"swagger-ui-react": "catalog:",
|
"swagger-ui-react": "catalog:",
|
||||||
"use-deep-compare-effect": "catalog:",
|
"use-deep-compare-effect": "catalog:",
|
||||||
@@ -92,6 +98,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
|
"@types/adm-zip": "catalog:",
|
||||||
|
"@types/better-sqlite3": "catalog:",
|
||||||
"@types/chroma-js": "catalog:",
|
"@types/chroma-js": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/prismjs": "catalog:",
|
"@types/prismjs": "catalog:",
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
@@ -17,10 +17,13 @@ import {
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import type { SuperJSONResult } from "superjson";
|
import type { SuperJSONResult } from "superjson";
|
||||||
|
|
||||||
|
import { TRPCClientError } from "@trpc/client";
|
||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
|
import { showWarningNotification } from "@homarr/notifications";
|
||||||
|
|
||||||
const getWebSocketProtocol = () => {
|
const getWebSocketProtocol = () => {
|
||||||
// window is not defined on server side
|
// window is not defined on server side
|
||||||
@@ -56,6 +59,20 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
queries: {
|
queries: {
|
||||||
staleTime: 5 * 1000,
|
staleTime: 5 * 1000,
|
||||||
},
|
},
|
||||||
|
mutations: {
|
||||||
|
onError(error) {
|
||||||
|
if (
|
||||||
|
error instanceof TRPCClientError &&
|
||||||
|
error.data?.code === "FORBIDDEN" &&
|
||||||
|
error.message === "Mutations are disabled in demo mode"
|
||||||
|
) {
|
||||||
|
showWarningNotification({
|
||||||
|
title: "Demo mode",
|
||||||
|
message: "This action is disabled in demo mode.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { IconLogin } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { env } from "@homarr/auth/env";
|
import { env } from "@homarr/auth/env";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||||
|
|
||||||
|
import { env as appEnv } from "~/env";
|
||||||
|
|
||||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||||
import { LoginForm } from "./_login-form";
|
import { LoginForm } from "./_login-form";
|
||||||
|
|
||||||
@@ -37,6 +40,13 @@ export default async function Login(props: LoginProps) {
|
|||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{appEnv.DEMO_MODE && (
|
||||||
|
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<Card w={64 * 6} maw="90vw">
|
<Card w={64 * 6} maw="90vw">
|
||||||
<LoginForm
|
<LoginForm
|
||||||
providers={env.AUTH_PROVIDERS}
|
providers={env.AUTH_PROVIDERS}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
layout: createBoardLayout({
|
layout: createBoardLayout({
|
||||||
headerActions: <BoardContentHeaderActions />,
|
headerActions: <BoardContentHeaderActions />,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
|
withTour: true,
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { useHotkeys } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconBox,
|
IconBox,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
IconLayoutBoard,
|
IconLayoutBoard,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconPencilOff,
|
IconPencilOff,
|
||||||
|
IconPlug,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconReplace,
|
IconReplace,
|
||||||
IconResize,
|
IconResize,
|
||||||
@@ -36,14 +38,16 @@ import { useBoardPermissions } from "~/components/board/permissions/client";
|
|||||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||||
|
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
|
|
||||||
export const BoardContentHeaderActions = () => {
|
export const BoardContentHeaderActions = () => {
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { hasChangeAccess } = useBoardPermissions(board);
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
|
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
||||||
|
|
||||||
if (!hasChangeAccess) {
|
if (!hasChangeAccess || isLoading) {
|
||||||
return <SelectBoardsMenu />;
|
return <SelectBoardsMenu />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,11 +55,15 @@ export const BoardContentHeaderActions = () => {
|
|||||||
<>
|
<>
|
||||||
{isEditMode && <AddMenu />}
|
{isEditMode && <AddMenu />}
|
||||||
|
|
||||||
<EditModeMenu />
|
<EditModeMenu demoMode={demoMode ?? false} />
|
||||||
|
|
||||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
{!demoMode && (
|
||||||
<IconSettings stroke={1.5} />
|
<OnboardingTour.Target id="board-settings">
|
||||||
</HeaderButton>
|
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||||
|
<IconSettings stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
</OnboardingTour.Target>
|
||||||
|
)}
|
||||||
|
|
||||||
<SelectBoardsMenu />
|
<SelectBoardsMenu />
|
||||||
</>
|
</>
|
||||||
@@ -67,6 +75,7 @@ const AddMenu = () => {
|
|||||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||||
|
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||||
const { addCategoryToEnd } = useCategoryActions();
|
const { addCategoryToEnd } = useCategoryActions();
|
||||||
const { addDynamicSection } = useDynamicSectionActions();
|
const { addDynamicSection } = useDynamicSectionActions();
|
||||||
const { createItem } = useItemActions();
|
const { createItem } = useItemActions();
|
||||||
@@ -108,6 +117,10 @@ const AddMenu = () => {
|
|||||||
});
|
});
|
||||||
}, [openAppSelectModal, createItem]);
|
}, [openAppSelectModal, createItem]);
|
||||||
|
|
||||||
|
const handleAddIntegration = useCallback(() => {
|
||||||
|
openIntegrationSelectModal({});
|
||||||
|
}, [openIntegrationSelectModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end">
|
<Menu position="bottom-end">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
@@ -127,6 +140,10 @@ const AddMenu = () => {
|
|||||||
{t("app.action.add")}
|
{t("app.action.add")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||||
|
{t("integration.action.create")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||||
@@ -141,7 +158,7 @@ const AddMenu = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditModeMenu = () => {
|
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
||||||
const [isEditMode, { open, close }] = useEditMode();
|
const [isEditMode, { open, close }] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
@@ -164,18 +181,28 @@ const EditModeMenu = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const discardDemoChanges = useCallback(() => {
|
||||||
|
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
|
close();
|
||||||
|
}, [utils, board.name, close]);
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
if (isEditMode) return saveBoard(board);
|
if (isEditMode) {
|
||||||
|
if (demoMode) return discardDemoChanges();
|
||||||
|
return saveBoard(board);
|
||||||
|
}
|
||||||
open();
|
open();
|
||||||
}, [board, isEditMode, saveBoard, open]);
|
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
|
||||||
|
|
||||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||||
usePreventLeaveWithDirty(isEditMode);
|
usePreventLeaveWithDirty(isEditMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderButton onClick={toggle} loading={isPending}>
|
<OnboardingTour.Target id="board-edit-mode">
|
||||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
<HeaderButton onClick={toggle} loading={isPending}>
|
||||||
</HeaderButton>
|
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||||
|
</HeaderButton>
|
||||||
|
</OnboardingTour.Target>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,27 +210,31 @@ const SelectBoardsMenu = () => {
|
|||||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end">
|
<OnboardingTour.Target id="board-switcher">
|
||||||
<Menu.Target>
|
<Box>
|
||||||
<HeaderButton w="auto" px={4}>
|
<Menu position="bottom-end">
|
||||||
<IconReplace stroke={1.5} />
|
<Menu.Target>
|
||||||
</HeaderButton>
|
<HeaderButton w="auto" px={4}>
|
||||||
</Menu.Target>
|
<IconReplace stroke={1.5} />
|
||||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
</HeaderButton>
|
||||||
<ScrollArea.Autosize mah={300}>
|
</Menu.Target>
|
||||||
{boards.map((board) => (
|
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||||
<Menu.Item
|
<ScrollArea.Autosize mah={300}>
|
||||||
key={board.id}
|
{boards.map((board) => (
|
||||||
component={Link}
|
<Menu.Item
|
||||||
href={`/boards/${board.name}`}
|
key={board.id}
|
||||||
leftSection={<IconLayoutBoard size={20} />}
|
component={Link}
|
||||||
>
|
href={`/boards/${board.name}`}
|
||||||
{board.name}
|
leftSection={<IconLayoutBoard size={20} />}
|
||||||
</Menu.Item>
|
>
|
||||||
))}
|
{board.name}
|
||||||
</ScrollArea.Autosize>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
))}
|
||||||
</Menu>
|
</ScrollArea.Autosize>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
</OnboardingTour.Target>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -244,7 +275,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
if (env.NODE_ENV === "development") return; // Allow to reload in development
|
if (env.NODE_ENV === "development") return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.returnValue = true;
|
event.returnValue = true;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
|||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
|
import { BoardTourProvider } from "~/components/onboarding/board-tour";
|
||||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
import type { Board } from "./_types";
|
import type { Board } from "./_types";
|
||||||
import type { Params } from "./(content)/_creator";
|
import type { Params } from "./(content)/_creator";
|
||||||
@@ -20,14 +21,21 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
|||||||
|
|
||||||
const logger = createLogger({ module: "createBoardLayout" });
|
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> {
|
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||||
headerActions: JSX.Element;
|
headerActions: JSX.Element;
|
||||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
|
withTour?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBoardLayout = <TParams extends Params>({
|
export const createBoardLayout = <TParams extends Params>({
|
||||||
headerActions,
|
headerActions,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
|
withTour = false,
|
||||||
}: CreateBoardLayoutProps<TParams>) => {
|
}: CreateBoardLayoutProps<TParams>) => {
|
||||||
const Layout = async ({
|
const Layout = async ({
|
||||||
params,
|
params,
|
||||||
@@ -61,14 +69,16 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
<EditModeProvider>
|
<EditModeProvider>
|
||||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||||
<CustomCss />
|
<CustomCss />
|
||||||
<ClientShell hasNavigation={false}>
|
<BoardTourWrapper hasSession={withTour && !!session}>
|
||||||
<MainHeader
|
<ClientShell hasNavigation={false}>
|
||||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
<MainHeader
|
||||||
actions={headerActions}
|
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||||
hasNavigation={false}
|
actions={headerActions}
|
||||||
/>
|
hasNavigation={false}
|
||||||
<AppShellMain>{children}</AppShellMain>
|
/>
|
||||||
</ClientShell>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
|
</ClientShell>
|
||||||
|
</BoardTourWrapper>
|
||||||
</BoardMantineProvider>
|
</BoardMantineProvider>
|
||||||
</EditModeProvider>
|
</EditModeProvider>
|
||||||
</BoardReadyProvider>
|
</BoardReadyProvider>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { Card } from "@mantine/core";
|
||||||
|
|
||||||
|
interface InitStepCardProps {
|
||||||
|
withBorder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InitStepCard = ({ children, withBorder = true }: PropsWithChildren<InitStepCardProps>) => (
|
||||||
|
<Card w="100%" withBorder={withBorder}>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const onboardingContentWidth = 64 * 16;
|
||||||
|
export const onboardingMaxWidth = "90vw";
|
||||||
@@ -1,39 +1,27 @@
|
|||||||
import type { MantineColor } from "@mantine/core";
|
import type { MantineColor } from "@mantine/core";
|
||||||
import { Button, Card, Stack, Text } from "@mantine/core";
|
import { Button, Stack, Text } from "@mantine/core";
|
||||||
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
|
import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { getMantineColor } from "@homarr/common";
|
import { getMantineColor } from "@homarr/common";
|
||||||
import { db } from "@homarr/db";
|
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { InitStepCard } from "../../_components/init-step-card";
|
||||||
|
|
||||||
export const InitFinish = async () => {
|
export const InitFinish = async () => {
|
||||||
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
|
|
||||||
const tFinish = await getScopedI18n("init.step.finish");
|
const tFinish = await getScopedI18n("init.step.finish");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card w={64 * 6} maw="90vw">
|
<InitStepCard>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text>{tFinish("description")}</Text>
|
<Text>{tFinish("description")}</Text>
|
||||||
|
|
||||||
{firstBoard ? (
|
<InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}>
|
||||||
<InternalLinkButton
|
{tFinish("action.goToBoard", { name: "dashboard" })}
|
||||||
href={`/auth/login?callbackUrl=/boards/${firstBoard.name}`}
|
</InternalLinkButton>
|
||||||
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
|
|
||||||
>
|
|
||||||
{tFinish("action.goToBoard", { name: firstBoard.name })}
|
|
||||||
</InternalLinkButton>
|
|
||||||
) : (
|
|
||||||
<InternalLinkButton
|
|
||||||
href="/auth/login?callbackUrl=/manage/boards"
|
|
||||||
iconProps={{ icon: IconCategoryPlus, color: "blue" }}
|
|
||||||
>
|
|
||||||
{tFinish("action.createBoard")}
|
|
||||||
</InternalLinkButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isProviderEnabled("credentials") && (
|
{isProviderEnabled("credentials") && (
|
||||||
<InternalLinkButton
|
<InternalLinkButton
|
||||||
@@ -51,7 +39,7 @@ export const InitFinish = async () => {
|
|||||||
{tFinish("action.docs")}
|
{tFinish("action.docs")}
|
||||||
</ExternalLinkButton>
|
</ExternalLinkButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</InitStepCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Card, Stack, TextInput } from "@mantine/core";
|
import { Button, Stack, TextInput } from "@mantine/core";
|
||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
import type { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ import { useZodForm } from "@homarr/form";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { groupCreateSchema } from "@homarr/validation/group";
|
import { groupCreateSchema } from "@homarr/validation/group";
|
||||||
|
|
||||||
|
import { InitStepCard } from "../../_components/init-step-card";
|
||||||
|
|
||||||
export const InitGroup = () => {
|
export const InitGroup = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
|
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
|
||||||
@@ -34,7 +36,7 @@ export const InitGroup = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card w={64 * 6} maw="90vw">
|
<InitStepCard>
|
||||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -48,6 +50,6 @@ export const InitGroup = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</InitStepCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { ActionIcon, Button, Card, Group, Text } from "@mantine/core";
|
import { ActionIcon, Button, Group, Text } from "@mantine/core";
|
||||||
import type { FileWithPath } from "@mantine/dropzone";
|
import type { FileWithPath } from "@mantine/dropzone";
|
||||||
import { IconPencil } from "@tabler/icons-react";
|
import { IconPencil } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { humanFileSize } from "@homarr/common";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { InitStepCard } from "../../_components/init-step-card";
|
||||||
|
|
||||||
interface FileInfoCardProps {
|
interface FileInfoCardProps {
|
||||||
file: FileWithPath;
|
file: FileWithPath;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
@@ -13,7 +15,7 @@ interface FileInfoCardProps {
|
|||||||
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||||
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
||||||
return (
|
return (
|
||||||
<Card w={64 * 12 + 8} maw="90vw" withBorder={false}>
|
<InitStepCard withBorder={false}>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<Group>
|
<Group>
|
||||||
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
||||||
@@ -36,6 +38,6 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
|||||||
<IconPencil size={16} stroke={1.5} />
|
<IconPencil size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</InitStepCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { startTransition, useState } from "react";
|
import { startTransition, useState } from "react";
|
||||||
import { Card, Stack } from "@mantine/core";
|
import { Stack } from "@mantine/core";
|
||||||
import type { FileWithPath } from "@mantine/dropzone";
|
import type { FileWithPath } from "@mantine/dropzone";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { InitialOldmarrImport } from "@homarr/old-import/components";
|
import { InitialOldmarrImport } from "@homarr/old-import/components";
|
||||||
|
|
||||||
|
import { InitStepCard } from "../../_components/init-step-card";
|
||||||
import { FileInfoCard } from "./file-info-card";
|
import { FileInfoCard } from "./file-info-card";
|
||||||
import { ImportDropZone } from "./import-dropzone";
|
import { ImportDropZone } from "./import-dropzone";
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ export const InitImport = () => {
|
|||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return (
|
return (
|
||||||
<Card w={64 * 12 + 8} maw="90vw">
|
<InitStepCard>
|
||||||
<ImportDropZone
|
<ImportDropZone
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
updateFile={(file) => {
|
updateFile={(file) => {
|
||||||
@@ -40,12 +41,12 @@ export const InitImport = () => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</InitStepCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack mb="sm">
|
<Stack w="100%" mb="sm">
|
||||||
<FileInfoCard file={file} onRemove={() => setFile(null)} />
|
<FileInfoCard file={file} onRemove={() => setFile(null)} />
|
||||||
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
|
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
Collapse,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconBrandDocker, IconCheck, IconChevronDown } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
|
export interface DiscoveredIntegration {
|
||||||
|
containerId: string;
|
||||||
|
containerName: string;
|
||||||
|
kind: IntegrationKind;
|
||||||
|
suggestedUrl: string;
|
||||||
|
publishedPort: number | null;
|
||||||
|
iconUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoveredApp {
|
||||||
|
containerId: string;
|
||||||
|
containerName: string;
|
||||||
|
suggestedUrl: string;
|
||||||
|
publishedPort: number | null;
|
||||||
|
iconUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DockerDiscoveryIndicatorProps {
|
||||||
|
integrations: DiscoveredIntegration[];
|
||||||
|
apps: DiscoveredApp[];
|
||||||
|
selectedAppIds: Set<string>;
|
||||||
|
onToggleApp: (containerId: string) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APP_CARD_HEIGHT = 80;
|
||||||
|
|
||||||
|
export const DockerDiscoveryIndicator = ({
|
||||||
|
integrations,
|
||||||
|
apps,
|
||||||
|
selectedAppIds,
|
||||||
|
onToggleApp,
|
||||||
|
isLoading,
|
||||||
|
}: DockerDiscoveryIndicatorProps) => {
|
||||||
|
const hasApps = apps.length > 0;
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const expandedOnce = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if ((hasApps || integrations.length > 0) && !expandedOnce.current) {
|
||||||
|
expandedOnce.current = true;
|
||||||
|
setExpanded(true);
|
||||||
|
}
|
||||||
|
}, [hasApps, integrations.length]);
|
||||||
|
|
||||||
|
const t = useScopedI18n("init.step.integrations.docker");
|
||||||
|
const totalCount = integrations.length + apps.length;
|
||||||
|
|
||||||
|
if (!isLoading && totalCount === 0) return null;
|
||||||
|
|
||||||
|
const selectedAppCount = apps.filter((app) => selectedAppIds.has(app.containerId)).length;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
p="sm"
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--mantine-color-blue-4)",
|
||||||
|
background: "var(--mantine-color-blue-light)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
||||||
|
<IconBrandDocker size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={0} style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{t("scanning")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("scanningDescription")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Loader size="sm" color="blue" type="dots" />
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
p="sm"
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--mantine-color-blue-4)",
|
||||||
|
background: "var(--mantine-color-blue-light)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UnstyledButton onClick={() => setExpanded((prev) => !prev)} w="100%">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
||||||
|
<IconBrandDocker size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{t("detected", { count: String(totalCount) })}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("detectedBreakdown", {
|
||||||
|
integrations: String(integrations.length),
|
||||||
|
apps: String(apps.length),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<IconChevronDown
|
||||||
|
size={16}
|
||||||
|
color="var(--mantine-color-dimmed)"
|
||||||
|
style={{
|
||||||
|
transform: expanded ? "rotate(180deg)" : undefined,
|
||||||
|
transition: "transform 200ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
|
||||||
|
<Collapse expanded={expanded}>
|
||||||
|
<Stack gap="xs" mt="sm">
|
||||||
|
{integrations.length > 0 && (
|
||||||
|
<Stack gap={4}>
|
||||||
|
{integrations.map((integration) => (
|
||||||
|
<Group key={integration.containerId} gap="xs" wrap="nowrap" py={2}>
|
||||||
|
<IntegrationAvatar kind={integration.kind} size="xs" />
|
||||||
|
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{integration.containerName}
|
||||||
|
</Text>
|
||||||
|
<Badge size="xs" variant="light" color="blue">
|
||||||
|
{getIntegrationName(integration.kind)}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasApps && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={500}>
|
||||||
|
{t("appSectionLabel", { count: String(selectedAppCount) })}
|
||||||
|
</Text>
|
||||||
|
<ScrollArea.Autosize mah="30vh">
|
||||||
|
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
|
||||||
|
{apps.map((app) => {
|
||||||
|
const isSelected = selectedAppIds.has(app.containerId);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={app.containerId}
|
||||||
|
h={APP_CARD_HEIGHT}
|
||||||
|
p="xs"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
||||||
|
borderWidth: isSelected ? 2 : undefined,
|
||||||
|
opacity: isSelected ? 1 : 0.6,
|
||||||
|
}}
|
||||||
|
onClick={() => onToggleApp(app.containerId)}
|
||||||
|
>
|
||||||
|
<Stack justify="space-between" h="100%" gap={4} align="center">
|
||||||
|
<Group justify="space-between" w="100%" wrap="nowrap">
|
||||||
|
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{app.containerName}
|
||||||
|
</Text>
|
||||||
|
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
||||||
|
</Group>
|
||||||
|
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
||||||
|
{app.containerName.at(0)?.toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
Collapse,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Progress,
|
||||||
|
SegmentedControl,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import {
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconArrowRight,
|
||||||
|
IconBrandDocker,
|
||||||
|
IconCheck,
|
||||||
|
IconChevronDown,
|
||||||
|
IconSettings,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions";
|
||||||
|
import { buildAppUrl, buildIntegrationUrl, getIntegrationName } from "@homarr/definitions";
|
||||||
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
||||||
|
import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid";
|
||||||
|
import { InitStepCard } from "../../_components/init-step-card";
|
||||||
|
|
||||||
|
type Phase = "select" | "configure" | "done";
|
||||||
|
|
||||||
|
interface IntegrationDraft {
|
||||||
|
kind: IntegrationKind;
|
||||||
|
initialUrl: string;
|
||||||
|
initialName?: string;
|
||||||
|
dockerPort?: number | null;
|
||||||
|
source: "manual" | "docker";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InitIntegrations = () => {
|
||||||
|
const [phase, setPhase] = useState<Phase>("select");
|
||||||
|
const [drafts, setDrafts] = useState<Map<IntegrationKind, IntegrationDraft>>(new Map());
|
||||||
|
const [selectedKinds, setSelectedKinds] = useState<IntegrationKind[]>([]);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [baseHost, setBaseHost] = useState("");
|
||||||
|
const [urlMode, setUrlMode] = useState<UrlTemplateMode>("subdomain");
|
||||||
|
const t = useScopedI18n("init.step.integrations");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
|
||||||
|
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
|
||||||
|
|
||||||
|
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : [];
|
||||||
|
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
|
||||||
|
const totalDetected = dockerIntegrations.length + dockerApps.length;
|
||||||
|
|
||||||
|
const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]);
|
||||||
|
|
||||||
|
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
|
||||||
|
const [appsExpanded, setAppsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const dockerApplied = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (dockerData?.status !== "success" || dockerApplied.current) return;
|
||||||
|
dockerApplied.current = true;
|
||||||
|
|
||||||
|
const newDrafts = new Map(drafts);
|
||||||
|
const newKinds = new Set(selectedKinds);
|
||||||
|
|
||||||
|
for (const item of dockerData.integrations) {
|
||||||
|
if (!newDrafts.has(item.kind)) {
|
||||||
|
newDrafts.set(item.kind, {
|
||||||
|
kind: item.kind,
|
||||||
|
initialUrl: item.suggestedUrl,
|
||||||
|
initialName: item.containerName,
|
||||||
|
dockerPort: item.publishedPort,
|
||||||
|
source: "docker",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
newKinds.add(item.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDrafts(newDrafts);
|
||||||
|
setSelectedKinds(Array.from(newKinds));
|
||||||
|
setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId)));
|
||||||
|
if (dockerData.apps.length > 0) {
|
||||||
|
setAppsExpanded(true);
|
||||||
|
}
|
||||||
|
}, [dockerData]);
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => {
|
||||||
|
setSelectedKinds(kinds);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleApp = useCallback((containerId: string) => {
|
||||||
|
setSelectedAppIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(containerId)) {
|
||||||
|
next.delete(containerId);
|
||||||
|
} else {
|
||||||
|
next.add(containerId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleContinueToConfig = () => {
|
||||||
|
const newDrafts = new Map(drafts);
|
||||||
|
for (const kind of selectedKinds) {
|
||||||
|
const existing = newDrafts.get(kind);
|
||||||
|
if (baseHost) {
|
||||||
|
newDrafts.set(kind, {
|
||||||
|
kind,
|
||||||
|
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
|
||||||
|
initialName: existing?.initialName,
|
||||||
|
dockerPort: existing?.dockerPort,
|
||||||
|
source: existing?.source ?? "manual",
|
||||||
|
});
|
||||||
|
} else if (!existing) {
|
||||||
|
newDrafts.set(kind, { kind, initialUrl: "", source: "manual" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDrafts(newDrafts);
|
||||||
|
setCurrentIndex(0);
|
||||||
|
setPhase("configure");
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishSetupAsync = async () => {
|
||||||
|
setPhase("done");
|
||||||
|
try {
|
||||||
|
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
|
||||||
|
if (appsToCreate.length > 0) {
|
||||||
|
await createApps(
|
||||||
|
appsToCreate.map((app) => {
|
||||||
|
const href = baseHost
|
||||||
|
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
|
||||||
|
: app.suggestedUrl;
|
||||||
|
return {
|
||||||
|
name: app.containerName,
|
||||||
|
href: href || null,
|
||||||
|
iconUrl: app.iconUrl,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await setupIntegrations();
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("configure.error.title"),
|
||||||
|
message: t("configure.error.message"),
|
||||||
|
});
|
||||||
|
setPhase("select");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const advanceOrFinish = () => {
|
||||||
|
if (currentIndex + 1 >= selectedKinds.length) {
|
||||||
|
void finishSetupAsync();
|
||||||
|
} else {
|
||||||
|
setCurrentIndex((i) => i + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const backToSelect = () => {
|
||||||
|
setPhase("select");
|
||||||
|
setCurrentIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (phase === "done") {
|
||||||
|
return (
|
||||||
|
<InitStepCard>
|
||||||
|
<Center py="xl">
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<Loader size="lg" />
|
||||||
|
<Text>{t("configure.finishing")}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</InitStepCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "configure") {
|
||||||
|
return (
|
||||||
|
<ConfigurePhase
|
||||||
|
selectedKinds={selectedKinds}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
drafts={drafts}
|
||||||
|
baseHost={baseHost}
|
||||||
|
setBaseHost={setBaseHost}
|
||||||
|
urlMode={urlMode}
|
||||||
|
setUrlMode={setUrlMode}
|
||||||
|
onSuccess={advanceOrFinish}
|
||||||
|
onCancel={backToSelect}
|
||||||
|
onSkip={advanceOrFinish}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedAppCount = dockerApps.filter((app) => selectedAppIds.has(app.containerId)).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InitStepCard>
|
||||||
|
<Stack>
|
||||||
|
<Text>{t("description")}</Text>
|
||||||
|
|
||||||
|
<Alert variant="outline" color="orange" icon={<IconAlertTriangle />} title={t("urlTemplate.label")}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t("urlTemplate.warning")}
|
||||||
|
</Text>
|
||||||
|
<Group gap="sm" grow>
|
||||||
|
<TextInput
|
||||||
|
placeholder={t("urlTemplate.placeholder")}
|
||||||
|
value={baseHost}
|
||||||
|
onChange={(e) => setBaseHost(e.currentTarget.value)}
|
||||||
|
data-autofocus
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
value={urlMode}
|
||||||
|
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
|
||||||
|
data={[
|
||||||
|
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
|
||||||
|
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
|
||||||
|
]}
|
||||||
|
style={{ flex: "0 0 auto" }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
{baseHost && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{isDockerLoading && (
|
||||||
|
<Paper withBorder p="sm" radius="md">
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
||||||
|
<IconBrandDocker size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t("docker.scanning")}
|
||||||
|
</Text>
|
||||||
|
<Loader size="sm" color="blue" type="dots" />
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isDockerLoading && totalDetected > 0 && (
|
||||||
|
<Paper withBorder p="sm" radius="md" style={{ borderColor: "var(--mantine-color-teal-4)" }}>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon variant="light" color="teal" size="lg" radius="md">
|
||||||
|
<IconBrandDocker size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm">
|
||||||
|
{t("docker.summary", {
|
||||||
|
apps: String(totalDetected),
|
||||||
|
integrations: String(dockerIntegrations.length),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IntegrationMultiSelectGrid
|
||||||
|
selectedKinds={selectedKinds}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
detectedKinds={detectedKinds}
|
||||||
|
onboarding
|
||||||
|
/>
|
||||||
|
|
||||||
|
{dockerApps.length > 0 && (
|
||||||
|
<Paper withBorder p="sm" radius="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => setAppsExpanded((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t("docker.appSectionLabel", { count: String(selectedAppCount) })}
|
||||||
|
</Text>
|
||||||
|
<IconChevronDown
|
||||||
|
size={16}
|
||||||
|
color="var(--mantine-color-dimmed)"
|
||||||
|
style={{
|
||||||
|
transform: appsExpanded ? "rotate(180deg)" : undefined,
|
||||||
|
transition: "transform 200ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Collapse expanded={appsExpanded}>
|
||||||
|
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
|
||||||
|
{dockerApps.map((app) => {
|
||||||
|
const isSelected = selectedAppIds.has(app.containerId);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={app.containerId}
|
||||||
|
h={80}
|
||||||
|
p="xs"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
||||||
|
borderWidth: isSelected ? 2 : undefined,
|
||||||
|
opacity: isSelected ? 1 : 0.6,
|
||||||
|
}}
|
||||||
|
onClick={() => handleToggleApp(app.containerId)}
|
||||||
|
>
|
||||||
|
<Stack justify="space-between" h="100%" gap={4} align="center">
|
||||||
|
<Group justify="space-between" w="100%" wrap="nowrap">
|
||||||
|
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{app.containerName}
|
||||||
|
</Text>
|
||||||
|
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
||||||
|
</Group>
|
||||||
|
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
||||||
|
{app.containerName.at(0)?.toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Collapse>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
|
||||||
|
{t("action.skip")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleContinueToConfig}
|
||||||
|
disabled={selectedKinds.length === 0 || undefined}
|
||||||
|
rightSection={<IconArrowRight size={16} stroke={1.5} />}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
{t("action.continue")} {selectedKinds.length > 0 && `(${selectedKinds.length})`}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</InitStepCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ConfigurePhaseProps {
|
||||||
|
selectedKinds: IntegrationKind[];
|
||||||
|
currentIndex: number;
|
||||||
|
drafts: Map<IntegrationKind, IntegrationDraft>;
|
||||||
|
baseHost: string;
|
||||||
|
setBaseHost: (value: string) => void;
|
||||||
|
urlMode: UrlTemplateMode;
|
||||||
|
setUrlMode: (value: UrlTemplateMode) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfigurePhase = ({
|
||||||
|
selectedKinds,
|
||||||
|
currentIndex,
|
||||||
|
drafts,
|
||||||
|
baseHost,
|
||||||
|
setBaseHost,
|
||||||
|
urlMode,
|
||||||
|
setUrlMode,
|
||||||
|
onSuccess,
|
||||||
|
onCancel,
|
||||||
|
onSkip,
|
||||||
|
}: ConfigurePhaseProps) => {
|
||||||
|
const t = useScopedI18n("init.step.integrations");
|
||||||
|
const [helpersOpen, { toggle: toggleHelpers }] = useDisclosure(false);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds
|
||||||
|
const currentKind = selectedKinds[currentIndex]!;
|
||||||
|
const draft = drafts.get(currentKind);
|
||||||
|
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
|
||||||
|
const computedUrl = baseHost
|
||||||
|
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
|
||||||
|
: draft?.initialUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InitStepCard>
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Group gap="xs">
|
||||||
|
<IntegrationAvatar kind={currentKind} size="sm" />
|
||||||
|
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
|
||||||
|
</Group>
|
||||||
|
<Tooltip label={t("configure.helpers.tooltip")}>
|
||||||
|
<ActionIcon variant={helpersOpen ? "light" : "subtle"} color="gray" onClick={toggleHelpers}>
|
||||||
|
<IconSettings size={18} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Progress value={progress} size="sm" />
|
||||||
|
<Text size="xs" c="dimmed" ta="center">
|
||||||
|
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Collapse expanded={helpersOpen}>
|
||||||
|
<Stack
|
||||||
|
gap="xs"
|
||||||
|
p="xs"
|
||||||
|
style={{ background: "var(--mantine-color-default-hover)", borderRadius: "var(--mantine-radius-sm)" }}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("configure.helpers.description")}
|
||||||
|
</Text>
|
||||||
|
<Group gap="sm" grow>
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
placeholder={t("urlTemplate.placeholder")}
|
||||||
|
value={baseHost}
|
||||||
|
onChange={(e) => setBaseHost(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
value={urlMode}
|
||||||
|
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
|
||||||
|
data={[
|
||||||
|
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
|
||||||
|
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
|
||||||
|
]}
|
||||||
|
style={{ flex: "0 0 auto" }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
{baseHost && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("urlTemplate.preview", {
|
||||||
|
example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<NewIntegrationForm
|
||||||
|
key={currentKind}
|
||||||
|
kind={currentKind}
|
||||||
|
initialUrl={computedUrl}
|
||||||
|
initialName={draft?.initialName}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSkip={onSkip}
|
||||||
|
isOnboarding
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</InitStepCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
|
import { Button, Group, Stack, Switch, Text } from "@mantine/core";
|
||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
import type { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ import type { TranslationObject } from "@homarr/translation";
|
|||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { settingsInitSchema } from "@homarr/validation/settings";
|
import { settingsInitSchema } from "@homarr/validation/settings";
|
||||||
|
|
||||||
|
import { InitStepCard } from "../../_components/init-step-card";
|
||||||
|
|
||||||
export const InitSettings = () => {
|
export const InitSettings = () => {
|
||||||
const tSection = useScopedI18n("management.page.settings.section");
|
const tSection = useScopedI18n("management.page.settings.section");
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -29,8 +31,8 @@ export const InitSettings = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||||
<Stack>
|
<Stack w="100%">
|
||||||
<Card w={64 * 12 + 8} maw="90vw">
|
<InitStepCard>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text fw={500}>{tSection("analytics.title")}</Text>
|
<Text fw={500}>{tSection("analytics.title")}</Text>
|
||||||
|
|
||||||
@@ -38,8 +40,8 @@ export const InitSettings = () => {
|
|||||||
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
|
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</InitStepCard>
|
||||||
<Card w={64 * 12 + 8} maw="90vw">
|
<InitStepCard>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ export const InitSettings = () => {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</InitStepCard>
|
||||||
|
|
||||||
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
|
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
|
||||||
{t("common.action.continue")}
|
{t("common.action.continue")}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { Card, Stack, Text } from "@mantine/core";
|
import { Stack, Text } from "@mantine/core";
|
||||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { getMantineColor } from "@homarr/common";
|
import { getMantineColor } from "@homarr/common";
|
||||||
|
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { InitStepCard } from "../../_components/init-step-card";
|
||||||
|
import { InitSqliteRestore } from "./sqlite-restore";
|
||||||
import { InitStartButton } from "./next-button";
|
import { InitStartButton } from "./next-button";
|
||||||
|
|
||||||
export const InitStart = async () => {
|
export const InitStart = async () => {
|
||||||
const tStart = await getScopedI18n("init.step.start");
|
const tStart = await getScopedI18n("init.step.start");
|
||||||
|
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card w={64 * 6} maw="90vw">
|
<InitStepCard>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text>{tStart("description")}</Text>
|
<Text>{tStart("description")}</Text>
|
||||||
|
|
||||||
@@ -26,7 +30,8 @@ export const InitStart = async () => {
|
|||||||
>
|
>
|
||||||
{tStart("action.importOldmarr")}
|
{tStart("action.importOldmarr")}
|
||||||
</InitStartButton>
|
</InitStartButton>
|
||||||
|
{isSqlite && <InitSqliteRestore />}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</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,11 +1,10 @@
|
|||||||
import { Card } from "@mantine/core";
|
import { InitStepCard } from "../../_components/init-step-card";
|
||||||
|
|
||||||
import { InitUserForm } from "./init-user-form";
|
import { InitUserForm } from "./init-user-form";
|
||||||
|
|
||||||
export const InitUser = () => {
|
export const InitUser = () => {
|
||||||
return (
|
return (
|
||||||
<Card w={64 * 6} maw="90vw">
|
<InitStepCard>
|
||||||
<InitUserForm />
|
<InitUserForm />
|
||||||
</Card>
|
</InitStepCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -9,10 +9,12 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
|
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
|
||||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||||
|
import { onboardingContentWidth, onboardingMaxWidth } from "./_constants";
|
||||||
import { BackToStart } from "./_steps/back";
|
import { BackToStart } from "./_steps/back";
|
||||||
import { InitFinish } from "./_steps/finish/init-finish";
|
import { InitFinish } from "./_steps/finish/init-finish";
|
||||||
import { InitGroup } from "./_steps/group/init-group";
|
import { InitGroup } from "./_steps/group/init-group";
|
||||||
import { InitImport } from "./_steps/import/init-import";
|
import { InitImport } from "./_steps/import/init-import";
|
||||||
|
import { InitIntegrations } from "./_steps/integrations/init-integrations";
|
||||||
import { InitSettings } from "./_steps/settings/init-settings";
|
import { InitSettings } from "./_steps/settings/init-settings";
|
||||||
import { InitStart } from "./_steps/start/init-start";
|
import { InitStart } from "./_steps/start/init-start";
|
||||||
import { InitUser } from "./_steps/user/init-user";
|
import { InitUser } from "./_steps/user/init-user";
|
||||||
@@ -23,6 +25,7 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
|
|||||||
user: InitUser,
|
user: InitUser,
|
||||||
group: InitGroup,
|
group: InitGroup,
|
||||||
settings: InitSettings,
|
settings: InitSettings,
|
||||||
|
integrations: InitIntegrations,
|
||||||
finish: InitFinish,
|
finish: InitFinish,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,7 +38,7 @@ export default async function InitPage() {
|
|||||||
return (
|
return (
|
||||||
<Box mih="100dvh">
|
<Box mih="100dvh">
|
||||||
<Center>
|
<Center>
|
||||||
<Stack align="center" mt="xl">
|
<Stack align="center" mt="xl" w={onboardingContentWidth} maw={onboardingMaxWidth}>
|
||||||
<HomarrLogoWithTitle size="lg" />
|
<HomarrLogoWithTitle size="lg" />
|
||||||
<Stack gap={6} align="center">
|
<Stack gap={6} align="center">
|
||||||
<Title order={3} fw={400} ta="center">
|
<Title order={3} fw={400} ta="center">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
|
import "@gfazioli/mantine-onboarding-tour/styles.css";
|
||||||
import "@homarr/notifications/styles.css";
|
import "@homarr/notifications/styles.css";
|
||||||
import "@homarr/spotlight/styles.css";
|
import "@homarr/spotlight/styles.css";
|
||||||
import "@homarr/ui/styles.css";
|
import "@homarr/ui/styles.css";
|
||||||
@@ -25,6 +26,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
|||||||
|
|
||||||
import { Analytics } from "~/components/layout/analytics";
|
import { Analytics } from "~/components/layout/analytics";
|
||||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||||
|
|
||||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
|||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
@@ -45,9 +46,11 @@ export default async function AppsPage(props: AppsPageProps) {
|
|||||||
title={t("page.list.title")}
|
title={t("page.list.title")}
|
||||||
primaryAction={
|
primaryAction={
|
||||||
canCreate ? (
|
canCreate ? (
|
||||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
<TourTarget id="manage-apps-create">
|
||||||
{t("page.create.title")}
|
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||||
</MobileAffixButton>
|
{t("page.create.title")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
</TourTarget>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
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 && <AppNoResults />}
|
||||||
{apps.length > 0 && (
|
{apps.length > 0 && (
|
||||||
<Stack gap="sm">
|
<TourTarget id="manage-apps-list">
|
||||||
{apps.map((app) => (
|
<Stack gap="sm">
|
||||||
<AppCard key={app.id} app={app} />
|
{apps.map((app) => (
|
||||||
))}
|
<AppCard key={app.id} app={app} />
|
||||||
</Stack>
|
))}
|
||||||
|
</Stack>
|
||||||
|
</TourTarget>
|
||||||
)}
|
)}
|
||||||
</ManagePageLayout>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
import { Link, UserAvatar } from "@homarr/ui";
|
import { Link, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||||
|
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||||
@@ -42,18 +43,26 @@ export default async function ManageBoardsPage() {
|
|||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManagePageLayout
|
||||||
title={t("title")}
|
title={t("title")}
|
||||||
primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined}
|
primaryAction={
|
||||||
|
canCreateBoards ? (
|
||||||
|
<TourTarget id="manage-boards-create">
|
||||||
|
<CreateBoardButton />
|
||||||
|
</TourTarget>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
floatingPrimaryAction={canCreateBoards}
|
floatingPrimaryAction={canCreateBoards}
|
||||||
>
|
>
|
||||||
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
||||||
{boards.length > 0 && (
|
{boards.length > 0 && (
|
||||||
<Grid>
|
<TourTarget id="manage-boards-list">
|
||||||
{boards.map((board) => (
|
<Grid>
|
||||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
{boards.map((board) => (
|
||||||
<BoardCard board={board} />
|
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||||
</GridCol>
|
<BoardCard board={board} />
|
||||||
))}
|
</GridCol>
|
||||||
</Grid>
|
))}
|
||||||
|
</Grid>
|
||||||
|
</TourTarget>
|
||||||
)}
|
)}
|
||||||
</ManagePageLayout>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ChangeEvent } from "react";
|
|
||||||
import React, { useMemo, useState } from "react";
|
|
||||||
import { Flex, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
|
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import { IntegrationAvatar, Link } from "@homarr/ui";
|
|
||||||
|
|
||||||
interface IntegrationCreateDropdownContentProps {
|
|
||||||
enableMockIntegration: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
const filteredKinds = useMemo(() => {
|
|
||||||
return integrationKinds
|
|
||||||
.filter((kind) => enableMockIntegration || kind !== "mock")
|
|
||||||
.filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()))
|
|
||||||
.sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB)));
|
|
||||||
}, [search, enableMockIntegration]);
|
|
||||||
|
|
||||||
const handleSearch = React.useCallback(
|
|
||||||
(event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value),
|
|
||||||
[setSearch],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction={{ base: "column-reverse", md: "column" }} gap="sm">
|
|
||||||
<TextInput
|
|
||||||
leftSection={<IconSearch stroke={1.5} size={20} />}
|
|
||||||
placeholder={t("integration.page.list.search")}
|
|
||||||
value={search}
|
|
||||||
data-autofocus
|
|
||||||
onChange={handleSearch}
|
|
||||||
variant="filled"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{filteredKinds.length > 0 ? (
|
|
||||||
<ScrollArea.Autosize mah={384}>
|
|
||||||
{filteredKinds.map((kind) => (
|
|
||||||
<Menu.Item component={Link} href={`/manage/integrations/new?kind=${kind}`} key={kind}>
|
|
||||||
<Group>
|
|
||||||
<IntegrationAvatar kind={kind} size="sm" />
|
|
||||||
<Text size="sm">{getIntegrationName(kind)}</Text>
|
|
||||||
</Group>
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
) : (
|
|
||||||
<Menu.Item disabled>{t("common.noResults")}</Menu.Item>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { NewIntegrationForm } from "./_integration-new-form";
|
||||||
|
|
||||||
|
interface IntegrationNewFormWrapperProps {
|
||||||
|
kind: IntegrationKind;
|
||||||
|
initialUrl?: string;
|
||||||
|
initialName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntegrationNewFormWrapper = ({ kind, initialUrl, initialName }: IntegrationNewFormWrapperProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NewIntegrationForm
|
||||||
|
kind={kind}
|
||||||
|
initialUrl={initialUrl}
|
||||||
|
initialName={initialName}
|
||||||
|
onSuccess={() => router.push("/manage/integrations")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { startTransition, useState } from "react";
|
import { startTransition, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Anchor,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Collapse,
|
Collapse,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
|
import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -27,6 +27,7 @@ import type { IntegrationKind } from "@homarr/definitions";
|
|||||||
import {
|
import {
|
||||||
getAllSecretKindOptions,
|
getAllSecretKindOptions,
|
||||||
getIconUrl,
|
getIconUrl,
|
||||||
|
getIntegrationApiKeyUrl,
|
||||||
getIntegrationDefaultUrl,
|
getIntegrationDefaultUrl,
|
||||||
getIntegrationName,
|
getIntegrationName,
|
||||||
integrationDefs,
|
integrationDefs,
|
||||||
@@ -45,9 +46,13 @@ import { IntegrationTestConnectionError } from "../_components/test-connection/i
|
|||||||
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
|
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
|
||||||
|
|
||||||
interface NewIntegrationFormProps {
|
interface NewIntegrationFormProps {
|
||||||
searchParams: Partial<z.infer<typeof integrationCreateSchema>> & {
|
kind: IntegrationKind;
|
||||||
kind: IntegrationKind;
|
initialUrl?: string;
|
||||||
};
|
initialName?: string;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
onSkip?: () => void;
|
||||||
|
isOnboarding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||||
@@ -58,39 +63,48 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
|
export const NewIntegrationForm = ({
|
||||||
|
kind,
|
||||||
|
initialUrl,
|
||||||
|
initialName,
|
||||||
|
onSuccess,
|
||||||
|
onCancel,
|
||||||
|
onSkip,
|
||||||
|
isOnboarding = false,
|
||||||
|
}: NewIntegrationFormProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
const secretKinds = getAllSecretKindOptions(kind);
|
||||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||||
const router = useRouter();
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false);
|
||||||
|
|
||||||
let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "";
|
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
|
||||||
if (hasUrlSecret) {
|
if (hasUrlSecret) {
|
||||||
// Placeholder Url, replaced with origin of the secret Url on submit
|
|
||||||
url = "http://localhost";
|
url = "http://localhost";
|
||||||
}
|
}
|
||||||
const form = useZodForm(formSchema, {
|
const form = useZodForm(formSchema, {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: searchParams.name ?? getIntegrationName(searchParams.kind),
|
name: initialName ?? getIntegrationName(kind),
|
||||||
url,
|
url,
|
||||||
secrets: secretKinds[0].map((kind) => ({
|
secrets: secretKinds[0].map((kind) => ({
|
||||||
kind,
|
kind,
|
||||||
value: "",
|
value: "",
|
||||||
})),
|
})),
|
||||||
attemptSearchEngineCreation: true,
|
attemptSearchEngineCreation: !isOnboarding,
|
||||||
hasApp: false,
|
hasApp: !isOnboarding,
|
||||||
appHref: url,
|
appHref: isOnboarding ? null : url,
|
||||||
appId: null,
|
appId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation({
|
const { mutateAsync: createIntegrationAsync, isPending: isCreatePending } = clientApi.integration.create.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
await revalidatePathActionAsync("/manage/integrations");
|
await revalidatePathActionAsync("/manage/integrations");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } =
|
||||||
|
clientApi.onboard.createIntegration.useMutation();
|
||||||
|
const isPending = isCreatePending || isOnboardingCreatePending;
|
||||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||||
|
|
||||||
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
|
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
|
||||||
@@ -98,6 +112,39 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||||
: values.url;
|
: values.url;
|
||||||
|
|
||||||
|
const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => {
|
||||||
|
if (data && "error" in data && data.error) {
|
||||||
|
setError(data.error);
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("integration.page.create.notification.error.title"),
|
||||||
|
message: t("integration.page.create.notification.error.message"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("integration.page.create.notification.success.title"),
|
||||||
|
message: t("integration.page.create.notification.success.message"),
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMutationError = () => {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("integration.page.create.notification.error.title"),
|
||||||
|
message: t("integration.page.create.notification.error.message"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOnboarding) {
|
||||||
|
await createOnboardingIntegrationAsync(
|
||||||
|
{ kind, name: values.name, url, secrets: values.secrets },
|
||||||
|
{ onSuccess: onMutationSuccess, onError: onMutationError },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
|
const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
|
||||||
|
|
||||||
const app = hasApp
|
const app = hasApp
|
||||||
@@ -106,49 +153,21 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
: {
|
: {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
href: hasCustomHref ? appHref : url,
|
href: hasCustomHref ? appHref : url,
|
||||||
iconUrl: getIconUrl(searchParams.kind),
|
iconUrl: getIconUrl(kind),
|
||||||
description: null,
|
description: null,
|
||||||
pingUrl: url,
|
pingUrl: url,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
await createIntegrationAsync(
|
await createIntegrationAsync(
|
||||||
{
|
{ kind, ...values, url, app },
|
||||||
kind: searchParams.kind,
|
{ onSuccess: onMutationSuccess, onError: onMutationError },
|
||||||
...values,
|
|
||||||
url,
|
|
||||||
app,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
// We do it this way as we are unable to send a typesafe error through onError
|
|
||||||
if (data?.error) {
|
|
||||||
setError(data.error);
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("integration.page.create.notification.error.title"),
|
|
||||||
message: t("integration.page.create.notification.error.message"),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t("integration.page.create.notification.success.title"),
|
|
||||||
message: t("integration.page.create.notification.success.message"),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/manage/integrations");
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("integration.page.create.notification.error.title"),
|
|
||||||
message: t("integration.page.create.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
|
const integrationCategories = integrationDefs[kind].category.flat();
|
||||||
|
const supportsSearchEngine =
|
||||||
|
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||||
@@ -175,27 +194,39 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
<ApiKeySettingsLink kind={kind} url={form.values.url} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||||
|
|
||||||
{supportsSearchEngine && (
|
{!isOnboarding && supportsSearchEngine && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={t("integration.field.attemptSearchEngineCreation.label")}
|
label={t("integration.field.attemptSearchEngineCreation.label")}
|
||||||
description={t("integration.field.attemptSearchEngineCreation.description", {
|
description={t("integration.field.attemptSearchEngineCreation.description", {
|
||||||
kind: getIntegrationName(searchParams.kind),
|
kind: getIntegrationName(kind),
|
||||||
})}
|
})}
|
||||||
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
|
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AppForm form={form} canCreateApps={canCreateApps} />
|
{!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />}
|
||||||
|
|
||||||
<Group justify="end" align="center">
|
<Group justify="end" align="center">
|
||||||
<Button variant="default" component={Link} href="/manage/integrations">
|
{onCancel ? (
|
||||||
{t("common.action.backToOverview")}
|
<Button variant="default" onClick={onCancel}>
|
||||||
</Button>
|
{t("common.action.backToOverview")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="default" component={Link} href="/manage/integrations">
|
||||||
|
{t("common.action.backToOverview")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onSkip && (
|
||||||
|
<Button variant="default" onClick={onSkip}>
|
||||||
|
{t("common.action.skip")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button type="submit" loading={isPending}>
|
<Button type="submit" loading={isPending}>
|
||||||
{t("integration.testConnection.action.create")}
|
{t("integration.testConnection.action.create")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -266,6 +297,40 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeUrl = (raw: string): string | null => {
|
||||||
|
try {
|
||||||
|
return new URL(raw).href;
|
||||||
|
} catch {
|
||||||
|
// Bare IP / hostname — prepend http:// and retry
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(`http://${raw}`).href;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const apiKeyUrl = getIntegrationApiKeyUrl(url, kind);
|
||||||
|
if (!apiKeyUrl) return null;
|
||||||
|
|
||||||
|
const resolved = normalizeUrl(url);
|
||||||
|
if (!resolved) return null;
|
||||||
|
|
||||||
|
const fullApiKeyUrl = getIntegrationApiKeyUrl(resolved, kind);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Anchor href={fullApiKeyUrl ?? apiKeyUrl} target="_blank" rel="noopener noreferrer" size="sm">
|
||||||
|
<Group gap={4}>
|
||||||
|
<IconKey size={14} stroke={1.5} />
|
||||||
|
<Text size="sm">{t("integration.field.apiKeySettings.label")}</Text>
|
||||||
|
<IconExternalLink size={14} stroke={1.5} />
|
||||||
|
</Group>
|
||||||
|
</Anchor>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
type IntegrationAppSelectProps = Modify<
|
type IntegrationAppSelectProps = Modify<
|
||||||
GetInputPropsReturnType,
|
GetInputPropsReturnType,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { IntegrationSelectGrid } from "~/components/integration/integration-select-grid";
|
||||||
|
|
||||||
|
interface IntegrationNewSelectGridProps {
|
||||||
|
enableMockIntegration: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntegrationNewSelectGrid = ({ enableMockIntegration }: IntegrationNewSelectGridProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSelect = (kind: IntegrationKind) => {
|
||||||
|
router.push(`/manage/integrations/new?kind=${kind}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={enableMockIntegration} />;
|
||||||
|
};
|
||||||
@@ -3,21 +3,21 @@ import { Container, Group, Stack, Title } from "@mantine/core";
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
|
||||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
import type { integrationCreateSchema } from "@homarr/validation/integration";
|
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { NewIntegrationForm } from "./_integration-new-form";
|
import { env } from "~/env";
|
||||||
|
import { IntegrationNewFormWrapper } from "./_integration-new-form-wrapper";
|
||||||
|
import { IntegrationNewSelectGrid } from "./_integration-new-select-grid";
|
||||||
|
|
||||||
interface NewIntegrationPageProps {
|
interface NewIntegrationPageProps {
|
||||||
searchParams: Promise<
|
searchParams: Promise<{
|
||||||
Partial<z.infer<typeof integrationCreateSchema>> & {
|
kind?: string;
|
||||||
kind: IntegrationKind;
|
url?: string;
|
||||||
}
|
name?: string;
|
||||||
>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
|
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
|
||||||
@@ -28,8 +28,20 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
|
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
notFound();
|
const t = await getScopedI18n("integration");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DynamicBreadcrumb />
|
||||||
|
<Container>
|
||||||
|
<Stack>
|
||||||
|
<Title>{t("action.create")}</Title>
|
||||||
|
<IntegrationNewSelectGrid enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tCreate = await getScopedI18n("integration.page.create");
|
const tCreate = await getScopedI18n("integration.page.create");
|
||||||
@@ -45,7 +57,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
|
|||||||
<IntegrationAvatar kind={currentKind} size="md" />
|
<IntegrationAvatar kind={currentKind} size="md" />
|
||||||
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
|
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
<NewIntegrationForm searchParams={searchParams} />
|
<IntegrationNewFormWrapper kind={currentKind} initialUrl={searchParams.url} initialName={searchParams.name} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import type { PropsWithChildren } from "react";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
AccordionControl,
|
AccordionControl,
|
||||||
@@ -8,12 +7,8 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
ActionIconGroup,
|
ActionIconGroup,
|
||||||
Anchor,
|
Anchor,
|
||||||
Button,
|
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Menu,
|
|
||||||
MenuDropdown,
|
|
||||||
MenuTarget,
|
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
TableTbody,
|
TableTbody,
|
||||||
@@ -23,7 +18,7 @@ import {
|
|||||||
TableTr,
|
TableTr,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconChevronDown, IconPencil, IconPlugX } from "@tabler/icons-react";
|
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -34,13 +29,12 @@ import { getIntegrationName } from "@homarr/definitions";
|
|||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { env } from "~/env";
|
|
||||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
|
||||||
import classes from "./page.module.css";
|
import classes from "./page.module.css";
|
||||||
|
|
||||||
interface IntegrationsPageProps {
|
interface IntegrationsPageProps {
|
||||||
@@ -67,41 +61,22 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
title={t("page.list.title")}
|
title={t("page.list.title")}
|
||||||
primaryAction={
|
primaryAction={
|
||||||
canCreateIntegrations ? (
|
canCreateIntegrations ? (
|
||||||
<ManageMobilePrimaryAction>
|
<TourTarget id="manage-integrations-create">
|
||||||
<IntegrationSelectMenu>
|
<MobileAffixButton component={Link} href="/manage/integrations/new">
|
||||||
<MenuTarget>
|
{t("action.create")}
|
||||||
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
</MobileAffixButton>
|
||||||
</MenuTarget>
|
</TourTarget>
|
||||||
</IntegrationSelectMenu>
|
|
||||||
</ManageMobilePrimaryAction>
|
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
floatingPrimaryAction={canCreateIntegrations}
|
floatingPrimaryAction={canCreateIntegrations}
|
||||||
>
|
>
|
||||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
<TourTarget id="manage-integrations-list">
|
||||||
|
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||||
|
</TourTarget>
|
||||||
</ManagePageLayout>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
|
|
||||||
return (
|
|
||||||
<Menu
|
|
||||||
width={256}
|
|
||||||
trapFocus
|
|
||||||
position="bottom-end"
|
|
||||||
withinPortal
|
|
||||||
shadow="md"
|
|
||||||
keepMounted={false}
|
|
||||||
withInitialFocusPlaceholder={false}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<MenuDropdown>
|
|
||||||
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
|
|
||||||
</MenuDropdown>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IntegrationListProps {
|
interface IntegrationListProps {
|
||||||
integrations: RouterOutputs["integration"]["all"];
|
integrations: RouterOutputs["integration"]["all"];
|
||||||
activeTab?: IntegrationKind;
|
activeTab?: IntegrationKind;
|
||||||
@@ -134,12 +109,12 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||||
{entries.map(([kind, integrations], index) => (
|
{entries.map(([kind, kindIntegrations], index) => (
|
||||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||||
<Group>
|
<Group>
|
||||||
<Text>{getIntegrationName(kind)}</Text>
|
<Text>{getIntegrationName(kind)}</Text>
|
||||||
<CountBadge count={integrations.length} />
|
<CountBadge count={kindIntegrations.length} />
|
||||||
</Group>
|
</Group>
|
||||||
</AccordionControl>
|
</AccordionControl>
|
||||||
<AccordionPanel>
|
<AccordionPanel>
|
||||||
@@ -152,7 +127,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
</TableTr>
|
</TableTr>
|
||||||
</TableThead>
|
</TableThead>
|
||||||
<TableTbody>
|
<TableTbody>
|
||||||
{integrations.map((integration) => (
|
{kindIntegrations.map((integration) => (
|
||||||
<TableTr key={integration.id}>
|
<TableTr key={integration.id}>
|
||||||
<TableTd>{integration.name}</TableTd>
|
<TableTd>{integration.name}</TableTd>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
@@ -173,7 +148,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
>
|
>
|
||||||
<IconPencil size={16} stroke={1.5} />
|
<IconPencil size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||||
</ActionIconGroup>
|
</ActionIconGroup>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -184,9 +159,9 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<Stack gap="xs" hiddenFrom="md">
|
<Stack gap="xs" hiddenFrom="md">
|
||||||
{integrations.map((integration, index) => (
|
{kindIntegrations.map((integration, integrationIndex) => (
|
||||||
<Fragment key={integration.id}>
|
<Fragment key={integration.id}>
|
||||||
{index !== 0 && <Divider />}
|
{integrationIndex !== 0 && <Divider />}
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<Text>{integration.name}</Text>
|
<Text>{integration.name}</Text>
|
||||||
@@ -201,7 +176,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
>
|
>
|
||||||
<IconPencil size={16} stroke={1.5} />
|
<IconPencil size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||||
</ActionIconGroup>
|
</ActionIconGroup>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
IconBrandTablerFilled,
|
IconBrandTablerFilled,
|
||||||
IconCertificate,
|
IconCertificate,
|
||||||
IconClipboardListFilled,
|
IconClipboardListFilled,
|
||||||
|
IconDatabaseExport,
|
||||||
IconDirectionsFilled,
|
IconDirectionsFilled,
|
||||||
IconGitFork,
|
IconGitFork,
|
||||||
IconHelpSquareRoundedFilled,
|
IconHelpSquareRoundedFilled,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
|
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||||
import { env } from "@homarr/docker/env";
|
import { env } from "@homarr/docker/env";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
|||||||
import type { NavigationLink } from "~/components/layout/navigation";
|
import type { NavigationLink } from "~/components/layout/navigation";
|
||||||
import { MainNavigation } from "~/components/layout/navigation";
|
import { MainNavigation } from "~/components/layout/navigation";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
|
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
|
||||||
|
|
||||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||||
const t = await getScopedI18n("management.navbar");
|
const t = await getScopedI18n("management.navbar");
|
||||||
@@ -45,11 +48,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
label: t("items.home"),
|
label: t("items.home"),
|
||||||
icon: IconHomeFilled,
|
icon: IconHomeFilled,
|
||||||
href: "/manage",
|
href: "/manage",
|
||||||
|
"data-onboarding-tour-id": "manage-welcome",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconLayoutDashboardFilled,
|
icon: IconLayoutDashboardFilled,
|
||||||
href: "/manage/boards",
|
href: "/manage/boards",
|
||||||
label: t("items.boards"),
|
label: t("items.boards"),
|
||||||
|
"data-onboarding-tour-id": "manage-boards",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconBox,
|
icon: IconBox,
|
||||||
@@ -59,12 +64,14 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
iconProps: {
|
iconProps: {
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
},
|
},
|
||||||
|
"data-onboarding-tour-id": "manage-apps",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconAffiliateFilled,
|
icon: IconAffiliateFilled,
|
||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
label: t("items.integrations"),
|
label: t("items.integrations"),
|
||||||
hidden: !session,
|
hidden: !session,
|
||||||
|
"data-onboarding-tour-id": "manage-integrations",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconSearch,
|
icon: IconSearch,
|
||||||
@@ -74,17 +81,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
iconProps: {
|
iconProps: {
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
},
|
},
|
||||||
|
"data-onboarding-tour-id": "manage-search-engines",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconPhotoFilled,
|
icon: IconPhotoFilled,
|
||||||
href: "/manage/medias",
|
href: "/manage/medias",
|
||||||
label: t("items.medias"),
|
label: t("items.medias"),
|
||||||
hidden: !session,
|
hidden: !session,
|
||||||
|
"data-onboarding-tour-id": "manage-medias",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconUserFilled,
|
icon: IconUserFilled,
|
||||||
label: t("items.users.label"),
|
label: t("items.users.label"),
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
|
"data-onboarding-tour-id": "manage-users",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: t("items.users.items.manage"),
|
label: t("items.users.items.manage"),
|
||||||
@@ -146,6 +156,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/tools/tasks",
|
href: "/manage/tools/tasks",
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("items.tools.items.backup"),
|
||||||
|
icon: IconDatabaseExport,
|
||||||
|
href: "/manage/tools/backup",
|
||||||
|
hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -153,6 +169,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/settings",
|
href: "/manage/settings",
|
||||||
icon: IconSettingsFilled,
|
icon: IconSettingsFilled,
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
|
"data-onboarding-tour-id": "manage-settings",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.help.label"),
|
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>
|
<ClientShell hasNavigation>
|
||||||
<MainHeader></MainHeader>
|
<MainHeader></MainHeader>
|
||||||
<MainNavigation links={navigationLinks}></MainNavigation>
|
<MainNavigation links={navigationLinks}></MainNavigation>
|
||||||
<AppShellMain>{children}</AppShellMain>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
</ClientShell>
|
</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";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import type { MantineColor } from "@mantine/core";
|
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 {
|
import {
|
||||||
IconCategoryPlus,
|
IconCategoryPlus,
|
||||||
|
IconDots,
|
||||||
|
IconFileText,
|
||||||
IconPlayerPlay,
|
IconPlayerPlay,
|
||||||
IconPlayerStop,
|
IconPlayerStop,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconRotateClockwise,
|
IconRotateClockwise,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} 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 { MantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useTimeAgo } from "@homarr/common";
|
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
||||||
import type { ContainerState } from "@homarr/docker";
|
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 { useModalAction } from "@homarr/modals";
|
||||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
@@ -27,25 +30,30 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
import { OverflowBadge } from "@homarr/ui";
|
import { OverflowBadge } from "@homarr/ui";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
const createColumns = (
|
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
||||||
t: TranslationFunction,
|
|
||||||
): MRT_ColumnDef<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",
|
accessorKey: "name",
|
||||||
header: t("docker.field.name.label"),
|
header: t("docker.field.name.label"),
|
||||||
Cell({ renderedCellValue, row }) {
|
Cell({ renderedCellValue, row }) {
|
||||||
return (
|
return (
|
||||||
<Group gap="xs">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<Avatar
|
<Avatar
|
||||||
variant="outline"
|
variant="outline"
|
||||||
radius="lg"
|
radius="sm"
|
||||||
size="md"
|
size="md"
|
||||||
styles={{ image: { objectFit: "contain" } }}
|
styles={{ image: { objectFit: "contain" } }}
|
||||||
src={row.original.iconUrl}
|
src={row.original.iconUrl}
|
||||||
>
|
>
|
||||||
{row.original.name.at(0)?.toUpperCase()}
|
{row.original.name.at(0)?.toUpperCase()}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text>{renderedCellValue}</Text>
|
<Text truncate="end" maw={500}>
|
||||||
|
{renderedCellValue}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -76,11 +84,11 @@ const createColumns = (
|
|||||||
maxSize: 200,
|
maxSize: 200,
|
||||||
Cell({ renderedCellValue, cell }) {
|
Cell({ renderedCellValue, cell }) {
|
||||||
return (
|
return (
|
||||||
<Box maw={200}>
|
<Tooltip label={cell.row.original.image} multiline maw={400}>
|
||||||
<Text truncate="end" title={cell.row.original.image}>
|
<Box maw={200}>
|
||||||
{renderedCellValue}
|
<Text truncate="end">{renderedCellValue}</Text>
|
||||||
</Text>
|
</Box>
|
||||||
</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 t = useI18n();
|
||||||
const tDocker = useScopedI18n("docker");
|
const tDocker = useScopedI18n("docker");
|
||||||
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||||
initialData,
|
initialData: { containers, timestamp },
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
@@ -131,6 +183,8 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
enableColumnFilters: false,
|
enableColumnFilters: false,
|
||||||
enablePagination: false,
|
enablePagination: false,
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
|
enableRowActions: true,
|
||||||
|
positionActionsColumn: "last",
|
||||||
positionToolbarAlertBanner: "top",
|
positionToolbarAlertBanner: "top",
|
||||||
enableTableFooter: false,
|
enableTableFooter: false,
|
||||||
enableBottomToolbar: false,
|
enableBottomToolbar: false,
|
||||||
@@ -141,7 +195,10 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
autoFocus: true,
|
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: () => (
|
renderTopToolbarCustomActions: () => (
|
||||||
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
|
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
|
||||||
{tDocker("action.refresh.label")}
|
{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 {
|
interface ContainerActionBarProps {
|
||||||
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
selectedContainers: DockerContainer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||||
const t = useScopedI18n("docker.action");
|
const t = useScopedI18n("docker.action");
|
||||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||||
const handleClick = () => {
|
|
||||||
openModal({
|
|
||||||
selectedContainers,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedIds = selectedContainers.map((container) => container.id);
|
const selectedIds = selectedContainers.map((container) => container.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
{containerActions.map(({ action, icon, color }) => (
|
||||||
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
|
||||||
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
))}
|
||||||
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
<Button
|
||||||
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light">
|
leftSection={<IconCategoryPlus />}
|
||||||
|
color="red"
|
||||||
|
onClick={() => openModal({ selectedContainers })}
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
{t("addToHomarr.label")}
|
{t("addToHomarr.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
@@ -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%" />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Group, Slider, Text } from "@mantine/core";
|
||||||
|
import { IconTextSize } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { useLogContext } from "./log-context";
|
||||||
|
|
||||||
|
export const LogFontSizeSlider = () => {
|
||||||
|
const { fontSize, setFontSize } = useLogContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap="xs" wrap="nowrap" w={160}>
|
||||||
|
<IconTextSize size={18} style={{ flexShrink: 0 }} />
|
||||||
|
<Slider
|
||||||
|
min={10}
|
||||||
|
max={24}
|
||||||
|
step={1}
|
||||||
|
value={fontSize}
|
||||||
|
onChange={setFontSize}
|
||||||
|
label={(val) => `${val}px`}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed" w={32} ta="right">
|
||||||
|
{fontSize}px
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,10 +6,23 @@ import { createContext, useContext, useMemo, useState } from "react";
|
|||||||
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||||
import { logLevels } from "@homarr/core/infrastructure/logs/constants";
|
import { logLevels } from "@homarr/core/infrastructure/logs/constants";
|
||||||
|
|
||||||
|
const FONT_SIZE_KEY = "homarr-log-font-size";
|
||||||
|
const DEFAULT_FONT_SIZE = 14;
|
||||||
|
|
||||||
|
const readStoredFontSize = () => {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_FONT_SIZE;
|
||||||
|
const stored = localStorage.getItem(FONT_SIZE_KEY);
|
||||||
|
if (!stored) return DEFAULT_FONT_SIZE;
|
||||||
|
const parsed = Number(stored);
|
||||||
|
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 10), 24) : DEFAULT_FONT_SIZE;
|
||||||
|
};
|
||||||
|
|
||||||
const LogContext = createContext<{
|
const LogContext = createContext<{
|
||||||
level: LogLevel;
|
level: LogLevel;
|
||||||
setLevel: (level: LogLevel) => void;
|
setLevel: (level: LogLevel) => void;
|
||||||
activeLevels: LogLevel[];
|
activeLevels: LogLevel[];
|
||||||
|
fontSize: number;
|
||||||
|
setFontSize: (size: number) => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
interface LogContextProviderProps extends PropsWithChildren {
|
interface LogContextProviderProps extends PropsWithChildren {
|
||||||
@@ -18,9 +31,19 @@ interface LogContextProviderProps extends PropsWithChildren {
|
|||||||
|
|
||||||
export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => {
|
export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => {
|
||||||
const [level, setLevel] = useState(defaultLevel);
|
const [level, setLevel] = useState(defaultLevel);
|
||||||
|
const [fontSize, setFontSizeRaw] = useState(readStoredFontSize);
|
||||||
const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]);
|
const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]);
|
||||||
|
|
||||||
return <LogContext.Provider value={{ level, setLevel, activeLevels }}>{children}</LogContext.Provider>;
|
const setFontSize = (size: number) => {
|
||||||
|
setFontSizeRaw(size);
|
||||||
|
localStorage.setItem(FONT_SIZE_KEY, String(size));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LogContext.Provider value={{ level, setLevel, activeLevels, fontSize, setFontSize }}>
|
||||||
|
{children}
|
||||||
|
</LogContext.Provider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useLogContext = () => {
|
export const useLogContext = () => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|||||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { ClientSideTerminalComponent } from "./client";
|
import { ClientSideTerminalComponent } from "./client";
|
||||||
|
import { LogFontSizeSlider } from "./font-size-slider";
|
||||||
import { LogLevelSelection } from "./level-selection";
|
import { LogLevelSelection } from "./level-selection";
|
||||||
import { LogContextProvider } from "./log-context";
|
import { LogContextProvider } from "./log-context";
|
||||||
|
|
||||||
@@ -38,7 +39,10 @@ export default async function LogsManagementPage() {
|
|||||||
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
|
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<LogLevelSelection />
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<LogFontSizeSlider />
|
||||||
|
<LogLevelSelection />
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||||
<ClientSideTerminalComponent />
|
<ClientSideTerminalComponent />
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import classes from "./terminal.module.css";
|
|||||||
|
|
||||||
export const TerminalComponent = () => {
|
export const TerminalComponent = () => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const { activeLevels } = useLogContext();
|
const { activeLevels, fontSize } = useLogContext();
|
||||||
|
|
||||||
const terminalRef = useRef<Terminal>(null);
|
const terminalRef = useRef<Terminal>(null);
|
||||||
|
const fitAddonRef = useRef<FitAddon>(null);
|
||||||
|
|
||||||
clientApi.log.subscribe.useSubscription(
|
clientApi.log.subscribe.useSubscription(
|
||||||
{
|
{
|
||||||
levels: activeLevels,
|
levels: activeLevels,
|
||||||
@@ -26,7 +28,6 @@ export const TerminalComponent = () => {
|
|||||||
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
|
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
// This makes sense as logging might cause an infinite loop
|
|
||||||
alert(err);
|
alert(err);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -43,22 +44,30 @@ export const TerminalComponent = () => {
|
|||||||
cursorBlink: false,
|
cursorBlink: false,
|
||||||
disableStdin: true,
|
disableStdin: true,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
|
fontSize,
|
||||||
});
|
});
|
||||||
terminalRef.current.open(ref.current);
|
terminalRef.current.open(ref.current);
|
||||||
terminalRef.current.loadAddon(canvasAddon);
|
terminalRef.current.loadAddon(canvasAddon);
|
||||||
|
|
||||||
// This is a hack to make sure the terminal is rendered before we try to fit it
|
|
||||||
// You can blame @Meierschlumpf for this
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
terminalRef.current?.loadAddon(fitAddon);
|
terminalRef.current?.loadAddon(fitAddon);
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
terminalRef.current?.dispose();
|
|
||||||
canvasAddon.dispose();
|
canvasAddon.dispose();
|
||||||
|
terminalRef.current?.dispose();
|
||||||
|
terminalRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!terminalRef.current) return;
|
||||||
|
terminalRef.current.options.fontSize = fontSize;
|
||||||
|
fitAddonRef.current?.fit();
|
||||||
|
}, [fontSize]);
|
||||||
|
|
||||||
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
||||||
};
|
};
|
||||||
+53
@@ -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 { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||||
|
import { ResetTours } from "./_components/_reset-tours";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -87,6 +88,13 @@ export default async function EditUserPage(props: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{session?.user.id === user.id && (
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
|
||||||
|
<ResetTours />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
<DangerZoneRoot>
|
<DangerZoneRoot>
|
||||||
<DangerZoneItem
|
<DangerZoneItem
|
||||||
label={t("user.action.delete.label")}
|
label={t("user.action.delete.label")}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
|
|||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
|
||||||
import { NavigationLink } from "../groups/[id]/_navigation";
|
import { NavigationLink } from "../groups/[id]/_navigation";
|
||||||
import { canAccessUserEditPage } from "./access";
|
import { canAccessUserEditPage } from "./access";
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const tUser = await getScopedI18n("management.page.user");
|
const tUser = await getScopedI18n("management.page.user");
|
||||||
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
|
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound).catch(catchTrpcUnauthorized);
|
||||||
|
|
||||||
if (!canAccessUserEditPage(session, user.id)) {
|
if (!canAccessUserEditPage(session, user.id)) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
import { ChangePasswordForm } from "./_components/_change-password-form";
|
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||||
|
|
||||||
@@ -23,7 +23,8 @@ export default async function UserSecurityPage(props: Props) {
|
|||||||
.getById({
|
.getById({
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
})
|
})
|
||||||
.catch(catchTrpcNotFound);
|
.catch(catchTrpcNotFound)
|
||||||
|
.catch(catchTrpcUnauthorized);
|
||||||
|
|
||||||
if (!canAccessUserEditPage(session, user.id)) {
|
if (!canAccessUserEditPage(session, user.id)) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server";
|
|||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
@@ -38,14 +39,18 @@ export default async function UsersPage() {
|
|||||||
title={t("list.title")}
|
title={t("list.title")}
|
||||||
primaryAction={
|
primaryAction={
|
||||||
credentialsProviderEnabled ? (
|
credentialsProviderEnabled ? (
|
||||||
<MobileAffixButton component={Link} href="/manage/users/create">
|
<TourTarget id="manage-users-create">
|
||||||
{t("create.title")}
|
<MobileAffixButton component={Link} href="/manage/users/create">
|
||||||
</MobileAffixButton>
|
{t("create.title")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
</TourTarget>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
floatingPrimaryAction={credentialsProviderEnabled}
|
floatingPrimaryAction={credentialsProviderEnabled}
|
||||||
>
|
>
|
||||||
<UserListComponent initialUserList={userList} />
|
<TourTarget id="manage-users-list">
|
||||||
|
<UserListComponent initialUserList={userList} />
|
||||||
|
</TourTarget>
|
||||||
</ManagePageLayout>
|
</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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
|||||||
),
|
),
|
||||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||||
settings,
|
settings,
|
||||||
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title(t) {
|
title(t) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { Button, Grid, Group, NumberInput, Stack } from "@mantine/core";
|
import { Grid, NumberInput, Stack } from "@mantine/core";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import type { GridStack } from "@homarr/gridstack";
|
import type { GridStack } from "@homarr/gridstack";
|
||||||
import { createModal } from "@homarr/modals";
|
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
@@ -16,9 +16,7 @@ interface InnerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||||
const tCommon = useScopedI18n("common");
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
// Keep track of the maximum width based on the x offset
|
|
||||||
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
||||||
const form = useZodForm(
|
const form = useZodForm(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -38,7 +36,6 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
|||||||
height: innerProps.item.height,
|
height: innerProps.item.height,
|
||||||
},
|
},
|
||||||
onValuesChange(values, previous) {
|
onValuesChange(values, previous) {
|
||||||
// Update the maximum width when the x offset changes
|
|
||||||
if (values.xOffset !== previous.xOffset) {
|
if (values.xOffset !== previous.xOffset) {
|
||||||
maxWidthRef.current = innerProps.columnCount - values.xOffset;
|
maxWidthRef.current = innerProps.columnCount - values.xOffset;
|
||||||
}
|
}
|
||||||
@@ -70,6 +67,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
|||||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={t("item.moveResize.field.xOffset.label")}
|
label={t("item.moveResize.field.xOffset.label")}
|
||||||
|
data-autofocus
|
||||||
min={0}
|
min={0}
|
||||||
max={innerProps.columnCount - 1}
|
max={innerProps.columnCount - 1}
|
||||||
{...form.getInputProps("xOffset")}
|
{...form.getInputProps("xOffset")}
|
||||||
@@ -93,12 +91,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
|||||||
<NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} />
|
<NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} />
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Group justify="end">
|
<ModalFormFooter onCancel={actions.closeModal} />
|
||||||
<Button variant="subtle" onClick={actions.closeModal}>
|
|
||||||
{tCommon("action.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit">{tCommon("action.saveChanges")}</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
@@ -106,5 +99,5 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
|||||||
defaultTitle(t) {
|
defaultTitle(t) {
|
||||||
return t("item.moveResize.title");
|
return t("item.moveResize.title");
|
||||||
},
|
},
|
||||||
size: "lg",
|
size: modalSizeForm,
|
||||||
});
|
});
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Avatar, Button, Card, Center, Grid, Group, Input, Stack, Text, Tooltip } from "@mantine/core";
|
import { Avatar, Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { createId, objectEntries } from "@homarr/common";
|
import { createId, objectEntries } from "@homarr/common";
|
||||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
import { createModal, useModalAction } from "@homarr/modals";
|
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||||
@@ -86,32 +86,31 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<SelectGridLayout
|
||||||
<Input
|
search={search}
|
||||||
value={search}
|
onSearchChange={setSearch}
|
||||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
placeholder={`${t("item.create.search")}...`}
|
||||||
leftSection={<IconSearch />}
|
onSearchKeyDown={(event) => {
|
||||||
placeholder={`${t("item.create.search")}...`}
|
if (event.key === "Enter" && filteredItems.length === 1) {
|
||||||
data-autofocus
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
onKeyDown={(event) => {
|
handleAdd(filteredItems[0]!.kind);
|
||||||
// Add item if there is only one item in the list and user presses Enter
|
}
|
||||||
if (event.key === "Enter" && filteredItems.length === 1) {
|
}}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
>
|
||||||
handleAdd(filteredItems[0]!.kind);
|
{filteredItems.map((item) => (
|
||||||
}
|
<WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} />
|
||||||
}}
|
))}
|
||||||
/>
|
|
||||||
|
|
||||||
<Grid>
|
{filteredItems.length === 0 && (
|
||||||
{filteredItems.map((item) => (
|
<Center p="xl">
|
||||||
<WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} />
|
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||||
))}
|
</Center>
|
||||||
</Grid>
|
)}
|
||||||
</Stack>
|
</SelectGridLayout>
|
||||||
);
|
);
|
||||||
}).withOptions({
|
}).withOptions({
|
||||||
defaultTitle: (t) => t("item.create.title"),
|
defaultTitle: (t) => t("item.create.title"),
|
||||||
size: "xl",
|
size: modalSizeSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
const WidgetItem = ({
|
const WidgetItem = ({
|
||||||
@@ -130,46 +129,58 @@ const WidgetItem = ({
|
|||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
<Card
|
||||||
<Card h="100%">
|
h={selectGridCardHeight}
|
||||||
<Stack justify="space-between" h="100%">
|
withBorder
|
||||||
<Stack gap="xs">
|
pos="relative"
|
||||||
<Center>
|
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
||||||
<item.icon />
|
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||||
</Center>
|
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
>
|
||||||
{item.name}
|
<Stack h="100%" gap="xs">
|
||||||
</Text>
|
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
<item.icon size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
{item.description}
|
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||||
</Text>
|
{item.name}
|
||||||
</Stack>
|
</Text>
|
||||||
<SupportedIntegrations mt="auto" integrations={item.supportedIntegrations} />
|
</Group>
|
||||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
<Tooltip label={item.description} multiline w={250} disabled={!item.description}>
|
||||||
{t(`item.create.addToBoard`)}
|
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||||
</Button>
|
{item.description}
|
||||||
</Stack>
|
</Text>
|
||||||
</Card>
|
</Tooltip>
|
||||||
</Grid.Col>
|
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||||
|
</Stack>
|
||||||
|
<Box
|
||||||
|
pos="absolute"
|
||||||
|
bottom={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
opacity: "var(--_hover-opacity)",
|
||||||
|
transition: "opacity 150ms ease",
|
||||||
|
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
||||||
|
{t(`item.create.addToBoard`)}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SupportedIntegrationsProps {
|
const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
|
||||||
integrations: IntegrationKind[];
|
|
||||||
mt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) => {
|
|
||||||
if (integrations.length === 0) {
|
if (integrations.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When there are 8 or more integrations, we show 6 and a "+X" avatar. Otherwise, we show all integrations.
|
|
||||||
const countToShow = integrations.length >= 8 ? 6 : 7;
|
const countToShow = integrations.length >= 8 ? 6 : 7;
|
||||||
const moreCount = integrations.length - countToShow;
|
const moreCount = integrations.length - countToShow;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center mt={mt}>
|
<Group gap={2} mt="auto">
|
||||||
<Tooltip.Group closeDelay={100}>
|
<Tooltip.Group closeDelay={100}>
|
||||||
<Group gap={2}>
|
<Group gap={2}>
|
||||||
{integrations.slice(0, countToShow).map((integration) => (
|
{integrations.slice(0, countToShow).map((integration) => (
|
||||||
@@ -195,6 +206,6 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps)
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Tooltip.Group>
|
</Tooltip.Group>
|
||||||
</Center>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
import { Stack, TextInput } from "@mantine/core";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { createModal } from "@homarr/modals";
|
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
@@ -36,13 +36,10 @@ export const CategoryEditModal = createModal<InnerProps>(({ actions, innerProps
|
|||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={t("section.category.field.name.label")} data-autofocus {...form.getInputProps("name")} />
|
<TextInput label={t("section.category.field.name.label")} data-autofocus {...form.getInputProps("name")} />
|
||||||
<Group justify="right">
|
<ModalFormFooter onCancel={actions.closeModal} submitLabel={innerProps.submitLabel} />
|
||||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
|
||||||
{t("common.action.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit">{innerProps.submitLabel}</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}).withOptions({});
|
}).withOptions({
|
||||||
|
size: modalSizeForm,
|
||||||
|
});
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, CloseButton, ColorInput, Group, Stack, TextInput, useMantineTheme } from "@mantine/core";
|
import { CloseButton, ColorInput, Stack, TextInput, useMantineTheme } from "@mantine/core";
|
||||||
import type { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { createModal } from "@homarr/modals";
|
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { TextMultiSelect } from "@homarr/ui";
|
import { TextMultiSelect } from "@homarr/ui";
|
||||||
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||||
@@ -31,7 +31,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={t("section.dynamic.option.title.label")} {...form.getInputProps("title")} />
|
<TextInput label={t("section.dynamic.option.title.label")} data-autofocus {...form.getInputProps("title")} />
|
||||||
<TextMultiSelect
|
<TextMultiSelect
|
||||||
label={t("section.dynamic.option.customCssClasses.label")}
|
label={t("section.dynamic.option.customCssClasses.label")}
|
||||||
{...form.getInputProps("customCssClasses")}
|
{...form.getInputProps("customCssClasses")}
|
||||||
@@ -48,14 +48,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
|
|||||||
}
|
}
|
||||||
{...form.getInputProps("borderColor")}
|
{...form.getInputProps("borderColor")}
|
||||||
/>
|
/>
|
||||||
<Group justify="end">
|
<ModalFormFooter onCancel={actions.closeModal} />
|
||||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
|
||||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
|
||||||
{t("common.action.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit">{t("common.action.saveChanges")}</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
@@ -63,5 +56,5 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
|
|||||||
defaultTitle(t) {
|
defaultTitle(t) {
|
||||||
return t("item.edit.title");
|
return t("item.edit.title");
|
||||||
},
|
},
|
||||||
size: "lg",
|
size: modalSizeForm,
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { objectEntries } from "@homarr/common";
|
||||||
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
|
import {
|
||||||
|
featuredIntegrations,
|
||||||
|
getIntegrationName,
|
||||||
|
hiddenFromOnboarding,
|
||||||
|
integrationDefs,
|
||||||
|
integrationKinds,
|
||||||
|
} from "@homarr/definitions";
|
||||||
|
import { widgetImports } from "@homarr/widgets";
|
||||||
|
|
||||||
|
const buildWidgetsByIntegration = () => {
|
||||||
|
const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record<
|
||||||
|
IntegrationKind,
|
||||||
|
WidgetKind[]
|
||||||
|
>;
|
||||||
|
|
||||||
|
for (const [widgetKind, widget] of objectEntries(widgetImports)) {
|
||||||
|
const supported =
|
||||||
|
"supportedIntegrations" in widget.definition
|
||||||
|
? (widget.definition.supportedIntegrations as IntegrationKind[])
|
||||||
|
: [];
|
||||||
|
for (const kind of supported) {
|
||||||
|
if (kind in map) {
|
||||||
|
map[kind].push(widgetKind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const widgetsByIntegration = buildWidgetsByIntegration();
|
||||||
|
|
||||||
|
export const categoryTranslationKeys: Record<string, string> = {
|
||||||
|
dnsHole: "integration.category.dnsHole",
|
||||||
|
mediaService: "integration.category.mediaService",
|
||||||
|
calendar: "integration.category.calendar",
|
||||||
|
mediaSearch: "integration.category.mediaSearch",
|
||||||
|
mediaRelease: "integration.category.mediaRelease",
|
||||||
|
mediaRequest: "integration.category.mediaRequest",
|
||||||
|
downloadClient: "integration.category.downloadClient",
|
||||||
|
usenet: "integration.category.usenet",
|
||||||
|
torrent: "integration.category.torrent",
|
||||||
|
miscellaneous: "integration.category.miscellaneous",
|
||||||
|
smartHomeServer: "integration.category.smartHomeServer",
|
||||||
|
indexerManager: "integration.category.indexerManager",
|
||||||
|
healthMonitoring: "integration.category.healthMonitoring",
|
||||||
|
search: "integration.category.search",
|
||||||
|
mediaTranscoding: "integration.category.mediaTranscoding",
|
||||||
|
networkController: "integration.category.networkController",
|
||||||
|
releasesProvider: "integration.category.releasesProvider",
|
||||||
|
notifications: "integration.category.notifications",
|
||||||
|
firewall: "integration.category.firewall",
|
||||||
|
timetable: "integration.category.timetable",
|
||||||
|
photoService: "integration.category.photoService",
|
||||||
|
notes: "integration.category.notes",
|
||||||
|
mediaMonitoring: "integration.category.mediaMonitoring",
|
||||||
|
speedtest: "integration.category.speedtest",
|
||||||
|
analytics: "integration.category.analytics",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IntegrationGridItem {
|
||||||
|
kind: IntegrationKind;
|
||||||
|
name: string;
|
||||||
|
categories: string[];
|
||||||
|
widgets: WidgetKind[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildSortedIntegrations = (
|
||||||
|
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
|
||||||
|
): IntegrationGridItem[] =>
|
||||||
|
integrationKinds
|
||||||
|
.filter((kind) => {
|
||||||
|
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
|
||||||
|
if (options.onboarding && widgetsByIntegration[kind].length === 0) return false;
|
||||||
|
if (!options.enableMockIntegration && kind === "mock") return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((kind) => ({
|
||||||
|
kind,
|
||||||
|
name: getIntegrationName(kind),
|
||||||
|
categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
|
||||||
|
widgets: widgetsByIntegration[kind],
|
||||||
|
}))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftIdx = featuredIntegrations.indexOf(left.kind);
|
||||||
|
const rightIdx = featuredIntegrations.indexOf(right.kind);
|
||||||
|
if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx;
|
||||||
|
if (leftIdx !== -1) return -1;
|
||||||
|
if (rightIdx !== -1) return 1;
|
||||||
|
return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const filterIntegrations = (items: IntegrationGridItem[], search: string) =>
|
||||||
|
items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim()));
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
|
import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
|
||||||
|
|
||||||
|
interface IntegrationMultiSelectGridProps {
|
||||||
|
selectedKinds: IntegrationKind[];
|
||||||
|
onSelectionChange: (kinds: IntegrationKind[]) => void;
|
||||||
|
enableMockIntegration?: boolean;
|
||||||
|
onboarding?: boolean;
|
||||||
|
detectedKinds?: Set<IntegrationKind>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntegrationMultiSelectGrid = ({
|
||||||
|
selectedKinds: selectedKindsArray,
|
||||||
|
onSelectionChange,
|
||||||
|
enableMockIntegration = false,
|
||||||
|
onboarding = false,
|
||||||
|
detectedKinds,
|
||||||
|
}: IntegrationMultiSelectGridProps) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const selectedKinds = useMemo(() => new Set(selectedKindsArray), [selectedKindsArray]);
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const toggleKind = useCallback(
|
||||||
|
(kind: IntegrationKind) => {
|
||||||
|
const current = new Set(selectedKindsArray);
|
||||||
|
if (current.has(kind)) {
|
||||||
|
current.delete(kind);
|
||||||
|
} else {
|
||||||
|
current.add(kind);
|
||||||
|
}
|
||||||
|
onSelectionChange(Array.from(current));
|
||||||
|
},
|
||||||
|
[selectedKindsArray, onSelectionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const integrations = useMemo(
|
||||||
|
() => buildSortedIntegrations({ enableMockIntegration, onboarding }),
|
||||||
|
[enableMockIntegration, onboarding],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
if (!detectedKinds || detectedKinds.size === 0) return integrations;
|
||||||
|
const detected = integrations.filter((i) => detectedKinds.has(i.kind));
|
||||||
|
const rest = integrations.filter((i) => !detectedKinds.has(i.kind));
|
||||||
|
return [...detected, ...rest];
|
||||||
|
}, [integrations, detectedKinds]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => filterIntegrations(sorted, search), [sorted, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectGridLayout
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
placeholder={`${t("integration.page.list.search")}...`}
|
||||||
|
disableScroll={onboarding}
|
||||||
|
disableAutoFocus={onboarding}
|
||||||
|
>
|
||||||
|
{filtered.map((integration) => {
|
||||||
|
const isSelected = selectedKinds.has(integration.kind);
|
||||||
|
const isDetected = detectedKinds?.has(integration.kind) ?? false;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={integration.kind}
|
||||||
|
h={selectGridCardHeight}
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
||||||
|
borderWidth: isSelected ? 2 : undefined,
|
||||||
|
}}
|
||||||
|
onClick={() => toggleKind(integration.kind)}
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
<Stack justify="space-between" h="100%" gap="xs">
|
||||||
|
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||||
|
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||||
|
<Text fw={500} lh={1.2} size="sm" lineClamp={2} style={{ flex: 1 }}>
|
||||||
|
{integration.name}
|
||||||
|
</Text>
|
||||||
|
{isSelected && <IconCheck size={18} color="var(--mantine-color-blue-6)" style={{ flexShrink: 0 }} />}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap={4} mt="auto">
|
||||||
|
{isDetected && (
|
||||||
|
<Badge variant="light" color="teal" size="xs" leftSection={<IconBrandDocker size={12} />}>
|
||||||
|
{t("integration.grid.detected")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Group gap={4} wrap="wrap">
|
||||||
|
{integration.categories.slice(0, 2).map((cat) => (
|
||||||
|
<Badge key={cat} variant="light" size="xs">
|
||||||
|
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
{integration.widgets.length > 0 ? (
|
||||||
|
<Tooltip
|
||||||
|
multiline
|
||||||
|
w={200}
|
||||||
|
label={
|
||||||
|
<Stack gap={4}>
|
||||||
|
{integration.widgets.map((widgetKind) => (
|
||||||
|
<Text key={widgetKind} size="xs">
|
||||||
|
{t(`widget.${widgetKind}.name`)}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
|
||||||
|
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Badge variant="light" color="gray" size="sm">
|
||||||
|
{t("integration.grid.noWidgets")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<Center p="xl">
|
||||||
|
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</SelectGridLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
|
import { IconPuzzle } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
|
||||||
|
|
||||||
|
interface IntegrationSelectGridProps {
|
||||||
|
onSelect: (kind: IntegrationKind) => void;
|
||||||
|
enableMockIntegration?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]);
|
||||||
|
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectGridLayout
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
placeholder={`${t("integration.page.list.search")}...`}
|
||||||
|
>
|
||||||
|
{filtered.map((integration) => (
|
||||||
|
<Card
|
||||||
|
key={integration.kind}
|
||||||
|
h={selectGridCardHeight}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => onSelect(integration.kind)}
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
<Stack justify="space-between" h="100%" gap="xs">
|
||||||
|
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||||
|
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||||
|
<Text fw={500} lh={1.2} size="sm" lineClamp={2}>
|
||||||
|
{integration.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap={4} mt="auto">
|
||||||
|
<Group gap={4} wrap="wrap">
|
||||||
|
{integration.categories.slice(0, 2).map((cat) => (
|
||||||
|
<Badge key={cat} variant="light" size="xs">
|
||||||
|
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
{integration.widgets.length > 0 ? (
|
||||||
|
<Tooltip
|
||||||
|
multiline
|
||||||
|
w={200}
|
||||||
|
label={
|
||||||
|
<Stack gap={4}>
|
||||||
|
{integration.widgets.map((widgetKind) => (
|
||||||
|
<Text key={widgetKind} size="xs">
|
||||||
|
{t(`widget.${widgetKind}.name`)}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
|
||||||
|
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Badge variant="light" color="gray" size="sm">
|
||||||
|
{t("integration.grid.noWidgets")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<Center p="xl">
|
||||||
|
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</SelectGridLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Group, ScrollArea, Title } from "@mantine/core";
|
||||||
|
import { IconArrowLeft } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
|
import { createModal, modalSizeSelect } from "@homarr/modals";
|
||||||
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
||||||
|
import { IntegrationSelectGrid } from "./integration-select-grid";
|
||||||
|
|
||||||
|
interface IntegrationSelectModalProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
enableMockIntegration?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
|
||||||
|
const [step, setStep] = useState<"select" | "form">("select");
|
||||||
|
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(null);
|
||||||
|
|
||||||
|
const handleSelect = (kind: IntegrationKind) => {
|
||||||
|
setSelectedKind(kind);
|
||||||
|
setStep("form");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep("select");
|
||||||
|
setSelectedKind(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuccess = () => {
|
||||||
|
innerProps.onSuccess?.();
|
||||||
|
actions.closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step === "form" && selectedKind) {
|
||||||
|
return (
|
||||||
|
<ScrollArea.Autosize mah="80vh">
|
||||||
|
<Group gap="xs" mb="md" style={{ cursor: "pointer" }} onClick={handleBack}>
|
||||||
|
<IconArrowLeft size={18} />
|
||||||
|
<IntegrationAvatar kind={selectedKind} size="sm" />
|
||||||
|
<Title order={4}>{getIntegrationName(selectedKind)}</Title>
|
||||||
|
</Group>
|
||||||
|
<NewIntegrationForm kind={selectedKind} onSuccess={handleSuccess} onCancel={handleBack} />
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={innerProps.enableMockIntegration} />;
|
||||||
|
}).withOptions({
|
||||||
|
defaultTitle: (t) => t("integration.action.create"),
|
||||||
|
size: modalSizeSelect,
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { Link } from "@homarr/ui";
|
|||||||
|
|
||||||
import { ClientBurger } from "./header/burger";
|
import { ClientBurger } from "./header/burger";
|
||||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||||
|
import { TourTarget } from "./header/tour-target";
|
||||||
import { UserButton } from "./header/user";
|
import { UserButton } from "./header/user";
|
||||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||||
|
|
||||||
@@ -25,11 +26,15 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
|||||||
{logo ?? <HomarrLogoWithTitle size="md" />}
|
{logo ?? <HomarrLogoWithTitle size="md" />}
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Group>
|
</Group>
|
||||||
<DesktopSearchInput />
|
<TourTarget id="board-search">
|
||||||
|
<DesktopSearchInput />
|
||||||
|
</TourTarget>
|
||||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||||
{actions}
|
{actions}
|
||||||
<MobileSearchButton />
|
<MobileSearchButton />
|
||||||
<UserButton />
|
<TourTarget id="board-user-menu">
|
||||||
|
<UserButton />
|
||||||
|
</TourTarget>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Spotlight />
|
<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 type { ReactNode } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||||
import { NavLink } from "@mantine/core";
|
import { NavLink } from "@mantine/core";
|
||||||
|
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
@@ -9,9 +10,15 @@ import { Link } from "@homarr/ui";
|
|||||||
export const CommonNavLink = (props: ClientNavigationLink) =>
|
export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||||
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
|
"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 NavLinkHref = (props: NavigationLinkHref) => {
|
||||||
const pathname = usePathname();
|
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 component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
||||||
) : (
|
) : (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -19,26 +26,29 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
|||||||
label={props.label}
|
label={props.label}
|
||||||
leftSection={props.icon}
|
leftSection={props.icon}
|
||||||
href={props.href}
|
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 NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isActive = props.items.some((item) => item.href === pathname);
|
const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
|
||||||
return (
|
const nav = (
|
||||||
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
||||||
{props.items.map((item) => (
|
{props.items.map((item) => (
|
||||||
<NavLinkHref key={item.label} {...item} />
|
<NavLinkHref key={item.label} {...item} />
|
||||||
))}
|
))}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
|
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CommonNavigationLinkProps {
|
interface CommonNavigationLinkProps {
|
||||||
label: string;
|
label: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
|
"data-onboarding-tour-id"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { icon: TablerIcon, iconProps, ...props } = link;
|
const { icon: TablerIcon, iconProps, "data-onboarding-tour-id": tourId, ...props } = link;
|
||||||
const Icon =
|
const Icon =
|
||||||
typeof TablerIcon === "string" ? (
|
typeof TablerIcon === "string" ? (
|
||||||
<Image src={TablerIcon} w={20} h={20} />
|
<Image src={TablerIcon} w={20} h={20} />
|
||||||
@@ -38,17 +38,20 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
|
|||||||
if ("items" in props) {
|
if ("items" in props) {
|
||||||
clientLink = {
|
clientLink = {
|
||||||
...props,
|
...props,
|
||||||
|
"data-onboarding-tour-id": tourId,
|
||||||
items: props.items
|
items: props.items
|
||||||
.filter((item) => !item.hidden)
|
.filter((item) => !item.hidden)
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
|
const { "data-onboarding-tour-id": itemTourId, ...rest } = item;
|
||||||
return {
|
return {
|
||||||
...item,
|
...rest,
|
||||||
|
"data-onboarding-tour-id": itemTourId,
|
||||||
icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
|
icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
} as ClientNavigationLink;
|
} as ClientNavigationLink;
|
||||||
} else {
|
} else {
|
||||||
clientLink = props as ClientNavigationLink;
|
clientLink = { ...props, "data-onboarding-tour-id": tourId } as ClientNavigationLink;
|
||||||
}
|
}
|
||||||
return <CommonNavLink key={index} {...clientLink} icon={Icon} />;
|
return <CommonNavLink key={index} {...clientLink} icon={Icon} />;
|
||||||
})}
|
})}
|
||||||
@@ -63,6 +66,7 @@ interface CommonNavigationLinkProps {
|
|||||||
icon: TablerIcon | string;
|
icon: TablerIcon | string;
|
||||||
iconProps?: TablerIconProps;
|
iconProps?: TablerIconProps;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
"data-onboarding-tour-id"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||||
|
|||||||
@@ -58,19 +58,23 @@ export const DynamicBreadcrumb = ({
|
|||||||
}
|
}
|
||||||
const href = `/${pathnameParts.slice(0, index + 1).join("/")}`;
|
const href = `/${pathnameParts.slice(0, index + 1).join("/")}`;
|
||||||
const translationKey = `${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)) {
|
if (nonInteractable?.includes(pathnamePart)) {
|
||||||
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
|
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 (
|
return (
|
||||||
<Anchor key={href} href={href}>
|
<Anchor key={href} href={href}>
|
||||||
{t(`${translationKey}.label` as TranslationKeys)}
|
{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);
|
window.location.assign(logoutUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.refresh();
|
router.push("/auth/login");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [logoutUrl, openModal, router]);
|
}, [logoutUrl, openModal, router]);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
|
|||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
server: {
|
server: {
|
||||||
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
||||||
|
DEMO_MODE: createBooleanSchema(false),
|
||||||
},
|
},
|
||||||
experimental__runtimeEnv: process.env,
|
experimental__runtimeEnv: process.env,
|
||||||
});
|
});
|
||||||
Vendored
+1
@@ -2,3 +2,4 @@ declare module "*.png";
|
|||||||
declare module "*.svg";
|
declare module "*.svg";
|
||||||
declare module "*.jpeg";
|
declare module "*.jpeg";
|
||||||
declare module "*.jpg";
|
declare module "*.jpg";
|
||||||
|
declare module "sql.js";
|
||||||
+20
-10
@@ -19,6 +19,9 @@
|
|||||||
|
|
||||||
<!-- Links -->
|
<!-- Links -->
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
<a href="https://demo.homarr.dev">
|
||||||
|
<strong>Live Demo 🎮</strong>
|
||||||
|
</a> •
|
||||||
<a href="https://homarr.dev/docs/category/installation-1/">
|
<a href="https://homarr.dev/docs/category/installation-1/">
|
||||||
<strong>Install 💻</strong>
|
<strong>Install 💻</strong>
|
||||||
</a> •
|
</a> •
|
||||||
@@ -246,13 +249,20 @@
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
|
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg" alt="PeaNUT" width="90" height="90" />
|
||||||
|
<br/>
|
||||||
|
<p align="center">PeaNUT</p>
|
||||||
|
</a>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
|
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">Pi-hole</p>
|
<p align="center">Pi-hole</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
|
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -293,15 +303,15 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Radarr</p>
|
<p align="center">Radarr</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/readarr" target="_blank" rel="noreferrer noopener">
|
<a href="https://homarr.dev/docs/integrations/readarr" target="_blank" rel="noreferrer noopener">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg" alt="Readarr" width="90" height="90" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg" alt="Readarr" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">Readarr</p>
|
<p align="center">Readarr</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -342,15 +352,15 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Speedtest<br/>Tracker</p>
|
<p align="center">Speedtest<br/>Tracker</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
|
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">Tdarr</p>
|
<p align="center">Tdarr</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
|
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe("Onboarding", () => {
|
|||||||
confirmPassword: "Comp(exP4sswOrd",
|
confirmPassword: "Comp(exP4sswOrd",
|
||||||
});
|
});
|
||||||
await actions.processSettingsStepAsync();
|
await actions.processSettingsStepAsync();
|
||||||
|
await actions.processIntegrationsStepAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await assertions.assertFinishStepVisibleAsync();
|
await assertions.assertFinishStepVisibleAsync();
|
||||||
@@ -72,6 +73,7 @@ describe("Onboarding", () => {
|
|||||||
name: externalGroupName,
|
name: externalGroupName,
|
||||||
});
|
});
|
||||||
await actions.processSettingsStepAsync();
|
await actions.processSettingsStepAsync();
|
||||||
|
await actions.processIntegrationsStepAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await assertions.assertFinishStepVisibleAsync();
|
await assertions.assertFinishStepVisibleAsync();
|
||||||
|
|||||||
@@ -51,4 +51,9 @@ export class OnboardingActions {
|
|||||||
await this.page.waitForSelector("text=Analytics");
|
await this.page.waitForSelector("text=Analytics");
|
||||||
await this.page.locator("css=button[type='submit']").click();
|
await this.page.locator("css=button[type='submit']").click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async processIntegrationsStepAsync() {
|
||||||
|
await this.page.waitForSelector("text=integrations you'd like to connect");
|
||||||
|
await this.page.locator("button", { hasText: "Skip for now" }).click();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { expect } from "vitest";
|
import { expect } from "vitest";
|
||||||
|
|
||||||
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
|
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
|
||||||
import { OnboardingStep } from "../../../packages/definitions/src";
|
import type { OnboardingStep } from "../../../packages/definitions/src";
|
||||||
import { credentialsAdminGroup } from "../../../packages/definitions/src/group";
|
import { credentialsAdminGroup } from "../../../packages/definitions/src/group";
|
||||||
import type { SqliteDatabase } from "../e2e-db";
|
import type { SqliteDatabase } from "../e2e-db";
|
||||||
|
|
||||||
@@ -57,6 +57,6 @@ export class OnboardingAssertions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async assertFinishStepVisibleAsync() {
|
public async assertFinishStepVisibleAsync() {
|
||||||
await this.page.waitForSelector("text=completed the setup", { timeout: 5000 });
|
await this.page.waitForSelector("text=completed the setup", { timeout: 15000 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -23,7 +23,7 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
client_max_body_size 32M;
|
client_max_body_size 256M;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homarr",
|
"name": "homarr",
|
||||||
"version": "1.61.1",
|
"version": "1.64.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env CI=true turbo build",
|
"build": "cross-env CI=true turbo build",
|
||||||
@@ -25,8 +25,6 @@
|
|||||||
"release": "semantic-release",
|
"release": "semantic-release",
|
||||||
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
||||||
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
||||||
"scripts:generate-codeowners": "tsx ./scripts/generate-codeowners.mts",
|
|
||||||
"scripts:validate-codeowners": "tsx ./scripts/validate-codeowners.mts",
|
|
||||||
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
|
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
|
||||||
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
|
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
|
||||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
|
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
|
||||||
@@ -80,7 +78,7 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@babel/helpers@<7.26.10": ">=7.29.7",
|
"@babel/helpers@<7.26.10": ">=7.29.7",
|
||||||
"@babel/runtime@<7.26.10": ">=7.29.7",
|
"@babel/runtime@<7.26.10": ">=7.29.7",
|
||||||
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
|
"axios@>=1.0.0 <1.8.2": ">=1.17.0",
|
||||||
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
|
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
|
||||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
|
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
|
||||||
"esbuild@<=0.24.2": ">=0.28.0",
|
"esbuild@<=0.24.2": ">=0.28.0",
|
||||||
@@ -90,13 +88,13 @@
|
|||||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
|
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
|
||||||
"prismjs@<1.30.0": ">=1.30.0",
|
"prismjs@<1.30.0": ">=1.30.0",
|
||||||
"proxmox-api>undici": "7.24.6",
|
"proxmox-api>undici": "7.24.6",
|
||||||
"react-is": "^19.2.6",
|
"react-is": "^19.2.7",
|
||||||
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
|
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
|
||||||
"sha.js@<=2.4.11": ">=2.4.12",
|
"sha.js@<=2.4.11": ">=2.4.12",
|
||||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||||
"tmp@<=0.2.3": ">=0.2.7",
|
"tmp@<=0.2.3": ">=0.2.7",
|
||||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
|
"vite@>=5.0.0 <=5.4.18": ">=8.0.16"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@types/node-unifi": "patches/@types__node-unifi.patch",
|
"@types/node-unifi": "patches/@types__node-unifi.patch",
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { createEnv } from "@homarr/core/infrastructure/env";
|
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
|
||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
server: {
|
server: {
|
||||||
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
|
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
|
||||||
|
DEMO_MODE: createBooleanSchema(false),
|
||||||
},
|
},
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
|
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
|
||||||
|
DEMO_MODE: process.env.DEMO_MODE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
|
|||||||
|
|
||||||
import { env } from "@homarr/docker/env";
|
import { env } from "@homarr/docker/env";
|
||||||
|
|
||||||
import { publicProcedure } from "../trpc";
|
import { isDemoMode, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
export const dockerMiddleware = () => {
|
export const dockerMiddleware = () => {
|
||||||
return publicProcedure.use(async ({ next }) => {
|
return publicProcedure.use(async ({ next }) => {
|
||||||
if (env.ENABLE_DOCKER) {
|
if (env.ENABLE_DOCKER || isDemoMode) {
|
||||||
return await next();
|
return await next();
|
||||||
}
|
}
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -1604,22 +1604,24 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
|||||||
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
items: items.map(({ integrations: itemIntegrations, ...item }) =>
|
items: items
|
||||||
parseItem({
|
.map(({ integrations: itemIntegrations, ...item }) =>
|
||||||
...item,
|
parseItem({
|
||||||
layouts: item.layouts.map((layout) => ({
|
...item,
|
||||||
xOffset: layout.xOffset,
|
layouts: item.layouts.map((layout) => ({
|
||||||
yOffset: layout.yOffset,
|
xOffset: layout.xOffset,
|
||||||
width: layout.width,
|
yOffset: layout.yOffset,
|
||||||
height: layout.height,
|
width: layout.width,
|
||||||
layoutId: layout.layoutId,
|
height: layout.height,
|
||||||
sectionId: layout.sectionId,
|
layoutId: layout.layoutId,
|
||||||
})),
|
sectionId: layout.sectionId,
|
||||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
})),
|
||||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||||
}),
|
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||||
),
|
}),
|
||||||
|
)
|
||||||
|
.filter((item) => item !== null),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1635,7 +1637,7 @@ const parseItem = (item: unknown) => {
|
|||||||
const result = outputItemSchema.safeParse(item);
|
const result = outputItemSchema.safeParse(item);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error.message);
|
return null;
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|||||||
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user