Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e078c3606 | ||
|
|
aec07f7ee5 | ||
|
|
c92e074a6f | ||
|
|
741e641bd2 | ||
|
|
9b7f189b02 | ||
|
|
0070538857 | ||
|
|
a11c53ebf7 | ||
|
|
e55def9b60 | ||
|
|
fc66211dd2 | ||
|
|
174e0a7606 | ||
|
|
30cb066b7f | ||
|
|
f1ce0fffad | ||
|
|
9c5ab62b3a | ||
|
|
6adb49eedf | ||
|
|
6482ea6ae8 | ||
|
|
f7d5fde8a9 | ||
|
|
de368a6969 | ||
|
|
e1280f311c | ||
|
|
517dcfa64e | ||
|
|
6faba5f772 | ||
|
|
cca6de2b86 | ||
|
|
c24ccde404 | ||
|
|
d28dd380e5 | ||
|
|
1de167c98d | ||
|
|
7b1a801ab8 | ||
|
|
8239a631f2 | ||
|
|
775ae0147c | ||
|
|
ece6757979 | ||
|
|
dd707bed7e | ||
|
|
276431ef4c | ||
|
|
c25c1dbac7 | ||
|
|
66ad01e7ce | ||
|
|
d4ffd19f01 | ||
|
|
4f693b0465 | ||
|
|
6437e57565 | ||
|
|
2566be2838 | ||
|
|
dc71084323 | ||
|
|
e41605640b |
No files matched your search
@@ -66,8 +66,6 @@ homarr/
|
||||
│ ├── analytics/ # Server-side analytics (Umami)
|
||||
│ ├── server-settings/ # Server setting keys/types
|
||||
│ ├── settings/ # User-facing settings UI context
|
||||
│ ├── old-import/ # Legacy Homarr import
|
||||
│ ├── old-schema/ # Legacy Homarr zod schemas
|
||||
│ └── cli/ # Node CLI for ops (brocli)
|
||||
├── tooling/
|
||||
│ ├── typescript/ # Base tsconfig
|
||||
|
||||
@@ -263,15 +263,6 @@ Even hidden options need translation keys or the translation test fails:
|
||||
"widget.mywidget.option.columnWidths.label": "Column widths"
|
||||
```
|
||||
|
||||
### Old-import mappings
|
||||
|
||||
Add to `packages/old-import/src/widgets/options.ts`:
|
||||
|
||||
```typescript
|
||||
columnOrder: () => undefined,
|
||||
columnWidths: () => undefined,
|
||||
```
|
||||
|
||||
### Dynamic storeKey
|
||||
|
||||
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
|
||||
@@ -610,13 +601,12 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
4. Add CSS file for transparent backgrounds (if widget context)
|
||||
5. Add responsive column visibility (if widget context)
|
||||
6. Add hidden options + hydration logic (if column persistence needed)
|
||||
7. Add old-import mappings for new options
|
||||
8. Add translation keys (including hidden option labels)
|
||||
9. Add context menu if row actions exist
|
||||
10. Add row expansion if detail view exists
|
||||
11. Add error handling on all mutations
|
||||
12. Add accessibility attributes
|
||||
13. Test at multiple widget widths
|
||||
14. Update docs in `apps/docs/docs/widgets/` if applicable
|
||||
15. Run `pnpm turbo typecheck` — catches old-import mismatches
|
||||
16. Run translation spec — catches missing keys
|
||||
7. Add translation keys (including hidden option labels)
|
||||
8. Add context menu if row actions exist
|
||||
9. Add row expansion if detail view exists
|
||||
10. Add error handling on all mutations
|
||||
11. Add accessibility attributes
|
||||
12. Test at multiple widget widths
|
||||
13. Update docs in `apps/docs/docs/widgets/` if applicable
|
||||
14. Run `pnpm turbo typecheck`
|
||||
15. Run translation spec — catches missing keys
|
||||
@@ -40,8 +40,6 @@ homarr/
|
||||
│ ├── analytics/ # Server-side analytics (Umami)
|
||||
│ ├── server-settings/ # Server setting keys/types
|
||||
│ ├── settings/ # User-facing settings UI context
|
||||
│ ├── old-import/ # Legacy Homarr import
|
||||
│ ├── old-schema/ # Legacy Homarr zod schemas
|
||||
│ └── cli/ # Node CLI for ops (brocli)
|
||||
├── tooling/
|
||||
│ ├── typescript/ # Base tsconfig
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ For a single consolidated file with all content, use:
|
||||
- [How to integrate custom input with use-form hook?](https://mantine.dev/llms/q-custom-input-use-form.md): Learn how to add use-form support for custom inputs
|
||||
- [How to prevent Modal from closing?](https://mantine.dev/llms/q-how-to-prevent-modal-from-closing.md): Learn how to prevent Modal from closing when the user clicks outside of it or presses the Escape key
|
||||
- [How to scroll to the top of the form if the form is submitted with errors?](https://mantine.dev/llms/q-scroll-to-the-top-of-the-form.md): Learn how to call a function when the form is submitted with errors
|
||||
- [How to update Mantine dependencies?](https://mantine.dev/llms/q-how-to-update-dependencies.md): Learn how to update @mantine/_, @mantinex/_ and postcss-preset-mantine dependencies manually or with ncu script
|
||||
- [How to update Mantine dependencies?](https://mantine.dev/llms/q-how-to-update-dependencies.md): Learn how to update `@mantine/*`, `@mantinex/*` and `postcss-preset-mantine` dependencies manually or with ncu script
|
||||
- [How to use Dropzone with @mantine/form?](https://mantine.dev/llms/q-how-to-use-dropzone-with-form.md): Learn how to use Dropzone with @mantine/form to handle file selection state
|
||||
- [How to use Mantine template on GitHub?](https://mantine.dev/llms/q-templates-usage.md): Learn how to create a new repository based on Mantine template
|
||||
- [I get hydration warning about data-mantine-color-scheme attribute, what does it mean?](https://mantine.dev/llms/q-color-scheme-hydration-warning.md): Learn how hydration works and how to fix warnings
|
||||
|
||||
@@ -13,8 +13,6 @@ This will be a one time occurrence and version > ``1.0`` will have less breaking
|
||||
This time more important than every; **read the breaking changes** list and **create a backup** before updating.
|
||||
Some third party installation methods may take multiple days or weeks to update to the new version.
|
||||
|
||||
See [this guide on how to import your previous Homarr dashboard](/docs/getting-started/after-the-installation#importing-a-zip-from-version-before-100)
|
||||
|
||||
## Changes at a glance
|
||||
- **Asynchronous integrations system**: In versions prior to ``1.0``, we fetched data upon each request synchronously.
|
||||
This means that the user had to wait for the requests to finish so any data would appear.
|
||||
|
||||
@@ -10,12 +10,11 @@ If you are still on a version before 0.15.10, please update to 0.15.10 where we
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
## Export your data
|
||||
## Preserve your old installation
|
||||
|
||||
As 1.0 is a complete rewrite of Homarr and therefor not compatible with the old docker mounts, we need to export your data first.
|
||||
For this you can navigate to the `Migrate to 1.0` page which you can find under `Management` > `Tools` > `Migrate to 1.0`.
|
||||
Here you can select which things you want to export and download the zip file.
|
||||
It will also show you a secret key which you will need to import the secret data like user password hashes, salts and integration secrets.
|
||||
Homarr 1.0 is a complete rewrite and cannot reuse the old Docker mounts. Keep the 0.15 installation and its data intact while you build the replacement. The historical `Management` > `Tools` > `Migrate to 1.0` export can still be kept as a record, but current Homarr releases cannot import that ZIP or its secret key.
|
||||
|
||||
Plan to recreate your apps, integrations, and boards manually in the new installation. A SQLite database backup can be restored from the onboarding welcome screen only when it was created by a compatible current Homarr release; it is not a conversion path for a 0.15 database.
|
||||
|
||||

|
||||
|
||||
@@ -28,28 +27,15 @@ Please note that if you are using docker you need to specify a diffrent external
|
||||
So in your compose file the ports would be `7576:7575` instead of `7575:7575`.
|
||||
|
||||
## Import your data
|
||||
Once the installation is completed you can access the new installation. During the onboarding you'll need to select `Import from Homarr before 1.0`.
|
||||
|
||||

|
||||
:::warning Legacy guide
|
||||
|
||||
After that you can upload the zip file you downloaded in the first step.
|
||||
The pre-1.0 ZIP importer is no longer included in current Homarr releases. Keep the old installation available until you have manually verified the replacement. Compatible SQLite backups from current releases can still be restored from the onboarding welcome screen.
|
||||
|
||||

|
||||
|
||||
After that you'll get a list with all your boards that can be imported. You can configure how it should be imported and can select which screensizes it should import.
|
||||
|
||||

|
||||
|
||||
Once you have selected everything you can click on `Confirm import and continue` which will open the encryption key input dialog where you can input the secret key you got during the export.
|
||||
|
||||

|
||||
|
||||
After that you can continue the onboarding and you should see all your boards and data in the new installation.
|
||||
|
||||
See [After the installation](https://homarr.dev/docs/getting-started/after-the-installation) for more information.
|
||||
:::
|
||||
|
||||
## Remove the old installation
|
||||
|
||||
After you are happy with the new installation and everything is working as expected you can remove the old installation.
|
||||
Please make sure to backup the data before you remove the old installation.
|
||||
Also once the port 7575 is free again you can change the port mapping of the new installation to `7575:7575` if you want to.
|
||||
Also once the port 7575 is free again you can change the port mapping of the new installation to `7575:7575` if you want to.
|
||||
@@ -6,18 +6,10 @@ tags:
|
||||
- Configuration
|
||||
---
|
||||
|
||||
import imageStartFromScratchStepServerSettings from "./img/onboarding-from-scratch-step-server-settings.png";
|
||||
import imageStartFromScratchStepAdminUser from "./img/onboarding-from-scratch-step-admin-user.png";
|
||||
import imageStartFromScratchStepExternalAdminGroup from "./img/onboarding-from-scratch-step-external-admin-group.png";
|
||||
import imageStartFromScratchStepFinish from "./img/onboarding-from-scratch-step-finish.png";
|
||||
import manageBoardHeaderEditMode from "./img/manage-board-header-edit-mode.png";
|
||||
import manageBoardHeaderAddItem from "./img/manage-board-header-add-item-menu.png";
|
||||
import manageBoardHeaderChooseItem from "./img/manage-board-header-choose-item.png";
|
||||
import manageBoardNoApp from "./img/manage-board-no-app.png";
|
||||
import manageExportOldHomarr from "./img/old-homarr-export-data.png";
|
||||
import manageImportOldHomarrDropzone from "./img/old-homarr-import-dropzone.png";
|
||||
import manageImportOldHomarrChooseData from "./img/old-homarr-import-choose-imported-data.png";
|
||||
import manageImportDecryptionToken from "./img/old-homarr-import-decryption-token.png";
|
||||
|
||||
:::tip
|
||||
|
||||
@@ -29,117 +21,50 @@ Congratulations on installing Homarr. Now it's time to get yourself familiar wit
|
||||
|
||||
## Onboarding
|
||||
|
||||
The onboard will show the first time you start Homarr.
|
||||
This process will help you with your first steps and adding some initial data, such as the administrator account.
|
||||
Onboarding appears the first time Homarr starts. Its goal is to create a useful responsive board, not merely an
|
||||
administrator account. The setup uses one guided studio, so you can keep the safe defaults and finish quickly or review
|
||||
every detected service before anything is added to the board.
|
||||
|
||||
:::note
|
||||
|
||||
If you see this screen repeatitly after you restarted the Homarr container, your Docker mounting paths may be missing,
|
||||
If you see this screen repeatedly after restarting the Homarr container, your Docker mounting paths may be missing,
|
||||
incorrect or read-only. Read [our installation instructions](/docs/getting-started/installation/docker) again to fix this issue.
|
||||
|
||||
:::
|
||||
|
||||
You can either import a previous dashboard using the ZIP archive or start from scratch:
|
||||
### Restore or start fresh
|
||||
|
||||

|
||||
SQLite installations can restore a complete Homarr database backup from the welcome screen. This restores current
|
||||
boards, people, apps, integrations, and settings. The pre-1.0 ZIP importer is no longer included.
|
||||
|
||||
You may refresh anytime and continue from a different device - the onboarding is saved immediately on the server for your convenience.
|
||||
For a new installation, Homarr first creates either a credentials administrator or an administrator group matching your
|
||||
LDAP/OIDC provider. The browser that starts setup claims the first-run session; refreshes are safe, while another browser
|
||||
cannot change credentials or integrations during that setup.
|
||||
|
||||
### Starting from scratch
|
||||
### Setup studio
|
||||
|
||||
Depending on your auth provider configuration you will either first see the admin user creation form or the external group creation form.
|
||||
If the credentials provider was selected, you'll be prompted to create an admin user for Homarr.
|
||||
This user cannot be deleted and has full control over Homarr and all of its boards.
|
||||
You must choose a username and password (both can later be changed).
|
||||
We enforce strict password requirements when you enter your password to protect your instance if you accidentally
|
||||
expose it to the internet.
|
||||
The studio keeps related choices together:
|
||||
|
||||
<img src={imageStartFromScratchStepAdminUser} alt={"Screenshot form to create admin user"} width={300} />
|
||||
1. **Essentials** sets the default language, color scheme, analytics preference, and the usual host, IP address, domain, or reverse-proxy path used by your services. URL suggestions can use separate ports, subdomains, or one path per service.
|
||||
2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup.
|
||||
3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it.
|
||||
4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets.
|
||||
5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint.
|
||||
6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct.
|
||||
|
||||
If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group.
|
||||
This group will be used to synchronize users from your provider to Homarr.
|
||||
When a user logs in through the provider, any of their groups that match groups in Homarr will be assigned to the user.
|
||||
The name of the group must match exactly the name of the group in the provider.
|
||||
Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket
|
||||
is unavailable, which is useful for remote Docker, rootless Docker, Synology, and mixed deployments. On Kubernetes or
|
||||
bare-metal installations without a Docker socket, enter services manually and use the same integration catalog.
|
||||
|
||||
<img
|
||||
src={imageStartFromScratchStepExternalAdminGroup}
|
||||
alt={"Screenshot form to create external admin group"}
|
||||
width={300}
|
||||
/>
|
||||
After setup, **Open my board** takes you directly to the configured board. The in-product tour then introduces edit mode,
|
||||
responsive layouts, widgets, and search without delaying completion.
|
||||
|
||||
Next, you can configure some basic settings for Homarr. We recommend to leave all settings at default for most users.
|
||||
Here you can also disable analytics. Please note that our analytics are completly anonymous and is fully compliant with GDPR and PECR.
|
||||
|
||||
<img
|
||||
src={imageStartFromScratchStepServerSettings}
|
||||
alt={"Screenshot of analytics and indexing settings in the onboarding"}
|
||||
width={300}
|
||||
/>
|
||||
|
||||
### Setting up integrations
|
||||
|
||||
After configuring server settings, the onboarding guides you through setting up your integrations.
|
||||
|
||||
#### Docker service discovery
|
||||
|
||||
If Docker is available (socket mounted), Homarr automatically detects running containers and matches them to known integration types.
|
||||
Matching integrations are pre-selected in the integration list, saving you from manually picking each one.
|
||||
|
||||
#### URL templating
|
||||
|
||||
You can define a base URL template to auto-fill integration URLs in bulk.
|
||||
Two modes are supported:
|
||||
|
||||
- **Subdomain mode** - e.g. `{app}.example.com` where `{app}` is replaced with the integration name
|
||||
- **Port mode** - e.g. `192.168.1.1:{port}` where `{port}` is replaced with the integration's default port
|
||||
|
||||
#### API key helper links
|
||||
|
||||
For integrations that require API keys (such as Sonarr, Radarr, SABnzbd, or Seerr), a helper link opens the relevant settings page in the target application so you can quickly copy your key.
|
||||
|
||||
#### Creating apps from Docker containers
|
||||
|
||||
You can optionally auto-create bookmark apps for all discovered Docker containers, making them immediately available on your board.
|
||||
|
||||
:::note
|
||||
|
||||
Release providers and Search.ch are configured directly in their widgets instead of the integrations page.
|
||||
|
||||
:::
|
||||
|
||||
After this step, you finished the onboarding.
|
||||
The onboarding can no longer be started again.
|
||||
From here you can click on one of the provided links to continue your journey.
|
||||
We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board).
|
||||
|
||||
<img src={imageStartFromScratchStepFinish} alt={"Screenshot of the finish onboarding process with links"} width={300} />
|
||||
|
||||
### Importing a ZIP from version before 1.0.0
|
||||
|
||||
This is recommended when you used Homarr before and want to re-import your data.
|
||||
It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0).
|
||||
|
||||
<img src={manageExportOldHomarr} alt="Export from old Homarr" style={{ maxWidth: 500 }} />
|
||||
|
||||
**How to export and import data**
|
||||
|
||||
1. Navigate to your old Homarr, open your management pages, open the tools submenu and open `Migrate to 1.0`.
|
||||
2. Check the contents that you want to export. We recommend you to export all data.
|
||||
3. Click on the export data button
|
||||
4. Save the ZIP file, that is being downloaded, on your device. If you selected "integrations" or "users", you also get an encryption / decryption key.
|
||||
Copy it and do not loose it!
|
||||
5. Go to your new >1.0.0 Homarr instance and start the onboarding. Choose `Import from Homarr before 1.0`
|
||||
6. Drag and drop your previously downloaded ZIP archive into the file upload: <br/><img src={manageImportOldHomarrDropzone} style={{ maxWidth: 500 }} />
|
||||
7. Next, choose the data that you want to import and how to import it. Since screen sizes were removed in 1.0, we create a new board for every screen size by default. We recommend you to only keep the best layout and adjust it later in 1.0.0 - but you can also import all of them and pick later.<br/><img src={manageImportOldHomarrChooseData} style={{ maxWidth: 500 }} />
|
||||
8. If you previously exported integrations or users, you will be asked to decrypt the data safely using your obtained encryption token. Paste into the modal and confirm your action.<br/><img src={manageImportDecryptionToken} style={{ maxWidth: 500 }} />
|
||||
9. Next, you can [follow the same setup as when starting from scratch](#starting-from-scratch). After you completed the onboarding, all of your data will be migrated and available.
|
||||
|
||||
## Creating your first board
|
||||
## Creating another board
|
||||
|
||||
In Homarr, you organize your apps and widgets on boards.
|
||||
Boards use fixed-size tiles so the same widget keeps the same proportions on every screen.
|
||||
Drag, resize, and section controls are available in edit mode.
|
||||
Let's create your first board by navigating here: `Manage` > `Boards` > `New board`:
|
||||
Onboarding already created the first board. To create another one, navigate to `Manage` > `Boards` > `New board`:
|
||||

|
||||
|
||||
A board must always have a name and can either be public or private.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: "Docker labels"
|
||||
description: "Discover apps, integrations, and widgets from container labels during Homarr onboarding."
|
||||
---
|
||||
|
||||
# Docker label discovery
|
||||
|
||||
During onboarding, Homarr scans running containers on every reachable
|
||||
[Docker-compatible host](/docs/integrations/docker). Labels let you provide exact app metadata instead of relying on
|
||||
image-name matching. You can review and deselect every result before Homarr creates anything.
|
||||
|
||||
A container with valid Homarr labels takes priority over image-based discovery. Containers without valid labels can
|
||||
still be suggested from their image and published ports.
|
||||
|
||||
## Homarr labels
|
||||
|
||||
`homarr.name` and `homarr.href` are required for label discovery. All other labels are optional.
|
||||
|
||||
| Label | Purpose |
|
||||
| -------------------- | -------------------------------------------------------------------------- |
|
||||
| `homarr.name` | App or integration display name |
|
||||
| `homarr.href` | Address opened by the app and used as the suggested integration address |
|
||||
| `homarr.group` | Container section that receives the selected app and compatible widget |
|
||||
| `homarr.icon` | App icon URL |
|
||||
| `homarr.description` | App description |
|
||||
| `homarr.ping` | Address used for app status checks |
|
||||
| `homarr.id` | Stable source identifier; defaults to the Docker container ID |
|
||||
| `homarr.board` | Intended board name; see [Board targeting](#board-targeting) |
|
||||
| `homarr.integration` | Homarr integration kind, for example `sonarr` |
|
||||
| `homarr.widget` | Homarr widget kind to add when it supports the selected integration |
|
||||
| `homarr.hide` | Excludes the container when this label is present, regardless of its value |
|
||||
|
||||
Unknown integration and widget kinds are ignored. Do not put API keys, passwords, or other secrets in labels. Enter
|
||||
credentials in onboarding, where Homarr stores them using its normal encrypted integration-secret flow.
|
||||
|
||||
## Compose example
|
||||
|
||||
```yaml
|
||||
services:
|
||||
sonarr:
|
||||
image: lscr.io/linuxserver/sonarr:latest
|
||||
labels:
|
||||
homarr.name: "Sonarr"
|
||||
homarr.href: "http://server.local:8989"
|
||||
homarr.group: "Media"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg"
|
||||
homarr.description: "TV library"
|
||||
homarr.ping: "http://server.local:8989"
|
||||
homarr.integration: "sonarr"
|
||||
homarr.widget: "calendar"
|
||||
```
|
||||
|
||||
Use an address that is reachable where it is needed. A Docker service name can work for server-side integration
|
||||
requests, while an app link opened in a browser usually needs a LAN hostname, IP address, or reverse-proxy address.
|
||||
Onboarding accepts these self-hosted address formats directly and only requires the field to be non-empty.
|
||||
|
||||
## Homepage label fallback
|
||||
|
||||
When `homarr.name` is absent, onboarding also understands these Homepage labels:
|
||||
|
||||
- `homepage.name`
|
||||
- `homepage.href`
|
||||
- `homepage.group`
|
||||
- `homepage.icon`
|
||||
- `homepage.description`
|
||||
|
||||
`homepage.name` and `homepage.href` are required for the fallback. Homarr does not combine partial Homarr metadata with
|
||||
Homepage metadata: once `homarr.name` is present, use `homarr.*` labels for the remaining fields too.
|
||||
|
||||
## What onboarding creates
|
||||
|
||||
In **Connect**, label-discovered apps and integrations appear before image-based suggestions. You can import multiple
|
||||
services in one pass and edit every suggested address. For image-matched apps, Homarr uses the detected service type
|
||||
when building host, subdomain, or reverse-proxy path suggestions.
|
||||
|
||||
When setup completes, Homarr rechecks the selected container sources and then:
|
||||
|
||||
- creates an app with its name, address, icon, description, and ping address;
|
||||
- creates the requested integration and links its app after you provide required credentials;
|
||||
- places grouped apps in a board container named by `homarr.group`;
|
||||
- places ungrouped apps in the main board area;
|
||||
- adds `homarr.widget` only when the widget kind is valid and compatible with the selected integration;
|
||||
- keeps image-based discovery available for unlabeled containers.
|
||||
|
||||
If a selected container disappears, targets another board, or requests an incompatible widget, setup finishes with a
|
||||
warning instead of creating an invalid board item.
|
||||
|
||||
## Board targeting
|
||||
|
||||
On a fresh installation with one seeded `dashboard` board, one unique selected `homarr.board` value can name that first
|
||||
board. Otherwise, the label must match the board selected in onboarding. Services labeled for another board are skipped
|
||||
and reported in the completion warning.
|
||||
|
||||
Onboarding configures one board at a time. Run normal board management after setup if you want to distribute services
|
||||
across several boards.
|
||||
@@ -160,11 +160,13 @@ They are then available in the app list and can be used as normally added apps.
|
||||
|
||||

|
||||
|
||||
### Configuration
|
||||
### Container labels
|
||||
|
||||
Currently Homarr supports one label for configuration of the containers:
|
||||
`homarr.hide` excludes a container from the Docker Containers widget, tools page, and onboarding discovery whenever the
|
||||
label is present.
|
||||
|
||||
- **homarr.hide:** If set to any value, the container will be hidden from the Docker Containers widget and tools page.
|
||||
During first-run onboarding, Homarr can also discover apps, integrations, groups, and compatible widgets from
|
||||
`homarr.*` labels, with Homepage labels as a fallback. See the complete [Docker label reference](/docs/integrations/docker-labels).
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -51,6 +51,6 @@ Note that the ping URL only accepts `http://` and `https://` URLs — custom URI
|
||||
|
||||
## List of apps
|
||||
|
||||
On the apps management page you find all apps that exist in your Homarr instance. You can edit and delete them.
|
||||
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
|
||||
|
||||

|
||||
Any signed-in user can view this list. Creating, editing, and deleting apps requires the corresponding app permission; board permissions are not required.
|
||||
@@ -25,8 +25,8 @@ Open **Management → Assistant**.
|
||||
4. Enable the assistant and save.
|
||||
|
||||
Choose a provider preset or use **Custom endpoint** with an OpenAI-compatible Chat Completions API. The
|
||||
[Homarr provider](../workshop/homarr-provider) needs no administrator API key; users sign in to the Community Workshop
|
||||
and receive a daily allowance.
|
||||
[Homarr provider](../workshop/homarr-provider) is the default and needs no administrator API key. The Homarr team pays
|
||||
for a limited number of Assistant uses for each user every day; users sign in to the Community Workshop to use them.
|
||||
|
||||
:::info Local model servers
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ Before restoring, Homarr uses **sql.js WASM** to analyze the backup directly in
|
||||
|
||||
Type `I understand` to acknowledge that restoring will replace your current database. This cannot be undone.
|
||||
|
||||
During first-run onboarding, the database is still empty, so the restore preview goes directly to **Restore Database**
|
||||
without the extra typed confirmation. After Homarr restarts, onboarding opens the restored default home board.
|
||||
|
||||
### 4. Restore
|
||||
|
||||
The backup ZIP is sent to the server, validated, and applied. After a successful restore:
|
||||
|
||||
@@ -20,6 +20,8 @@ You can create as many boards as you want.
|
||||
Boards can be restricted to user groups.
|
||||
A user may also have a "home board" which they will see at the root path (e.g. my-homarr.com or my-homarr.com/board).
|
||||
Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home) or via the management pages.
|
||||
The management page includes a compact structural preview of each board's Base layout so you can recognize it before
|
||||
opening it. Containers include their visible widgets and app icons instead of appearing empty.
|
||||
|
||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ Check [the available integrations](/docs/category/integrations) for further info
|
||||
|
||||
## Create an integration
|
||||
|
||||
To create an integration, you can choose the supported application from the dropdown at the top right:
|
||||
To create an integration, choose **New integration** and select the supported application from the searchable grid. The grid groups related services visually and shows which widgets each integration supports.
|
||||
|
||||

|
||||
The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use.
|
||||
|
||||
Next, you'll need to fill out the required fields to configure the integration:
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ tags:
|
||||
# Search Engines
|
||||
|
||||
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `CTRL + K`.
|
||||
|
||||

|
||||
The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine.
|
||||
|
||||
You can add any search engine that has a search URL that accepts a search term as a parameter.
|
||||
|
||||
|
||||
@@ -48,8 +48,7 @@
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/old-import": "workspace:^0.1.0",
|
||||
"@homarr/old-schema": "workspace:^0.1.0",
|
||||
"@homarr/onboarding": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
@@ -69,6 +68,7 @@
|
||||
"@mantine/modals": "catalog:",
|
||||
"@mantine/tiptap": "catalog:",
|
||||
"@modelcontextprotocol/server": "catalog:",
|
||||
"@rexa-developer/tiks": "catalog:",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/react-devtools": "catalog:",
|
||||
|
||||
@@ -4,9 +4,9 @@ import { Box, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getRepresentativeLayoutWidth } from "../_layout-utils";
|
||||
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
|
||||
@@ -52,7 +52,11 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
redirect("/auth/login");
|
||||
const requestedBoardName =
|
||||
typeof resolvedParams.name === "string" ? `/boards/${encodeURIComponent(resolvedParams.name)}` : null;
|
||||
redirect(
|
||||
requestedBoardName ? `/auth/login?callbackUrl=${encodeURIComponent(requestedBoardName)}` : "/auth/login",
|
||||
);
|
||||
}
|
||||
|
||||
notFound();
|
||||
|
||||
@@ -21,6 +21,7 @@ import { IconX } from "@tabler/icons-react";
|
||||
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { BoardColorInput } from "@homarr/ui";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { generateColors } from "../../(content)/_theme";
|
||||
@@ -44,21 +45,11 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<ColorInput
|
||||
label={t("board.field.primaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
<BoardColorInput label={t("board.field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<ColorInput
|
||||
label={t("board.field.secondaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("secondaryColor")}
|
||||
/>
|
||||
<BoardColorInput label={t("board.field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||
|
||||
@@ -4,21 +4,21 @@ import { Badge, Box, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/cor
|
||||
import { IconLayoutGrid } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
|
||||
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import type { BoardLayout } from "../../_layout-utils";
|
||||
import { getRepresentativeLayoutWidth, projectBoardLayout } from "../../_layout-utils";
|
||||
import classes from "./_layout-preview.module.css";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
layout: BoardLayout;
|
||||
layouts: BoardLayout[];
|
||||
sourceLayout: BoardLayout;
|
||||
layout: BoardPreviewLayout;
|
||||
layouts: BoardPreviewLayout[];
|
||||
sourceLayout: BoardPreviewLayout;
|
||||
apps: RouterOutputs["app"]["byIds"];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { getRepresentativeLayoutWidth, projectBoardLayout } from "./_layout-utils";
|
||||
|
||||
const mobile = {
|
||||
id: "mobile",
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
};
|
||||
const custom = {
|
||||
id: "tablet",
|
||||
name: "Tablet",
|
||||
columnCount: 6,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 480,
|
||||
role: "custom" as const,
|
||||
};
|
||||
const base = {
|
||||
id: "base",
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
};
|
||||
|
||||
describe("layout representative widths", () => {
|
||||
test("uses a phone width, activation midpoint, and desktop minimum", () => {
|
||||
const layouts = [mobile, custom, base];
|
||||
expect(getRepresentativeLayoutWidth(mobile, layouts)).toBe(390);
|
||||
expect(getRepresentativeLayoutWidth(custom, layouts)).toBe(624);
|
||||
expect(getRepresentativeLayoutWidth(base, layouts)).toBe(1280);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectBoardLayout", () => {
|
||||
test("returns exact saved positions without recursively re-projecting the active layout", () => {
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const container = new ContainerSectionMockBuilder({ id: "container" })
|
||||
.addLayout({ layoutId: mobile.id, parentSectionId: rootSection.id, width: 3, height: 2 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, container]).build();
|
||||
|
||||
expect(projectBoardLayout(board, mobile, mobile)).toEqual([
|
||||
expect.objectContaining({ id: container.id, sectionId: rootSection.id, width: 3, height: 2 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses production ordering and sizing for items and containers", () => {
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const container = new ContainerSectionMockBuilder({ id: "container" })
|
||||
.addLayout({ layoutId: base.id, parentSectionId: rootSection.id, width: 6, height: 3, xOffset: 6, yOffset: 0 })
|
||||
.build();
|
||||
const rootItem = new ItemMockBuilder({ id: "root-item", kind: "clock" })
|
||||
.addLayout({ layoutId: base.id, sectionId: rootSection.id, width: 5, height: 2, xOffset: 0, yOffset: 0 })
|
||||
.build();
|
||||
const nestedItem = new ItemMockBuilder({ id: "nested-item", kind: "clock" })
|
||||
.addLayout({ layoutId: base.id, sectionId: container.id, width: 4, height: 1, xOffset: 0, yOffset: 0 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(mobile)
|
||||
.addSections([rootSection, container])
|
||||
.addItems([rootItem, nestedItem])
|
||||
.build();
|
||||
|
||||
const projected = projectBoardLayout(board, base, mobile);
|
||||
|
||||
expect(projected.find((element) => element.id === rootItem.id)).toMatchObject({
|
||||
type: "item",
|
||||
width: 3,
|
||||
height: 2,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
sectionId: rootSection.id,
|
||||
});
|
||||
expect(projected.find((element) => element.id === container.id)).toMatchObject({
|
||||
type: "section",
|
||||
width: 3,
|
||||
xOffset: 0,
|
||||
yOffset: 2,
|
||||
sectionId: rootSection.id,
|
||||
});
|
||||
expect(projected.find((element) => element.id === nestedItem.id)).toMatchObject({
|
||||
type: "item",
|
||||
width: 3,
|
||||
sectionId: container.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores missing saved positions and supports empty boards", () => {
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const itemWithoutBasePosition = new ItemMockBuilder({ id: "missing", kind: "clock" }).build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(mobile)
|
||||
.addSection(rootSection)
|
||||
.addItem(itemWithoutBasePosition)
|
||||
.build();
|
||||
|
||||
expect(projectBoardLayout(board, base, mobile)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -1,2 +0,0 @@
|
||||
export const onboardingContentWidth = 64 * 16;
|
||||
export const onboardingMaxWidth = "90vw";
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export const BackToStart = () => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
|
||||
|
||||
const handleBackToStartAsync = async () => {
|
||||
await mutateAsync();
|
||||
await revalidatePathActionAsync("/init");
|
||||
};
|
||||
|
||||
return (
|
||||
<Button loading={isPending} variant="subtle" color="gray" fullWidth onClick={handleBackToStartAsync}>
|
||||
{t("init.backToStart")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
|
||||
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
export const InitFinish = async () => {
|
||||
const tFinish = await getScopedI18n("init.step.finish");
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Stack>
|
||||
<Text>{tFinish("description")}</Text>
|
||||
|
||||
<InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}>
|
||||
{tFinish("action.goToBoard", { name: "dashboard" })}
|
||||
</InternalLinkButton>
|
||||
|
||||
{isProviderEnabled("credentials") && (
|
||||
<InternalLinkButton
|
||||
href="/auth/login?callbackUrl=/manage/users/invites"
|
||||
iconProps={{ icon: IconMailForward, color: "pink" }}
|
||||
>
|
||||
{tFinish("action.inviteUser")}
|
||||
</InternalLinkButton>
|
||||
)}
|
||||
|
||||
<ExternalLinkButton
|
||||
href={createDocumentationLink("/docs/getting-started/after-the-installation")}
|
||||
iconProps={{ icon: IconBook2, color: "yellow" }}
|
||||
>
|
||||
{tFinish("action.docs")}
|
||||
</ExternalLinkButton>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
|
||||
interface LinkButtonProps {
|
||||
href: string;
|
||||
children: string;
|
||||
iconProps: IconProps;
|
||||
}
|
||||
|
||||
interface IconProps {
|
||||
icon: TablerIcon;
|
||||
color: MantineColor;
|
||||
}
|
||||
|
||||
const Icon = ({ icon: IcomComponent, color }: IconProps) => {
|
||||
return <IcomComponent color={getMantineColor(color, 6)} size={16} stroke={1.5} />;
|
||||
};
|
||||
|
||||
const InternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
|
||||
return (
|
||||
<Button variant="default" component={Link} href={href} leftSection={<Icon {...iconProps} />}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const ExternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
|
||||
return (
|
||||
<Button variant="default" component="a" href={href} leftSection={<Icon {...iconProps} />}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Stack, TextInput } from "@mantine/core";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { groupCreateSchema } from "@homarr/validation/group";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
export const InitGroup = () => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/init");
|
||||
},
|
||||
});
|
||||
const form = useZodForm(groupCreateSchema, {
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmitAsync = async (values: z.infer<typeof groupCreateSchema>) => {
|
||||
await mutateAsync(values, {
|
||||
onError(error) {
|
||||
if (error.data?.code === "CONFLICT") {
|
||||
form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("init.step.group.form.name.label")}
|
||||
description={t("init.step.group.form.name.description")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
|
||||
{t("common.action.continue")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ActionIcon, Button, Group, Text } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
interface FileInfoCardProps {
|
||||
file: FileWithPath;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
||||
return (
|
||||
<InitStepCard withBorder={false}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group>
|
||||
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text visibleFrom="md" c="gray.6" size="sm">
|
||||
{formatBytes(file.size)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
rightSection={<IconPencil size={16} stroke={1.5} />}
|
||||
onClick={onRemove}
|
||||
visibleFrom="md"
|
||||
>
|
||||
{tFileInfo("action.change")}
|
||||
</Button>
|
||||
<ActionIcon size="sm" variant="subtle" color="gray" hiddenFrom="md" onClick={onRemove}>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Group, rem, Text } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
|
||||
import { IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
|
||||
|
||||
import "@mantine/dropzone/styles.css";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface ImportDropZoneProps {
|
||||
loading: boolean;
|
||||
updateFile: (file: FileWithPath) => void;
|
||||
}
|
||||
|
||||
export const ImportDropZone = ({ loading, updateFile }: ImportDropZoneProps) => {
|
||||
const tDropzone = useScopedI18n("init.step.import.dropzone");
|
||||
return (
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const firstFile = files[0];
|
||||
if (!firstFile) return;
|
||||
|
||||
updateFile(firstFile);
|
||||
}}
|
||||
acceptColor="blue.6"
|
||||
rejectColor="red.6"
|
||||
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
|
||||
loading={loading}
|
||||
multiple={false}
|
||||
maxSize={1024 * 1024 * 1024 * 64} // 64 MB
|
||||
onReject={(rejections) => {
|
||||
console.error(
|
||||
"Rejected files",
|
||||
rejections.map(
|
||||
(rejection) =>
|
||||
`File: ${rejection.file.name} size=${rejection.file.size} fileType=${rejection.file.type}\n - ${rejection.errors.map((error) => error.message).join("\n - ")}`,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={220} 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>
|
||||
{tDropzone("title")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" inline mt={7}>
|
||||
{tDropzone("description")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useState } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { InitialOldmarrImport } from "@homarr/old-import/components";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
import { FileInfoCard } from "./file-info-card";
|
||||
import { ImportDropZone } from "./import-dropzone";
|
||||
|
||||
export const InitImport = () => {
|
||||
const [file, setFile] = useState<FileWithPath | null>(null);
|
||||
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
|
||||
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<InitStepCard>
|
||||
<ImportDropZone
|
||||
loading={isPending}
|
||||
updateFile={(file) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
mutate(formData, {
|
||||
onSuccess: (result) => {
|
||||
startTransition(() => {
|
||||
setAnalyseResult(result);
|
||||
setFile(file);
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InitStepCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack w="100%" mb="sm">
|
||||
<FileInfoCard file={file} onRemove={() => setFile(null)} />
|
||||
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -1,483 +0,0 @@
|
||||
"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,129 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack, Switch, Text } from "@mantine/core";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { CheckboxProps } from "@homarr/form/types";
|
||||
import { defaultServerSettings } from "@homarr/server-settings";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { settingsInitSchema } from "@homarr/validation/settings";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
export const InitSettings = () => {
|
||||
const tSection = useScopedI18n("management.page.settings.section");
|
||||
const t = useI18n();
|
||||
const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/init");
|
||||
},
|
||||
});
|
||||
const form = useZodForm(settingsInitSchema, { initialValues: defaultServerSettings });
|
||||
|
||||
const handleSubmitAsync = async (values: z.infer<typeof settingsInitSchema>) => {
|
||||
await mutateAsync(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack w="100%">
|
||||
<InitStepCard>
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>{tSection("analytics.title")}</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
<InitStepCard>
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
<CrawlingRow
|
||||
kind="noIndex"
|
||||
{...form.getInputProps("crawlingAndIndexing.noIndex", { type: "checkbox" })}
|
||||
/>
|
||||
<CrawlingRow
|
||||
kind="noFollow"
|
||||
{...form.getInputProps("crawlingAndIndexing.noFollow", { type: "checkbox" })}
|
||||
/>
|
||||
<CrawlingRow
|
||||
kind="noTranslate"
|
||||
{...form.getInputProps("crawlingAndIndexing.noTranslate", { type: "checkbox" })}
|
||||
/>
|
||||
<CrawlingRow
|
||||
kind="noSiteLinksSearchBox"
|
||||
{...form.getInputProps("crawlingAndIndexing.noSiteLinksSearchBox", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
|
||||
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
|
||||
{t("common.action.continue")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface AnalyticsRowProps {
|
||||
kind: Exclude<keyof TranslationObject["management"]["page"]["settings"]["section"]["analytics"], "title">;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AnalyticsRow = ({ kind, ...props }: AnalyticsRowProps & CheckboxProps) => {
|
||||
const tSection = useI18n("management.page.settings.section");
|
||||
|
||||
return (
|
||||
<SettingRow title={tSection(`analytics.${kind}.title`)} text={tSection(`analytics.${kind}.text`)} {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
interface CrawlingRowProps {
|
||||
kind: Exclude<
|
||||
keyof TranslationObject["management"]["page"]["settings"]["section"]["crawlingAndIndexing"],
|
||||
"title" | "warning"
|
||||
>;
|
||||
}
|
||||
|
||||
const CrawlingRow = ({ kind, ...inputProps }: CrawlingRowProps & CheckboxProps) => {
|
||||
const tSection = useI18n("management.page.settings.section");
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
title={tSection(`crawlingAndIndexing.${kind}.title`)}
|
||||
text={tSection(`crawlingAndIndexing.${kind}.text`)}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingRow = ({
|
||||
title,
|
||||
text,
|
||||
disabled,
|
||||
...inputProps
|
||||
}: { title: string; text: string; disabled?: boolean } & CheckboxProps) => {
|
||||
return (
|
||||
<Group wrap="nowrap" align="center">
|
||||
<Stack gap={0} style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="xs" c="gray.5">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Switch disabled={disabled} {...inputProps} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
import { InitSqliteRestore } from "./sqlite-restore";
|
||||
import { InitStartButton } from "./next-button";
|
||||
|
||||
export const InitStart = async () => {
|
||||
const tStart = await getScopedI18n("init.step.start");
|
||||
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Stack>
|
||||
<Text>{tStart("description")}</Text>
|
||||
|
||||
<InitStartButton
|
||||
preferredStep={undefined}
|
||||
icon={<IconPlayerPlay color={getMantineColor("green", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{tStart("action.scratch")}
|
||||
</InitStartButton>
|
||||
<InitStartButton
|
||||
preferredStep="import"
|
||||
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{tStart("action.importOldmarr")}
|
||||
</InitStartButton>
|
||||
{isSqlite && <InitSqliteRestore />}
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
interface InitStartButtonProps {
|
||||
icon: ReactNode;
|
||||
preferredStep: OnboardingStep | undefined;
|
||||
}
|
||||
|
||||
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
|
||||
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
await mutateAsync({ preferredStep });
|
||||
await revalidatePathActionAsync("/init");
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleClickAsync} variant="default" leftSection={icon}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, 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,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Stack, TextInput } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { userInitSchema } from "@homarr/validation/user";
|
||||
|
||||
export const InitUserForm = () => {
|
||||
const t = useScopedI18n("user");
|
||||
const tUser = useScopedI18n("init.step.user");
|
||||
const { mutateAsync, isPending } = clientApi.user.initUser.useMutation();
|
||||
const form = useZodForm(userInitSchema, {
|
||||
initialValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmitAsync = async (values: FormType) => {
|
||||
await mutateAsync(values, {
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: tUser("notification.success.title"),
|
||||
message: tUser("notification.success.message"),
|
||||
});
|
||||
|
||||
void signIn("credentials", {
|
||||
name: values.username,
|
||||
password: values.password,
|
||||
redirect: false,
|
||||
}).then(async () => {
|
||||
await revalidatePathActionAsync("/init");
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification({
|
||||
title: tUser("notification.error.title"),
|
||||
message: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<form
|
||||
onSubmit={form.onSubmit(
|
||||
(values) => void handleSubmitAsync(values),
|
||||
(err) => console.log(err),
|
||||
)}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
||||
<UserCreatePasswordFields
|
||||
passwordInputProps={form.getInputProps("password")}
|
||||
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
|
||||
/>
|
||||
<Button type="submit" fullWidth loading={isPending}>
|
||||
{t("action.create")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof userInitSchema>;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
import { InitUserForm } from "./init-user-form";
|
||||
|
||||
export const InitUser = () => {
|
||||
return (
|
||||
<InitStepCard>
|
||||
<InitUserForm />
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
@@ -1,66 +1,78 @@
|
||||
import type { JSX } from "react";
|
||||
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { db } from "@homarr/db";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getOnboardingClaimTokenFromCookieHeader, isOnboardingClaimValidAsync } from "@homarr/api/onboarding-claim";
|
||||
import { normalizeOnboardingStep } from "@homarr/api/onboarding-step";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { groups } from "@homarr/db/schema";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { env as dockerEnv } from "@homarr/docker/env";
|
||||
import { OnboardingStudio } from "@homarr/onboarding";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { onboardingContentWidth, onboardingMaxWidth } from "./_constants";
|
||||
import { BackToStart } from "./_steps/back";
|
||||
import { InitFinish } from "./_steps/finish/init-finish";
|
||||
import { InitGroup } from "./_steps/group/init-group";
|
||||
import { InitImport } from "./_steps/import/init-import";
|
||||
import { InitIntegrations } from "./_steps/integrations/init-integrations";
|
||||
import { InitSettings } from "./_steps/settings/init-settings";
|
||||
import { InitStart } from "./_steps/start/init-start";
|
||||
import { InitUser } from "./_steps/user/init-user";
|
||||
|
||||
const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Element>)> = {
|
||||
start: InitStart,
|
||||
import: InitImport,
|
||||
user: InitUser,
|
||||
group: InitGroup,
|
||||
settings: InitSettings,
|
||||
integrations: InitIntegrations,
|
||||
finish: InitFinish,
|
||||
};
|
||||
|
||||
const getCurrentOnboardingStepAsync = async () => {
|
||||
const value = await db.query.onboarding.findFirst();
|
||||
if (!value) return { current: "start" as const, previous: null };
|
||||
|
||||
return { current: value.step, previous: value.previousStep };
|
||||
};
|
||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
||||
import { env } from "~/env";
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import { AssistantConfiguration } from "../manage/assistant/_components/assistant-configuration";
|
||||
|
||||
export default async function InitPage() {
|
||||
const t = await getScopedI18n("init.step");
|
||||
const currentStep = await getCurrentOnboardingStepAsync();
|
||||
|
||||
const CurrentComponent = stepComponents[currentStep.current];
|
||||
const [state, session, requestHeaders, firstUser] = await Promise.all([
|
||||
db.query.onboarding.findFirst(),
|
||||
auth(),
|
||||
headers(),
|
||||
db.query.users.findFirst({ columns: { id: true } }),
|
||||
]);
|
||||
const canConfigurePrivileged = session?.user.permissions.includes("admin") ?? false;
|
||||
const onboardingClaim = getOnboardingClaimTokenFromCookieHeader(requestHeaders.get("cookie"));
|
||||
const canReadSetupContext =
|
||||
canConfigurePrivileged || (!firstUser && (await isOnboardingClaimValidAsync(db, onboardingClaim)));
|
||||
const [defaultGroup, availableBoards] = canReadSetupContext
|
||||
? await Promise.all([
|
||||
db.query.groups.findFirst({
|
||||
where: eq(groups.name, everyoneGroup),
|
||||
columns: { homeBoardId: true },
|
||||
}),
|
||||
db.query.boards.findMany({ columns: { id: true, name: true, primaryColor: true, secondaryColor: true } }),
|
||||
])
|
||||
: [undefined, []];
|
||||
const homeBoardId = defaultGroup?.homeBoardId;
|
||||
const homeBoard = availableBoards.find((board) => board.id === homeBoardId);
|
||||
const initialBoard = homeBoard ?? (availableBoards.length === 1 ? availableBoards[0] : null);
|
||||
const currentStep = normalizeOnboardingStep(state?.step);
|
||||
const databaseDriver =
|
||||
dbEnv.DRIVER === "better-sqlite3" ? "sqlite" : dbEnv.DRIVER === "mysql2" ? "mysql" : "postgresql";
|
||||
const { workshopApiUrl, workshopWebUrl } = canReadSetupContext
|
||||
? resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: env.WORKSHOP_API_URL,
|
||||
workshopWebUrl: env.WORKSHOP_WEB_URL,
|
||||
})
|
||||
: { workshopApiUrl: "", workshopWebUrl: "" };
|
||||
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
|
||||
|
||||
return (
|
||||
<Box mih="100dvh">
|
||||
<Center>
|
||||
<Stack align="center" mt="xl" w={onboardingContentWidth} maw={onboardingMaxWidth}>
|
||||
<HomarrLogoWithTitle size="lg" />
|
||||
<Stack gap={6} align="center">
|
||||
<Title order={3} fw={400} ta="center">
|
||||
{t(`${currentStep.current}.title`)}
|
||||
</Title>
|
||||
<Text size="sm" c="gray.5" ta="center">
|
||||
{t(`${currentStep.current}.subtitle`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<CurrentLanguageCombobox width="100%" />
|
||||
<CurrentColorSchemeCombobox w="100%" />
|
||||
{CurrentComponent && <CurrentComponent />}
|
||||
{currentStep.previous === "start" && <BackToStart />}
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
<OnboardingStudio
|
||||
environment={{
|
||||
version: getPackageVersion(),
|
||||
currentStep,
|
||||
databaseDriver,
|
||||
externalAuthEnabled: isProviderEnabled("ldap") || isProviderEnabled("oidc"),
|
||||
dockerConfigured: canReadSetupContext && dockerEnv.ENABLE_DOCKER,
|
||||
kubernetesConfigured: canReadSetupContext && dockerEnv.ENABLE_KUBERNETES,
|
||||
workshopApiUrl,
|
||||
workshopUrl: workshopWebUrl,
|
||||
serverOrigin: baseUrl,
|
||||
mcpEndpoint: `${baseUrl}/api/mcp`,
|
||||
canConfigurePrivileged,
|
||||
hasUsers: Boolean(firstUser),
|
||||
initialBoard: initialBoard ?? null,
|
||||
availableBoards: availableBoards.map(({ id, name }) => ({ id, name })),
|
||||
}}
|
||||
sqliteRestore={databaseDriver === "sqlite" ? <DatabaseRestoreFlow variant="standalone" /> : undefined}
|
||||
assistantConfiguration={canConfigurePrivileged ? <AssistantConfiguration /> : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import "@gfazioli/mantine-onboarding-tour/styles.css";
|
||||
import "@homarr/notifications/styles.css";
|
||||
import "@homarr/spotlight/styles.css";
|
||||
import "@homarr/ui/styles.css";
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
import "mantine-datatable/styles.css";
|
||||
import "~/styles/color-scheme.scss";
|
||||
import "~/styles/scroll-area.scss";
|
||||
@@ -173,8 +174,12 @@ export default async function Layout(props: {
|
||||
>
|
||||
<head>
|
||||
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
|
||||
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
|
||||
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
|
||||
{session ? (
|
||||
<>
|
||||
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
|
||||
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
|
||||
</>
|
||||
) : null}
|
||||
<SearchEngineOptimization />
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
|
||||
@@ -21,26 +21,4 @@
|
||||
|
||||
.scrollContainer {
|
||||
height: 100%;
|
||||
transform: rotateZ(10deg);
|
||||
}
|
||||
|
||||
@keyframes scrolling {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-50.8%);
|
||||
}
|
||||
}
|
||||
|
||||
.scrollAnimationContainer {
|
||||
animation: scrolling;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scrollAnimationContainer {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,10 @@
|
||||
import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core";
|
||||
import { Box, Group, Image, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { splitToNChunks } from "@homarr/common";
|
||||
import { integrationDefs } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { IntegrationMarquee } from "@homarr/ui";
|
||||
|
||||
import classes from "./hero-banner.module.css";
|
||||
|
||||
const icons = Object.values(integrationDefs)
|
||||
.filter((int) => int.name !== "Mock")
|
||||
.map((int) => int.iconUrl);
|
||||
|
||||
const countIconGroups = 3;
|
||||
const animationDurationInSeconds = icons.length;
|
||||
const arrayInChunks = splitToNChunks(icons, countIconGroups);
|
||||
const gridSpan = 12 / countIconGroups;
|
||||
|
||||
export const HeroBanner = async () => {
|
||||
const t = await getScopedI18n("management.page.home");
|
||||
|
||||
@@ -30,41 +20,7 @@ export const HeroBanner = async () => {
|
||||
</Group>
|
||||
</Stack>
|
||||
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
||||
<Grid>
|
||||
{Array(countIconGroups)
|
||||
.fill(0)
|
||||
.map((_, columnIndex) => (
|
||||
<GridCol key={`grid-column-${columnIndex}`} span={gridSpan}>
|
||||
<Stack
|
||||
className={classes.scrollAnimationContainer}
|
||||
style={{
|
||||
animationDuration: `${animationDurationInSeconds - columnIndex}s`,
|
||||
}}
|
||||
>
|
||||
{arrayInChunks[columnIndex]?.map((icon, index) => (
|
||||
<Image
|
||||
key={`grid-column-${columnIndex}-scroll-1-${index}`}
|
||||
src={icon}
|
||||
fit={"contain"}
|
||||
w={50}
|
||||
h={50}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* This is used for making the animation seem seamless */}
|
||||
{arrayInChunks[columnIndex]?.map((icon, index) => (
|
||||
<Image
|
||||
key={`grid-column-${columnIndex}-scroll-2-${index}`}
|
||||
src={icon}
|
||||
fit={"contain"}
|
||||
w={50}
|
||||
h={50}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
<IntegrationMarquee />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ interface AppDeleteButtonProps {
|
||||
|
||||
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
const t = useScopedI18n("app.page.delete");
|
||||
const tList = useScopedI18n("app.page.list.action");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate, isPending } = clientApi.app.delete.useMutation();
|
||||
|
||||
@@ -50,7 +51,14 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
}, [app, mutate, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
|
||||
<ActionIcon
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={onClick}
|
||||
aria-label={tList("delete", { name: app.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
@@ -16,10 +16,7 @@ export default async function AppEditPage(props: AppEditPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (
|
||||
!session?.user.permissions.includes("board-modify-all") ||
|
||||
!session?.user.permissions.includes("app-modify-all")
|
||||
) {
|
||||
if (!session?.user.permissions.includes("app-modify-all")) {
|
||||
notFound();
|
||||
}
|
||||
const app = await api.app.byId({ id: params.id });
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
export default async function AppNewPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
|
||||
if (!session?.user.permissions.includes("app-create")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import { Fragment } from "react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
|
||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getSafeAppHref } from "@homarr/common";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
pageSize: z
|
||||
.string()
|
||||
.regex(/^[1-9]\d*$/u)
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
.catch(10),
|
||||
page: z
|
||||
.string()
|
||||
.regex(/^[1-9]\d*$/u)
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
.catch(1),
|
||||
});
|
||||
|
||||
interface AppsPageProps {
|
||||
@@ -29,24 +40,38 @@ interface AppsPageProps {
|
||||
|
||||
export default async function AppsPage(props: AppsPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
if (!session.user.permissions.includes("board-modify-all")) {
|
||||
notFound();
|
||||
}
|
||||
if (!session) redirect("/auth/login");
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
|
||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||
const t = await getScopedI18n("app");
|
||||
|
||||
const canCreate = session.user.permissions.includes("app-create");
|
||||
const canModify = session.user.permissions.includes("app-modify-all");
|
||||
const canDelete = session.user.permissions.includes("app-full-all");
|
||||
const hasSearch = Boolean(searchParams.search?.trim());
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
const emptyState = hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
|
||||
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/apps" }}
|
||||
/>
|
||||
) : (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.title")}
|
||||
description={t("page.list.noResults.description")}
|
||||
action={{ label: t("page.list.noResults.action"), href: "/manage/apps/new", hidden: !canCreate }}
|
||||
/>
|
||||
);
|
||||
|
||||
const page = (
|
||||
<ManageCollectionPage
|
||||
title={t("page.list.title")}
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={apps.length}
|
||||
emptyState={emptyState}
|
||||
primaryAction={
|
||||
canCreate ? (
|
||||
<TourTarget id="manage-apps-create">
|
||||
@@ -56,101 +81,100 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
||||
toolbar={
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
totalCount > searchParams.pageSize ? (
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{apps.length === 0 && <AppNoResults />}
|
||||
{apps.length > 0 && (
|
||||
<TourTarget id="manage-apps-list">
|
||||
<Stack gap="sm">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</Stack>
|
||||
</TourTarget>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
{apps.map((app) => (
|
||||
<AppItem
|
||||
key={app.id}
|
||||
app={app}
|
||||
canModify={canModify}
|
||||
canDelete={canDelete}
|
||||
editLabel={t("page.list.action.edit", { name: app.name })}
|
||||
/>
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
);
|
||||
|
||||
return apps.length > 0 ? <TourTarget id="manage-apps-list">{page}</TourTarget> : page;
|
||||
}
|
||||
|
||||
interface AppCardProps {
|
||||
app: RouterOutputs["app"]["all"][number];
|
||||
interface AppItemProps {
|
||||
app: RouterOutputs["app"]["getPaginated"]["items"][number];
|
||||
canModify: boolean;
|
||||
canDelete: boolean;
|
||||
editLabel: string;
|
||||
}
|
||||
|
||||
const AppCard = async ({ app }: AppCardProps) => {
|
||||
const t = await getScopedI18n("app");
|
||||
const session = await auth();
|
||||
const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
|
||||
const descriptionLines = app.description?.split("\n");
|
||||
const safeHref = getSafeAppHref(app.href);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
|
||||
<Avatar
|
||||
<ManageCollectionItem
|
||||
leading={<Avatar size={36} src={app.iconUrl} radius="sm" styles={{ image: { objectFit: "contain" } }} alt="" />}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1}>
|
||||
{app.name}
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
descriptionLines ? (
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{descriptionLines.map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
{index < descriptionLines.length - 1 && <br />}
|
||||
</Fragment>
|
||||
))}
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
metadata={
|
||||
safeHref ? (
|
||||
<Anchor
|
||||
href={safeHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
lineClamp={1}
|
||||
size="sm"
|
||||
src={app.iconUrl}
|
||||
radius={0}
|
||||
styles={{
|
||||
image: {
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: "1" }}>
|
||||
<Text fw={500} lineClamp={1}>
|
||||
{app.name}
|
||||
</Text>
|
||||
{app.description && (
|
||||
<Text size="sm" c="gray.6" lineClamp={4}>
|
||||
{app.description.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
{app.href && (
|
||||
<Anchor href={app.href} lineClamp={1} size="sm" style={{ wordBreak: "break-all" }}>
|
||||
{app.href}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group>
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{app.href}
|
||||
</Anchor>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canModify || canDelete ? (
|
||||
<ActionIconGroup>
|
||||
{session?.user.permissions.includes("app-modify-all") && (
|
||||
{canModify && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title")}
|
||||
size={44}
|
||||
aria-label={editLabel}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
|
||||
{canDelete && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const AppNoResults = async () => {
|
||||
const t = await getI18n();
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("app.page.list.noResults.title")}
|
||||
action={{
|
||||
label: t("app.page.list.noResults.action"),
|
||||
href: "/manage/apps/new",
|
||||
hidden: !session?.user.permissions.includes("app-create"),
|
||||
}}
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+9
-4
@@ -144,8 +144,8 @@ export const AssistantConfiguration = () => {
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
|
||||
const [provider, setProvider] = useState<AssistantProvider>("openrouter");
|
||||
const [baseUrl, setBaseUrl] = useState<string>(assistantProviderPresets.openrouter.baseUrl);
|
||||
const [provider, setProvider] = useState<AssistantProvider>("homarr");
|
||||
const [baseUrl, setBaseUrl] = useState<string>(assistantProviderPresets.homarr.baseUrl);
|
||||
const [modelDiscoveryPath, setModelDiscoveryPath] = useState<string>("/models");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [credentialFlow, setCredentialFlow] = useState<CredentialFlow>("idle");
|
||||
@@ -157,7 +157,7 @@ export const AssistantConfiguration = () => {
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const configuredProvider = configuration?.provider ?? "openrouter";
|
||||
const configuredProvider = configuration?.provider ?? "homarr";
|
||||
setProvider(configuredProvider);
|
||||
setBaseUrl(
|
||||
configuredProvider === "homarr"
|
||||
@@ -203,7 +203,7 @@ export const AssistantConfiguration = () => {
|
||||
);
|
||||
const providerOptions = useMemo(
|
||||
() =>
|
||||
(["hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => {
|
||||
(["free", "hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => {
|
||||
const items = assistantProviderIds
|
||||
.filter((providerId) => assistantProviderPresets[providerId].category === category)
|
||||
.map((providerId) => ({
|
||||
@@ -441,6 +441,11 @@ export const AssistantConfiguration = () => {
|
||||
{option.label}
|
||||
</Text>
|
||||
</Group>
|
||||
{option.value === "homarr" && (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{t("provider.options.homarr.description")}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -22,7 +22,7 @@ const iconProps = {
|
||||
|
||||
interface BoardCardMenuDropdownProps {
|
||||
board: Pick<
|
||||
RouterOutputs["board"]["getAllBoards"][number],
|
||||
RouterOutputs["board"]["getManageOverview"][number],
|
||||
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -1,38 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Menu } from "@mantine/core";
|
||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconCategoryPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||
import { AddBoardModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n();
|
||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||
const { openModal } = useModalAction(AddBoardModal);
|
||||
|
||||
return (
|
||||
<ManageMobilePrimaryAction>
|
||||
<Button.Group>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
</Button>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<Button px="xs" ms={1}>
|
||||
<IconChevronDown size="1rem" />
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||
{t("board.action.oldImport.label")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Button.Group>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
</Button>
|
||||
</ManageMobilePrimaryAction>
|
||||
);
|
||||
};
|
||||
@@ -24,10 +24,11 @@ import {
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
@@ -37,7 +38,7 @@ import { CreateBoardButton } from "./_components/create-board-button";
|
||||
export default async function ManageBoardsPage() {
|
||||
const t = await getScopedI18n("management.page.board");
|
||||
const session = await auth();
|
||||
const boards = await api.board.getAllBoards();
|
||||
const boards = await api.board.getManageOverview();
|
||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||
|
||||
return (
|
||||
@@ -52,13 +53,32 @@ export default async function ManageBoardsPage() {
|
||||
}
|
||||
floatingPrimaryAction={canCreateBoards}
|
||||
>
|
||||
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
||||
{boards.length === 0 && (
|
||||
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
|
||||
)}
|
||||
{boards.length > 0 && (
|
||||
<TourTarget id="manage-boards-list">
|
||||
<Grid>
|
||||
{boards.map((board) => (
|
||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||
<BoardCard board={board} />
|
||||
<BoardCard
|
||||
board={board}
|
||||
isMenuVisible={constructBoardPermissions(board, session).hasChangeAccess}
|
||||
labels={{
|
||||
visibility: t(`visibility.${board.isPublic ? "public" : "private"}`),
|
||||
preview: t("preview.label", {
|
||||
name: board.name,
|
||||
count: String(board.preview?.items.length ?? 0),
|
||||
}),
|
||||
unknownCreator: t("preview.unknownCreator"),
|
||||
home: t("action.setHomeBoard.badge.label"),
|
||||
homeTooltip: t("action.setHomeBoard.badge.tooltip"),
|
||||
mobileHome: t("action.setMobileHomeBoard.badge.label"),
|
||||
mobileHomeTooltip: t("action.setMobileHomeBoard.badge.tooltip"),
|
||||
open: t("action.open.label"),
|
||||
settings: t("action.settings.label"),
|
||||
}}
|
||||
/>
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
@@ -69,48 +89,61 @@ export default async function ManageBoardsPage() {
|
||||
}
|
||||
|
||||
interface BoardCardProps {
|
||||
board: RouterOutputs["board"]["getAllBoards"][number];
|
||||
board: RouterOutputs["board"]["getManageOverview"][number];
|
||||
isMenuVisible: boolean;
|
||||
labels: {
|
||||
visibility: string;
|
||||
preview: string;
|
||||
unknownCreator: string;
|
||||
home: string;
|
||||
homeTooltip: string;
|
||||
mobileHome: string;
|
||||
mobileHomeTooltip: string;
|
||||
open: string;
|
||||
settings: string;
|
||||
};
|
||||
}
|
||||
|
||||
const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
const t = await getScopedI18n("management.page.board");
|
||||
const { hasChangeAccess: isMenuVisible } = await getBoardPermissionsAsync(board);
|
||||
const visibility = board.isPublic ? "public" : "private";
|
||||
const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
|
||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card padding={0} withBorder>
|
||||
<CardSection withBorder>
|
||||
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
|
||||
</CardSection>
|
||||
<CardSection p="sm" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
<Tooltip label={t(`visibility.${visibility}`)}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
|
||||
<Tooltip label={labels.visibility}>
|
||||
<VisibilityIcon size={20} stroke={1.5} />
|
||||
</Tooltip>
|
||||
<Text fw={700}>{board.name}</Text>
|
||||
<Text fw={700} truncate>
|
||||
{board.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
|
||||
{board.isHome && (
|
||||
<Tooltip label={t("action.setHomeBoard.badge.tooltip")}>
|
||||
<Tooltip label={labels.homeTooltip}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
|
||||
{t("action.setHomeBoard.badge.label")}
|
||||
{labels.home}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.isMobileHome && (
|
||||
<Tooltip label={t("action.setMobileHomeBoard.badge.tooltip")}>
|
||||
<Tooltip label={labels.mobileHomeTooltip}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
|
||||
{t("action.setMobileHomeBoard.badge.label")}
|
||||
{labels.mobileHome}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.creator && (
|
||||
<Group gap="xs">
|
||||
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
|
||||
<UserAvatar user={board.creator} size="sm" />
|
||||
<Text>{board.creator.name}</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -123,9 +156,10 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
variant="default"
|
||||
h={44}
|
||||
fullWidth
|
||||
>
|
||||
{t("action.open.label")}
|
||||
{labels.open}
|
||||
</Button>
|
||||
{isMenuVisible && (
|
||||
<Menu position="bottom-end">
|
||||
@@ -133,8 +167,8 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
<ActionIcon
|
||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("action.settings.label")}
|
||||
size={44}
|
||||
aria-label={labels.settings}
|
||||
>
|
||||
<IconDotsVertical size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
@@ -11,13 +10,12 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface DeleteIntegrationActionButtonProps {
|
||||
count: number;
|
||||
integration: { id: string; name: string };
|
||||
}
|
||||
|
||||
export const DeleteIntegrationActionButton = ({ count, integration }: DeleteIntegrationActionButtonProps) => {
|
||||
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
|
||||
const t = useScopedI18n("integration.page.delete");
|
||||
const router = useRouter();
|
||||
const tList = useScopedI18n("integration.page.list.action");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
|
||||
@@ -27,6 +25,7 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={() => {
|
||||
openConfirmModal({
|
||||
title: t("title"),
|
||||
@@ -40,9 +39,6 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
if (count === 1) {
|
||||
router.replace("/manage/integrations");
|
||||
}
|
||||
void revalidatePathActionAsync("/manage/integrations");
|
||||
void utils.integration.invalidate();
|
||||
},
|
||||
@@ -57,7 +53,7 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
|
||||
},
|
||||
});
|
||||
}}
|
||||
aria-label={t("title")}
|
||||
aria-label={tList("delete", { name: integration.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -51,8 +51,6 @@ interface NewIntegrationFormProps {
|
||||
initialName?: string;
|
||||
onSuccess: () => void;
|
||||
onCancel?: () => void;
|
||||
onSkip?: () => void;
|
||||
isOnboarding?: boolean;
|
||||
}
|
||||
|
||||
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||
@@ -63,20 +61,12 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||
}),
|
||||
);
|
||||
|
||||
export const NewIntegrationForm = ({
|
||||
kind,
|
||||
initialUrl,
|
||||
initialName,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
onSkip,
|
||||
isOnboarding = false,
|
||||
}: NewIntegrationFormProps) => {
|
||||
export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, onCancel }: NewIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const secretKinds = getAllSecretKindOptions(kind);
|
||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||
const { data: session } = useSession();
|
||||
const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false);
|
||||
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
||||
|
||||
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
|
||||
if (hasUrlSecret) {
|
||||
@@ -90,9 +80,9 @@ export const NewIntegrationForm = ({
|
||||
kind,
|
||||
value: "",
|
||||
})),
|
||||
attemptSearchEngineCreation: !isOnboarding,
|
||||
hasApp: !isOnboarding,
|
||||
appHref: isOnboarding ? null : url,
|
||||
attemptSearchEngineCreation: true,
|
||||
hasApp: true,
|
||||
appHref: url,
|
||||
appId: null,
|
||||
},
|
||||
});
|
||||
@@ -104,13 +94,6 @@ export const NewIntegrationForm = ({
|
||||
await utils.integration.invalidate();
|
||||
},
|
||||
});
|
||||
const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } =
|
||||
clientApi.onboard.createIntegration.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.integration.invalidate();
|
||||
},
|
||||
});
|
||||
const isPending = isCreatePending || isOnboardingCreatePending;
|
||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||
|
||||
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
|
||||
@@ -143,14 +126,6 @@ export const NewIntegrationForm = ({
|
||||
});
|
||||
};
|
||||
|
||||
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 app = hasApp
|
||||
@@ -206,7 +181,7 @@ export const NewIntegrationForm = ({
|
||||
|
||||
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||
|
||||
{!isOnboarding && supportsSearchEngine && (
|
||||
{supportsSearchEngine && (
|
||||
<Checkbox
|
||||
label={t("integration.field.attemptSearchEngineCreation.label")}
|
||||
description={t("integration.field.attemptSearchEngineCreation.description", {
|
||||
@@ -216,7 +191,7 @@ export const NewIntegrationForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />}
|
||||
<AppForm form={form} canCreateApps={canCreateApps} />
|
||||
|
||||
<Group justify="end" align="center">
|
||||
{onCancel ? (
|
||||
@@ -228,12 +203,7 @@ export const NewIntegrationForm = ({
|
||||
{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={isCreatePending}>
|
||||
{t("integration.testConnection.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
.root {
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.item {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
transition: background-color 150ms ease;
|
||||
overflow: hidden;
|
||||
|
||||
&[data-first="true"] {
|
||||
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
|
||||
}
|
||||
|
||||
&[data-last="true"] {
|
||||
border-radius: 0 0 var(--mantine-radius-lg) var(--mantine-radius-lg);
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
z-index: 1;
|
||||
background-color: var(--mantine-color-body);
|
||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
&[data-rotate] {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,80 @@
|
||||
import { Fragment } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
AccordionControl,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Anchor,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Badge, Text } from "@mantine/core";
|
||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getSafeApplicationUrl } from "@homarr/common";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||
import { IntegrationAvatar, Link, SearchInput } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
import classes from "./page.module.css";
|
||||
|
||||
const searchParamsSchema = z.object({ search: z.string().optional() });
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
searchParams: Promise<{
|
||||
tab?: IntegrationKind;
|
||||
}>;
|
||||
searchParams: Promise<z.infer<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
if (!session) redirect("/auth/login");
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const integrations = await api.integration.all();
|
||||
const t = await getScopedI18n("integration");
|
||||
const canCreate = session.user.permissions.includes("integration-create");
|
||||
const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all");
|
||||
const query = searchParams.search?.trim().toLocaleLowerCase() ?? "";
|
||||
const filteredIntegrations = integrations
|
||||
.filter((integration) => {
|
||||
if (!query) return true;
|
||||
return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) =>
|
||||
value.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
getIntegrationName(integrationA.kind).localeCompare(getIntegrationName(integrationB.kind)) ||
|
||||
integrationA.name.localeCompare(integrationB.name),
|
||||
);
|
||||
const hasSearch = query.length > 0;
|
||||
|
||||
const canCreateIntegrations = session.user.permissions.includes("integration-create");
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
const page = (
|
||||
<ManageCollectionPage
|
||||
title={t("page.list.title")}
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={filteredIntegrations.length}
|
||||
emptyState={
|
||||
hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
|
||||
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/integrations" }}
|
||||
/>
|
||||
) : (
|
||||
<NoResults
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.title")}
|
||||
description={t("page.list.noResults.description")}
|
||||
action={{
|
||||
label: t("page.list.noResults.action"),
|
||||
href: "/manage/integrations/new",
|
||||
hidden: !canCreate,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
primaryAction={
|
||||
canCreateIntegrations ? (
|
||||
canCreate ? (
|
||||
<TourTarget id="manage-integrations-create">
|
||||
<MobileAffixButton component={Link} href="/manage/integrations/new">
|
||||
{t("action.create")}
|
||||
@@ -68,128 +82,83 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreateIntegrations}
|
||||
>
|
||||
<TourTarget id="manage-integrations-list">
|
||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||
</TourTarget>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntegrationListProps {
|
||||
integrations: RouterOutputs["integration"]["all"];
|
||||
activeTab?: IntegrationKind;
|
||||
}
|
||||
|
||||
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
|
||||
const t = await getScopedI18n("integration");
|
||||
const session = await auth();
|
||||
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
|
||||
if (integrations.length === 0) {
|
||||
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
|
||||
}
|
||||
|
||||
const groupedIntegrations = integrations.reduce(
|
||||
(acc, integration) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!acc[integration.kind]) {
|
||||
acc[integration.kind] = [];
|
||||
toolbar={
|
||||
<SearchInput
|
||||
placeholder={`${t("page.list.search")}...`}
|
||||
ariaLabel={t("page.list.search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
}
|
||||
|
||||
acc[integration.kind].push(integration);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{filteredIntegrations.map((integration) => (
|
||||
<IntegrationItem
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
canManage={hasGlobalFullAccess || integration.permissions.hasFullAccess}
|
||||
/>
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
);
|
||||
|
||||
const entries = objectEntries(groupedIntegrations);
|
||||
return filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
|
||||
}
|
||||
|
||||
interface IntegrationItemProps {
|
||||
integration: RouterOutputs["integration"]["all"][number];
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps) => {
|
||||
const t = await getScopedI18n("integration");
|
||||
const kindName = getIntegrationName(integration.kind);
|
||||
const safeUrl = getSafeApplicationUrl(integration.url);
|
||||
|
||||
return (
|
||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||
{entries.map(([kind, kindIntegrations], index) => (
|
||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={kindIntegrations.length} />
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Table visibleFrom="md">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("field.name.label")}</TableTh>
|
||||
<TableTh>{t("field.url.label")}</TableTh>
|
||||
<TableTh />
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{kindIntegrations.map((integration) => (
|
||||
<TableTr key={integration.id}>
|
||||
<TableTd>{integration.name}</TableTd>
|
||||
<TableTd>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group justify="end">
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Stack gap="xs" hiddenFrom="md">
|
||||
{kindIntegrations.map((integration, integrationIndex) => (
|
||||
<Fragment key={integration.id}>
|
||||
{integrationIndex !== 0 && <Divider />}
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text>{integration.name}</Text>
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</ActiveTabAccordion>
|
||||
<ManageCollectionItem
|
||||
leading={<IntegrationAvatar kind={integration.kind} size="md" radius="sm" />}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1}>
|
||||
{integration.name}
|
||||
</Text>
|
||||
}
|
||||
badges={
|
||||
<Badge size="sm" variant="light">
|
||||
{kindName}
|
||||
</Badge>
|
||||
}
|
||||
metadata={
|
||||
safeUrl ? (
|
||||
<Anchor
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
lineClamp={1}
|
||||
size="sm"
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canManage ? (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: integration.name })}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} />
|
||||
</ActionIconGroup>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -71,7 +71,11 @@ export default async function MediaListPage(props: MediaListPageProps) {
|
||||
}
|
||||
toolbar={
|
||||
<Group>
|
||||
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||
<SearchInput
|
||||
placeholder={`${t("media.search")}...`}
|
||||
ariaLabel={t("media.search")}
|
||||
defaultValue={searchParams.search}
|
||||
/>
|
||||
{session.user.permissions.includes("media-view-all") && (
|
||||
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface SearchEngineDeleteButtonProps {
|
||||
|
||||
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
|
||||
const t = useScopedI18n("search.engine.page.delete");
|
||||
const tList = useScopedI18n("search.engine.page.list.action");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
|
||||
|
||||
@@ -50,7 +51,14 @@ export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteBut
|
||||
}, [searchEngine, mutate, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
|
||||
<ActionIcon
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={onClick}
|
||||
aria-label={tList("delete", { name: searchEngine.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Badge, Text } from "@mantine/core";
|
||||
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getSafeApplicationUrl } from "@homarr/common";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
||||
@@ -27,123 +28,145 @@ interface SearchEnginesPageProps {
|
||||
|
||||
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
if (!session) redirect("/auth/login");
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
|
||||
|
||||
const tEngine = await getScopedI18n("search.engine");
|
||||
|
||||
const t = await getScopedI18n("search.engine");
|
||||
const canCreate = session.user.permissions.includes("search-engine-create");
|
||||
const canModify = session.user.permissions.includes("search-engine-modify-all");
|
||||
const canDelete = session.user.permissions.includes("search-engine-full-all");
|
||||
const hasSearch = Boolean(searchParams.search?.trim());
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={tEngine("page.list.title")}
|
||||
<ManageCollectionPage
|
||||
title={t("page.list.title")}
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={searchEngines.length}
|
||||
emptyState={
|
||||
hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconSearch}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
|
||||
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/search-engines" }}
|
||||
/>
|
||||
) : (
|
||||
<NoResults
|
||||
icon={IconSearch}
|
||||
title={t("page.list.noResults.title")}
|
||||
description={t("page.list.noResults.description")}
|
||||
action={{
|
||||
label: t("page.list.noResults.action"),
|
||||
href: "/manage/search-engines/new",
|
||||
hidden: !canCreate,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
primaryAction={
|
||||
canCreate ? (
|
||||
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||
{tEngine("page.create.title")}
|
||||
{t("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
||||
toolbar={
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
totalCount > searchParams.pageSize ? (
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
||||
{searchEngines.length > 0 && (
|
||||
<Stack gap="sm">
|
||||
{searchEngines.map((searchEngine) => (
|
||||
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
{searchEngines.map((searchEngine) => (
|
||||
<SearchEngineItem
|
||||
key={searchEngine.id}
|
||||
searchEngine={searchEngine}
|
||||
canModify={canModify}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchEngineCardProps {
|
||||
interface SearchEngineItemProps {
|
||||
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
|
||||
canModify: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
||||
const SearchEngineItem = async ({ searchEngine, canModify, canDelete }: SearchEngineItemProps) => {
|
||||
const t = await getScopedI18n("search.engine");
|
||||
const session = await auth();
|
||||
const isIntegration = searchEngine.type === "fromIntegration";
|
||||
const previewUrl =
|
||||
searchEngine.type === "generic" && searchEngine.urlTemplate !== null
|
||||
? getSafeApplicationUrl(searchEngine.urlTemplate.replace("%s", "test"))
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Avatar
|
||||
<ManageCollectionItem
|
||||
leading={
|
||||
<Avatar size={36} src={searchEngine.iconUrl} radius="sm" styles={{ image: { objectFit: "contain" } }} alt="" />
|
||||
}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1}>
|
||||
{searchEngine.name}
|
||||
</Text>
|
||||
}
|
||||
badges={
|
||||
<Badge size="sm" variant="light" color={isIntegration ? "blue" : "gray"}>
|
||||
{t(`page.list.type.${searchEngine.type}`)}
|
||||
</Badge>
|
||||
}
|
||||
description={
|
||||
searchEngine.description ? (
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{searchEngine.description}
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
metadata={
|
||||
previewUrl ? (
|
||||
<Anchor
|
||||
href={previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
lineClamp={1}
|
||||
size="sm"
|
||||
src={searchEngine.iconUrl}
|
||||
radius={0}
|
||||
styles={{
|
||||
image: {
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack gap={0}>
|
||||
<Text fw={500} lineClamp={1}>
|
||||
{searchEngine.name}
|
||||
</Text>
|
||||
{searchEngine.description && (
|
||||
<Text size="sm" c="gray.6" lineClamp={4}>
|
||||
{searchEngine.description}
|
||||
</Text>
|
||||
)}
|
||||
{searchEngine.type === "generic" && searchEngine.urlTemplate !== null && (
|
||||
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
|
||||
{searchEngine.urlTemplate}
|
||||
</Anchor>
|
||||
)}
|
||||
{searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("page.list.interactive")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group>
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{searchEngine.urlTemplate}
|
||||
</Anchor>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canModify || canDelete ? (
|
||||
<ActionIconGroup>
|
||||
{session?.user.permissions.includes("search-engine-modify-all") && (
|
||||
{canModify && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/search-engines/edit/${searchEngine.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title")}
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: searchEngine.name })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{session?.user.permissions.includes("search-engine-full-all") && (
|
||||
<SearchEngineDeleteButton searchEngine={searchEngine} />
|
||||
)}
|
||||
{canDelete && <SearchEngineDeleteButton searchEngine={searchEngine} />}
|
||||
</ActionIconGroup>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchEngineNoResults = async () => {
|
||||
const t = await getI18n();
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<NoResults
|
||||
icon={IconSearch}
|
||||
title={t("search.engine.page.list.noResults.title")}
|
||||
action={{
|
||||
label: t("search.engine.page.list.noResults.action"),
|
||||
href: "/manage/search-engines/new",
|
||||
hidden: !session?.user.permissions.includes("search-engine-create"),
|
||||
}}
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -66,7 +66,11 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<SearchInput placeholder={`${tMembers("search")}...`} defaultValue={searchParams.search} />
|
||||
<SearchInput
|
||||
placeholder={`${tMembers("search")}...`}
|
||||
ariaLabel={tMembers("search")}
|
||||
defaultValue={searchParams.search}
|
||||
/>
|
||||
{canManageMembers && !isReserved && (
|
||||
<AddGroupMember
|
||||
groupId={group.id}
|
||||
|
||||
@@ -8,6 +8,11 @@ import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
import {
|
||||
getOnboardingClaimTokenFromCookieHeader,
|
||||
isClaimOnlyOnboardingAccessAllowedAsync,
|
||||
} from "@homarr/api/onboarding-claim";
|
||||
import { normalizeOnboardingStep } from "@homarr/api/onboarding-step";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
@@ -19,6 +24,101 @@ 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 MEBIBYTE = 1024 * 1024;
|
||||
const MAX_COMPRESSED_BACKUP_BYTES = 256 * MEBIBYTE;
|
||||
const MAX_MULTIPART_REQUEST_BYTES = MAX_COMPRESSED_BACKUP_BYTES + MEBIBYTE;
|
||||
const MAX_UNCOMPRESSED_DATABASE_BYTES = 512 * MEBIBYTE;
|
||||
const MAX_UNCOMPRESSED_ARCHIVE_BYTES = MAX_UNCOMPRESSED_DATABASE_BYTES + 2 * MEBIBYTE;
|
||||
const MAX_METADATA_BYTES = MEBIBYTE;
|
||||
const MAX_ENCRYPTION_KEY_BYTES = 1024;
|
||||
const RESTART_DELAY_MS = 500;
|
||||
|
||||
let restoreInProgress = false;
|
||||
|
||||
class BackupTooLargeError extends Error {}
|
||||
|
||||
const readBoundedRequestBodyAsync = async (request: Request): Promise<Uint8Array<ArrayBuffer>> => {
|
||||
const declaredLength = request.headers.get("content-length");
|
||||
if (declaredLength !== null) {
|
||||
const parsedLength = Number(declaredLength);
|
||||
if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) {
|
||||
throw new Error("Invalid Content-Length header");
|
||||
}
|
||||
if (parsedLength > MAX_MULTIPART_REQUEST_BYTES) {
|
||||
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.body) return new Uint8Array();
|
||||
|
||||
const reader = request.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > MAX_MULTIPART_REQUEST_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const body = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
const parseBoundedFormDataAsync = async (request: Request): Promise<FormData> => {
|
||||
const contentType = request.headers.get("content-type");
|
||||
if (!contentType) throw new Error("Missing Content-Type header");
|
||||
|
||||
const body = await readBoundedRequestBodyAsync(request);
|
||||
return new Response(body, { headers: { "content-type": contentType } }).formData();
|
||||
};
|
||||
|
||||
const assertArchiveSizeLimits = (zip: AdmZip) => {
|
||||
const entries = zip.getEntries();
|
||||
let totalSize = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) continue;
|
||||
const entrySize = entry.header.size;
|
||||
if (!Number.isSafeInteger(entrySize) || entrySize < 0) {
|
||||
throw new Error(`Invalid uncompressed size for ${entry.entryName}`);
|
||||
}
|
||||
totalSize += entrySize;
|
||||
if (totalSize > MAX_UNCOMPRESSED_ARCHIVE_BYTES) {
|
||||
throw new BackupTooLargeError("Uncompressed backup archive exceeds the 514 MB limit");
|
||||
}
|
||||
}
|
||||
|
||||
const dbEntry = zip.getEntry("db.sqlite");
|
||||
if (dbEntry && dbEntry.header.size > MAX_UNCOMPRESSED_DATABASE_BYTES) {
|
||||
throw new BackupTooLargeError("Uncompressed SQLite database exceeds the 512 MB limit");
|
||||
}
|
||||
|
||||
const metadataEntry = zip.getEntry("metadata.json");
|
||||
if (metadataEntry && metadataEntry.header.size > MAX_METADATA_BYTES) {
|
||||
throw new BackupTooLargeError("Backup metadata exceeds the 1 MB limit");
|
||||
}
|
||||
|
||||
const encryptionKeyEntry = zip.getEntry("encryption-key.txt");
|
||||
if (encryptionKeyEntry && encryptionKeyEntry.header.size > MAX_ENCRYPTION_KEY_BYTES) {
|
||||
throw new BackupTooLargeError("Backup encryption key entry is too large");
|
||||
}
|
||||
};
|
||||
|
||||
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
|
||||
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
|
||||
@@ -72,16 +172,43 @@ const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex:
|
||||
transaction();
|
||||
};
|
||||
|
||||
const getHomeBoardName = (tempDb: InstanceType<typeof Database>): string | null => {
|
||||
const row = tempDb
|
||||
.prepare(
|
||||
`SELECT "name" FROM (
|
||||
SELECT "board"."name" AS "name", 0 AS "priority"
|
||||
FROM "group"
|
||||
INNER JOIN "board" ON "board"."id" = "group"."home_board_id"
|
||||
WHERE "group"."name" = 'everyone'
|
||||
UNION ALL
|
||||
SELECT "board"."name" AS "name", 1 AS "priority"
|
||||
FROM "user"
|
||||
INNER JOIN "board" ON "board"."id" = "user"."home_board_id"
|
||||
UNION ALL
|
||||
SELECT "board"."name" AS "name", 2 AS "priority"
|
||||
FROM "board"
|
||||
)
|
||||
ORDER BY "priority", "name"
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get() as { name: string } | undefined;
|
||||
return row?.name ?? null;
|
||||
};
|
||||
|
||||
const isOnboardingActiveAsync = async (): Promise<boolean> => {
|
||||
const onboardingRow = await db.query.onboarding.findFirst();
|
||||
if (!onboardingRow) return false;
|
||||
return onboardingRow.step === "start";
|
||||
return normalizeOnboardingStep(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());
|
||||
const hasOnboardingClaim = await isClaimOnlyOnboardingAccessAllowedAsync(
|
||||
db,
|
||||
getOnboardingClaimTokenFromCookieHeader(req.headers.get("cookie")),
|
||||
);
|
||||
const isOnboarding = !isAdmin && hasOnboardingClaim && (await isOnboardingActiveAsync());
|
||||
|
||||
if (!isAdmin && !isOnboarding) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
@@ -96,41 +223,62 @@ export async function POST(req: Request) {
|
||||
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");
|
||||
if (restoreInProgress) {
|
||||
return NextResponse.json({ error: "A database restore is already in progress" }, { status: 409 });
|
||||
}
|
||||
|
||||
restoreInProgress = true;
|
||||
let tempDirectory: string | null = null;
|
||||
let tempDb: InstanceType<typeof Database> | null = null;
|
||||
|
||||
try {
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await parseBoundedFormDataAsync(req);
|
||||
} catch (error) {
|
||||
if (error instanceof BackupTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
if (file.size > MAX_COMPRESSED_BACKUP_BYTES) {
|
||||
return NextResponse.json({ error: "Backup upload exceeds the 256 MB limit" }, { status: 413 });
|
||||
}
|
||||
|
||||
let zip: AdmZip;
|
||||
try {
|
||||
zip = new AdmZip(Buffer.from(await file.arrayBuffer()));
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
|
||||
}
|
||||
|
||||
const entries = zip.getEntries().map((entry) => entry.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 });
|
||||
}
|
||||
|
||||
try {
|
||||
assertArchiveSizeLimits(zip);
|
||||
} catch (error) {
|
||||
if (error instanceof BackupTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
tempDirectory = fs.mkdtempSync(path.join(path.dirname(dbPath), ".homarr-restore-"));
|
||||
const tempPath = path.join(tempDirectory, "db.sqlite");
|
||||
const dbEntry = zip.getEntry("db.sqlite");
|
||||
if (!dbEntry) {
|
||||
return NextResponse.json({ error: "Invalid backup: missing db.sqlite" }, { status: 400 });
|
||||
@@ -164,6 +312,8 @@ export async function POST(req: Request) {
|
||||
reEncryptSecrets(tempDb, importedKey);
|
||||
}
|
||||
|
||||
const homeBoardName = getHomeBoardName(tempDb);
|
||||
|
||||
tempDb.close();
|
||||
tempDb = null;
|
||||
|
||||
@@ -178,23 +328,34 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const restartTimer = setTimeout(() => {
|
||||
console.log("Database restored, restarting server...");
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
}, RESTART_DELAY_MS);
|
||||
restartTimer.unref();
|
||||
|
||||
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Database restored. Server is restarting...",
|
||||
restartAfterMs: RESTART_DELAY_MS,
|
||||
homeBoardName,
|
||||
});
|
||||
} 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)) {
|
||||
restoreInProgress = false;
|
||||
try {
|
||||
tempDb?.close();
|
||||
} catch (error) {
|
||||
console.error("[backup/import] Failed to close temporary database:", error);
|
||||
}
|
||||
if (tempDirectory) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
// temp file may have been renamed already
|
||||
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.error("[backup/import] Failed to remove temporary restore directory:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// @vitest-environment node
|
||||
|
||||
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, vi } from "vitest";
|
||||
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
|
||||
const routeMocks = vi.hoisted(() => ({
|
||||
auth: vi.fn(),
|
||||
claimAllowed: vi.fn(),
|
||||
onboardingFindFirst: vi.fn(),
|
||||
dbEnv: {
|
||||
DRIVER: "better-sqlite3",
|
||||
URL: "",
|
||||
},
|
||||
commonEnv: {
|
||||
SECRET_ENCRYPTION_KEY: "1".repeat(64),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/next", () => ({ auth: routeMocks.auth }));
|
||||
vi.mock("@homarr/auth/server", () => ({ isProviderEnabled: () => false }));
|
||||
vi.mock("@homarr/api/onboarding-claim", () => ({
|
||||
getOnboardingClaimTokenFromCookieHeader: () => "test-claim",
|
||||
isClaimOnlyOnboardingAccessAllowedAsync: routeMocks.claimAllowed,
|
||||
}));
|
||||
vi.mock("@homarr/common/env", () => ({ env: routeMocks.commonEnv }));
|
||||
vi.mock("@homarr/core/infrastructure/db/env", () => ({ dbEnv: routeMocks.dbEnv }));
|
||||
vi.mock("@homarr/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
onboarding: {
|
||||
findFirst: routeMocks.onboardingFindFirst,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../import/route";
|
||||
|
||||
const MEBIBYTE = 1024 * 1024;
|
||||
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
|
||||
|
||||
const createMigratedDatabase = (databasePath: string, boardId: string, boardName: string) => {
|
||||
const sqlite = new Database(databasePath);
|
||||
migrate(drizzle(sqlite, { casing: DB_CASING }), { migrationsFolder: MIGRATIONS_FOLDER });
|
||||
sqlite.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 (
|
||||
'${boardId}', '${boardName}', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0
|
||||
)
|
||||
`);
|
||||
sqlite.close();
|
||||
};
|
||||
|
||||
const createBackup = (directory: string, name: string, boardName: string) => {
|
||||
const sourcePath = path.join(directory, `${name}.sqlite`);
|
||||
createMigratedDatabase(sourcePath, `board-${name}`, boardName);
|
||||
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", fs.readFileSync(sourcePath));
|
||||
zip.addFile(
|
||||
"metadata.json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
dbDialect: "sqlite",
|
||||
encryptionKey: routeMocks.commonEnv.SECRET_ENCRYPTION_KEY,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return zip.toBuffer();
|
||||
};
|
||||
|
||||
const createImportRequest = (backup: Buffer) => {
|
||||
const formData = new FormData();
|
||||
formData.set("file", new File([Uint8Array.from(backup)], "homarr-backup.zip", { type: "application/zip" }));
|
||||
return new Request("http://homarr.test/api/backup/import", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
};
|
||||
|
||||
const readBoardNames = (databasePath: string) => {
|
||||
const sqlite = new Database(databasePath, { readonly: true });
|
||||
const rows = sqlite.prepare('SELECT "name" FROM "board" ORDER BY "name"').all() as { name: string }[];
|
||||
sqlite.close();
|
||||
return rows.map((row) => row.name);
|
||||
};
|
||||
|
||||
describe("POST /api/backup/import", () => {
|
||||
let temporaryDirectory: string;
|
||||
let activeDatabasePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-import-route-test-"));
|
||||
activeDatabasePath = path.join(temporaryDirectory, "db.sqlite");
|
||||
routeMocks.dbEnv.DRIVER = "better-sqlite3";
|
||||
routeMocks.dbEnv.URL = activeDatabasePath;
|
||||
routeMocks.auth.mockResolvedValue({ user: { permissions: ["admin"] } });
|
||||
routeMocks.claimAllowed.mockResolvedValue(false);
|
||||
routeMocks.onboardingFindFirst.mockResolvedValue({ step: "start" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("accepts the legacy import step through normalized onboarding claim access", async () => {
|
||||
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
|
||||
const backup = createBackup(temporaryDirectory, "legacy", "Restored board");
|
||||
routeMocks.auth.mockResolvedValue(null);
|
||||
routeMocks.claimAllowed.mockResolvedValue(true);
|
||||
routeMocks.onboardingFindFirst.mockResolvedValue({ step: "import" });
|
||||
|
||||
const response = await POST(createImportRequest(backup));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
success: true,
|
||||
restartAfterMs: 500,
|
||||
homeBoardName: "Restored board",
|
||||
});
|
||||
expect(readBoardNames(activeDatabasePath)).toEqual(["Restored board"]);
|
||||
});
|
||||
|
||||
it("rejects an oversized declared upload before reading its body", async () => {
|
||||
const originalDatabase = Buffer.from("active database remains untouched");
|
||||
fs.writeFileSync(activeDatabasePath, originalDatabase);
|
||||
const request = new Request("http://homarr.test/api/backup/import", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-length": String(258 * MEBIBYTE),
|
||||
"content-type": "multipart/form-data; boundary=oversized",
|
||||
},
|
||||
body: "--oversized--\r\n",
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
|
||||
});
|
||||
|
||||
it("rejects an oversized uncompressed SQLite entry without replacing the active database", async () => {
|
||||
const originalDatabase = Buffer.from("active database remains untouched");
|
||||
fs.writeFileSync(activeDatabasePath, originalDatabase);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", Buffer.from("small compressed payload"));
|
||||
zip.addFile("metadata.json", Buffer.from('{"dbDialect":"sqlite"}'));
|
||||
const databaseEntry = zip.getEntry("db.sqlite");
|
||||
if (!databaseEntry) throw new Error("Expected database entry");
|
||||
databaseEntry.header.size = 513 * MEBIBYTE;
|
||||
|
||||
const response = await POST(createImportRequest(zip.toBuffer()));
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
|
||||
});
|
||||
|
||||
it("rejects an oversized uncompressed archive before extracting any entry", async () => {
|
||||
const originalDatabase = Buffer.from("active database remains untouched");
|
||||
fs.writeFileSync(activeDatabasePath, originalDatabase);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", Buffer.from("small compressed payload"));
|
||||
zip.addFile("metadata.json", Buffer.from('{"dbDialect":"sqlite"}'));
|
||||
zip.addFile("unexpected.bin", Buffer.from("small compressed payload"));
|
||||
const unexpectedEntry = zip.getEntry("unexpected.bin");
|
||||
if (!unexpectedEntry) throw new Error("Expected oversized test entry");
|
||||
unexpectedEntry.header.size = 515 * MEBIBYTE;
|
||||
|
||||
const response = await POST(createImportRequest(zip.toBuffer()));
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
|
||||
});
|
||||
|
||||
it("uses and cleans a unique temporary directory for every restore", async () => {
|
||||
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
|
||||
const createdDirectories: string[] = [];
|
||||
const makeTemporaryDirectory = fs.mkdtempSync.bind(fs);
|
||||
vi.spyOn(fs, "mkdtempSync").mockImplementation((prefix, options) => {
|
||||
const directory = makeTemporaryDirectory(prefix, options);
|
||||
createdDirectories.push(directory);
|
||||
return directory;
|
||||
});
|
||||
|
||||
const firstResponse = await POST(createImportRequest(createBackup(temporaryDirectory, "first", "First restore")));
|
||||
const secondResponse = await POST(
|
||||
createImportRequest(createBackup(temporaryDirectory, "second", "Second restore")),
|
||||
);
|
||||
|
||||
expect(firstResponse.status).toBe(200);
|
||||
expect(secondResponse.status).toBe(200);
|
||||
expect(new Set(createdDirectories).size).toBe(2);
|
||||
expect(createdDirectories.every((directory) => !fs.existsSync(directory))).toBe(true);
|
||||
expect(readBoardNames(activeDatabasePath)).toEqual(["Second restore"]);
|
||||
});
|
||||
|
||||
it("keeps a successful restore response when temporary directory cleanup fails", async () => {
|
||||
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
|
||||
const backup = createBackup(temporaryDirectory, "cleanup-failure", "Restored despite cleanup failure");
|
||||
const removeDirectory = fs.rmSync.bind(fs);
|
||||
const cleanupError = new Error("simulated cleanup failure");
|
||||
const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(fs, "rmSync").mockImplementation((target, options) => {
|
||||
if (String(target).includes(".homarr-restore-")) throw cleanupError;
|
||||
return removeDirectory(target, options);
|
||||
});
|
||||
|
||||
const response = await POST(createImportRequest(backup));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ success: true });
|
||||
expect(readBoardNames(activeDatabasePath)).toEqual(["Restored despite cleanup failure"]);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
"[backup/import] Failed to remove temporary restore directory:",
|
||||
cleanupError,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a concurrent restore while the first request owns the restore lock", async () => {
|
||||
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
|
||||
const backup = createBackup(temporaryDirectory, "concurrent", "Concurrent restore");
|
||||
const originalArrayBuffer = File.prototype.arrayBuffer;
|
||||
let signalStarted!: () => void;
|
||||
let releaseFirstRequest!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
signalStarted = resolve;
|
||||
});
|
||||
const release = new Promise<void>((resolve) => {
|
||||
releaseFirstRequest = resolve;
|
||||
});
|
||||
vi.spyOn(File.prototype, "arrayBuffer").mockImplementationOnce(async function (this: File) {
|
||||
signalStarted();
|
||||
await release;
|
||||
return originalArrayBuffer.call(this);
|
||||
});
|
||||
|
||||
const firstRestore = POST(createImportRequest(backup));
|
||||
await started;
|
||||
const concurrentResponse = await POST(createImportRequest(backup));
|
||||
releaseFirstRequest();
|
||||
const firstResponse = await firstRestore;
|
||||
|
||||
expect(concurrentResponse.status).toBe(409);
|
||||
expect(firstResponse.status).toBe(200);
|
||||
expect(readBoardNames(activeDatabasePath)).toEqual(["Concurrent restore"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
claimOnboardingAsync,
|
||||
getOnboardingClaimTokenFromCookieHeader,
|
||||
onboardingClaimCookieName,
|
||||
} from "@homarr/api/onboarding-claim";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
const noStoreHeaders = { "Cache-Control": "no-store" };
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const fetchSite = request.headers.get("sec-fetch-site");
|
||||
if (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") {
|
||||
return NextResponse.json({ code: "cross_site" }, { status: 403 });
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const result = await claimOnboardingAsync(db, {
|
||||
currentToken: getOnboardingClaimTokenFromCookieHeader(request.headers.get("cookie")),
|
||||
force: session?.user.permissions.includes("admin") ?? false,
|
||||
});
|
||||
if (result.status === "finished") {
|
||||
const response = NextResponse.json({ code: "finished" }, { status: 409 });
|
||||
response.cookies.delete(onboardingClaimCookieName);
|
||||
return response;
|
||||
}
|
||||
if (result.status === "forbidden") {
|
||||
return NextResponse.json({ code: "administrator_required" }, { status: 403 });
|
||||
}
|
||||
if (result.status === "locked") {
|
||||
return NextResponse.json({ code: "locked", expiresAt: result.expiresAt }, { status: 423, headers: noStoreHeaders });
|
||||
}
|
||||
if (result.status !== "issued" && result.status !== "active") {
|
||||
return NextResponse.json({ code: "unavailable" }, { status: 409 });
|
||||
}
|
||||
|
||||
const forwardedProtocol = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const secure = forwardedProtocol === "https" || new URL(request.url).protocol === "https:";
|
||||
const response = NextResponse.json(
|
||||
{ status: result.status, expiresAt: result.expiresAt },
|
||||
{ headers: noStoreHeaders },
|
||||
);
|
||||
response.cookies.set(onboardingClaimCookieName, result.token, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure,
|
||||
path: "/",
|
||||
expires: new Date(result.expiresAt),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getStepAfterRestorePreview, waitForServerReadinessAsync } from "./database-restore-flow";
|
||||
|
||||
it("requires confirmation after every restore preview", () => {
|
||||
expect(getStepAfterRestorePreview()).toBe("confirm");
|
||||
});
|
||||
|
||||
describe("waitForServerReadinessAsync", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts checking after the server-provided restart delay and returns when ready", async () => {
|
||||
const controller = new AbortController();
|
||||
const requestReady = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||
const readiness = waitForServerReadinessAsync({
|
||||
restartAfterMs: 500,
|
||||
signal: controller.signal,
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 250,
|
||||
requestReady,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
expect(requestReady).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(requestReady).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
await expect(readiness).resolves.toBe("ready");
|
||||
expect(requestReady).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("times out even when a readiness request never settles on its own", async () => {
|
||||
const controller = new AbortController();
|
||||
const requestReady = vi.fn(
|
||||
(signal: AbortSignal) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve(false), { once: true });
|
||||
}),
|
||||
);
|
||||
const readiness = waitForServerReadinessAsync({
|
||||
restartAfterMs: 0,
|
||||
signal: controller.signal,
|
||||
timeoutMs: 1_000,
|
||||
pollIntervalMs: 100,
|
||||
requestTimeoutMs: 400,
|
||||
requestReady,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_001);
|
||||
|
||||
await expect(readiness).resolves.toBe("timedOut");
|
||||
expect(requestReady).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("stops cleanly when the restore flow is unmounted", async () => {
|
||||
const controller = new AbortController();
|
||||
const requestReady = vi.fn().mockResolvedValue(false);
|
||||
const readiness = waitForServerReadinessAsync({
|
||||
restartAfterMs: 500,
|
||||
signal: controller.signal,
|
||||
requestReady,
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
|
||||
await expect(readiness).resolves.toBe("aborted");
|
||||
expect(requestReady).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, 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 "@mantine/dropzone/styles.css"; // oxlint-disable-line import/no-unassigned-import
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -22,16 +22,105 @@ interface DatabaseRestoreFlowProps {
|
||||
onRestoreComplete?: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_RESTART_DELAY_MS = 500;
|
||||
const RESTART_READINESS_TIMEOUT_MS = 45_000;
|
||||
const RESTART_POLL_INTERVAL_MS = 750;
|
||||
const RESTART_REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type ServerReadinessResult = "ready" | "timedOut" | "aborted";
|
||||
|
||||
export const getStepAfterRestorePreview = (): RestoreStep => "confirm";
|
||||
|
||||
interface WaitForServerReadinessOptions {
|
||||
restartAfterMs: number;
|
||||
signal: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
requestReady?: (signal: AbortSignal) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const waitAsync = (durationMs: number, signal: AbortSignal) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
signal.removeEventListener("abort", handleAbort);
|
||||
resolve(true);
|
||||
}, durationMs);
|
||||
const handleAbort = () => {
|
||||
window.clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
signal.addEventListener("abort", handleAbort, { once: true });
|
||||
});
|
||||
|
||||
const requestServerReadinessAsync = async (signal: AbortSignal) => {
|
||||
const response = await fetch("/api/health/ready", { cache: "no-store", signal });
|
||||
return response.ok;
|
||||
};
|
||||
|
||||
export const waitForServerReadinessAsync = async ({
|
||||
restartAfterMs,
|
||||
signal,
|
||||
timeoutMs = RESTART_READINESS_TIMEOUT_MS,
|
||||
pollIntervalMs = RESTART_POLL_INTERVAL_MS,
|
||||
requestTimeoutMs = RESTART_REQUEST_TIMEOUT_MS,
|
||||
requestReady = requestServerReadinessAsync,
|
||||
}: WaitForServerReadinessOptions): Promise<ServerReadinessResult> => {
|
||||
if (!(await waitAsync(Math.max(0, restartAfterMs), signal))) return "aborted";
|
||||
|
||||
const deadline = Date.now() + Math.max(0, timeoutMs);
|
||||
while (!signal.aborted) {
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs < 0) return "timedOut";
|
||||
|
||||
const requestController = new AbortController();
|
||||
const abortRequest = () => requestController.abort();
|
||||
signal.addEventListener("abort", abortRequest, { once: true });
|
||||
const requestTimer = window.setTimeout(abortRequest, Math.min(requestTimeoutMs, remainingMs));
|
||||
|
||||
let ready = false;
|
||||
try {
|
||||
ready = await requestReady(requestController.signal);
|
||||
} catch {
|
||||
// The server is expected to be temporarily unreachable while it restarts.
|
||||
} finally {
|
||||
window.clearTimeout(requestTimer);
|
||||
signal.removeEventListener("abort", abortRequest);
|
||||
}
|
||||
|
||||
if (signal.aborted) return "aborted";
|
||||
if (ready) return "ready";
|
||||
|
||||
const delayMs = Math.min(pollIntervalMs, deadline - Date.now());
|
||||
if (delayMs <= 0) return "timedOut";
|
||||
if (!(await waitAsync(delayMs, signal))) return "aborted";
|
||||
}
|
||||
|
||||
return "aborted";
|
||||
};
|
||||
|
||||
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 [restoreStatus, setRestoreStatus] = useState<"restoring" | "restarting" | "timedOut">("restoring");
|
||||
const restartControllerRef = useRef<AbortController | null>(null);
|
||||
const restoreDestinationRef = useRef<string | null>(null);
|
||||
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
restartControllerRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFileDrop = useCallback(
|
||||
(files: FileWithPath[]) => {
|
||||
const droppedFile = files[0];
|
||||
@@ -39,6 +128,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
setFile(droppedFile);
|
||||
setStep("preview");
|
||||
setImportError(null);
|
||||
setRestoreStatus("restoring");
|
||||
void analyzeFile(droppedFile);
|
||||
},
|
||||
[analyzeFile],
|
||||
@@ -48,59 +138,77 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
setFile(null);
|
||||
setStep("upload");
|
||||
setImportError(null);
|
||||
apiDoneRef.current = false;
|
||||
animDoneRef.current = false;
|
||||
apiErrorRef.current = null;
|
||||
setRestoreStatus("restoring");
|
||||
restartControllerRef.current?.abort();
|
||||
restartControllerRef.current = null;
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const tryFinalize = useCallback(() => {
|
||||
if (!apiDoneRef.current) return;
|
||||
const startReadinessCheck = useCallback((restartAfterMs: number, destination = restoreDestinationRef.current) => {
|
||||
restartControllerRef.current?.abort();
|
||||
restoreDestinationRef.current = destination;
|
||||
const restartController = new AbortController();
|
||||
restartControllerRef.current = restartController;
|
||||
setRestoreStatus("restarting");
|
||||
|
||||
if (apiErrorRef.current) {
|
||||
setImportError(apiErrorRef.current);
|
||||
setStep("error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!animDoneRef.current) return;
|
||||
onRestoreComplete?.();
|
||||
window.location.reload();
|
||||
}, [onRestoreComplete]);
|
||||
void waitForServerReadinessAsync({ restartAfterMs, signal: restartController.signal }).then((result) => {
|
||||
if (restartControllerRef.current !== restartController || result === "aborted") return;
|
||||
if (result === "ready") {
|
||||
if (destination) window.location.assign(destination);
|
||||
else window.location.reload();
|
||||
return;
|
||||
}
|
||||
restartControllerRef.current = null;
|
||||
setRestoreStatus("timedOut");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!file) return;
|
||||
setStep("restoring");
|
||||
setImportError(null);
|
||||
apiDoneRef.current = false;
|
||||
animDoneRef.current = false;
|
||||
apiErrorRef.current = null;
|
||||
setRestoreStatus("restoring");
|
||||
restartControllerRef.current?.abort();
|
||||
restartControllerRef.current = null;
|
||||
|
||||
let restartAfterMs = DEFAULT_RESTART_DELAY_MS;
|
||||
let homeBoardName: string | null = null;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetch("/api/backup/import", { method: "POST", body: formData });
|
||||
const data = (await response.json().catch(() => null)) as {
|
||||
error?: unknown;
|
||||
homeBoardName?: unknown;
|
||||
restartAfterMs?: unknown;
|
||||
} | null;
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const data = await response.json();
|
||||
apiErrorRef.current = data.error ?? t("failed.title");
|
||||
} catch {
|
||||
apiErrorRef.current = `Server returned ${response.status}`;
|
||||
}
|
||||
const message = typeof data?.error === "string" ? data.error : `Server returned ${response.status}`;
|
||||
setImportError(message);
|
||||
setStep("error");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
apiErrorRef.current = t("failed.title");
|
||||
} finally {
|
||||
apiDoneRef.current = true;
|
||||
tryFinalize();
|
||||
}
|
||||
}, [file, t, tryFinalize]);
|
||||
|
||||
const handleAnimationComplete = useCallback(() => {
|
||||
animDoneRef.current = true;
|
||||
tryFinalize();
|
||||
}, [tryFinalize]);
|
||||
if (
|
||||
typeof data?.restartAfterMs === "number" &&
|
||||
Number.isFinite(data.restartAfterMs) &&
|
||||
data.restartAfterMs >= 0
|
||||
) {
|
||||
restartAfterMs = data.restartAfterMs;
|
||||
}
|
||||
if (typeof data?.homeBoardName === "string") homeBoardName = data.homeBoardName;
|
||||
} catch {
|
||||
setImportError(t("failed.title"));
|
||||
setStep("error");
|
||||
return;
|
||||
}
|
||||
|
||||
onRestoreComplete?.();
|
||||
const destination =
|
||||
variant === "standalone" && homeBoardName ? `/boards/${encodeURIComponent(homeBoardName)}` : null;
|
||||
startReadinessCheck(restartAfterMs, destination);
|
||||
}, [file, onRestoreComplete, startReadinessCheck, t, variant]);
|
||||
|
||||
if (step === "upload") {
|
||||
return (
|
||||
@@ -145,19 +253,19 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
if (step === "preview") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" miw={0}>
|
||||
<IconFileZip size={20} />
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{file?.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button variant="subtle" size="xs" onClick={handleClear}>
|
||||
<Button variant="subtle" size="xs" onClick={handleClear} style={{ flexShrink: 0 }}>
|
||||
{t("changeFile")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -183,7 +291,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
<>
|
||||
<BackupPreviewPanel analysis={analysis} />
|
||||
<Group justify="flex-end">
|
||||
<Button rightSection={<IconArrowRight size={16} />} onClick={() => setStep("confirm")}>
|
||||
<Button rightSection={<IconArrowRight size={16} />} onClick={() => setStep(getStepAfterRestorePreview())}>
|
||||
{t("continueToRestore")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -206,8 +314,9 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
return (
|
||||
<RestoreProgressPanel
|
||||
active
|
||||
migrations={analysis?.migrations.pending ?? []}
|
||||
onComplete={handleAnimationComplete}
|
||||
status={restoreStatus}
|
||||
onRetry={() => startReadinessCheck(0)}
|
||||
onReload={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => {
|
||||
const translations: Record<string, string> = {
|
||||
"progress.title": "Restoring database...",
|
||||
"progress.restarting": "Restarting server",
|
||||
"timeout.title": "Server did not come back online",
|
||||
"timeout.message": "Retry the connection check or refresh this page manually.",
|
||||
tryAgain: "Try again",
|
||||
"docker.action.refresh.label": "Refresh",
|
||||
};
|
||||
return {
|
||||
useScopedI18n: () => (key: string) => translations[key] ?? key,
|
||||
useI18n: () => (key: string) => translations[key] ?? key,
|
||||
};
|
||||
});
|
||||
|
||||
import { RestoreProgressPanel } from "./restore-progress-panel";
|
||||
|
||||
describe("RestoreProgressPanel", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes("prefers-reduced-motion"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const renderPanel = async (
|
||||
status: "restoring" | "restarting" | "timedOut",
|
||||
onRetry = vi.fn(),
|
||||
onReload = vi.fn(),
|
||||
) => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(RestoreProgressPanel, { active: true, status, onRetry, onReload }),
|
||||
),
|
||||
),
|
||||
);
|
||||
return { onRetry, onReload };
|
||||
};
|
||||
|
||||
it("announces real restore and restart state without a motion-only cue", async () => {
|
||||
await renderPanel("restoring");
|
||||
const output = host.querySelector("output");
|
||||
expect(output?.getAttribute("aria-live")).toBe("polite");
|
||||
expect(output?.getAttribute("aria-busy")).toBe("true");
|
||||
expect(output?.textContent).toContain("Restoring database...");
|
||||
expect(output?.querySelector("svg")).not.toBeNull();
|
||||
|
||||
await renderPanel("restarting");
|
||||
expect(output?.getAttribute("aria-busy")).toBe("true");
|
||||
expect(output?.textContent).toContain("Restarting server");
|
||||
});
|
||||
|
||||
it("exposes keyboard-native retry and manual refresh actions after timeout", async () => {
|
||||
const { onRetry, onReload } = await renderPanel("timedOut");
|
||||
const output = host.querySelector("output");
|
||||
const buttons = Array.from(host.querySelectorAll("button"));
|
||||
|
||||
expect(output?.getAttribute("aria-busy")).toBe("false");
|
||||
expect(output?.textContent).toContain("Server did not come back online");
|
||||
expect(output?.textContent).toContain("Retry the connection check or refresh this page manually.");
|
||||
expect(buttons.map((button) => button.textContent)).toEqual(["Try again", "Refresh"]);
|
||||
|
||||
buttons[0]?.click();
|
||||
buttons[1]?.click();
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
expect(onReload).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,164 +1,78 @@
|
||||
"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 { Button, Group, Loader, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { useReducedMotion } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconCheck, IconDatabaseImport, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { MigrationFile } from "./types";
|
||||
|
||||
const BASE_PHASE_DURATIONS = {
|
||||
extracting: 1200,
|
||||
encrypting: 1500,
|
||||
swapping: 800,
|
||||
restarting: 10000,
|
||||
} as const;
|
||||
|
||||
const MIGRATION_STEP_DURATION = 100;
|
||||
|
||||
interface TimelineStep {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: Icon;
|
||||
duration: number;
|
||||
}
|
||||
export type RestoreProgressStatus = "restoring" | "restarting" | "timedOut";
|
||||
|
||||
interface RestoreProgressPanelProps {
|
||||
active: boolean;
|
||||
migrations: MigrationFile[];
|
||||
onComplete?: () => void;
|
||||
status: RestoreProgressStatus;
|
||||
onRetry: () => void;
|
||||
onReload: () => void;
|
||||
}
|
||||
|
||||
export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore.progress");
|
||||
const [currentStepIdx, setCurrentStepIdx] = useState(-1);
|
||||
export const RestoreProgressPanel = ({ active, status, onRetry, onReload }: RestoreProgressPanelProps) => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore");
|
||||
const tRoot = useI18n();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
const steps = useMemo((): TimelineStep[] => {
|
||||
const result: TimelineStep[] = [
|
||||
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting },
|
||||
];
|
||||
if (!active) return null;
|
||||
|
||||
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 (const [i, step] of steps.entries()) {
|
||||
if (cancelled) return;
|
||||
setCurrentStepIdx(i);
|
||||
await new Promise((r) => setTimeout(r, step.duration));
|
||||
}
|
||||
if (!cancelled) {
|
||||
onComplete?.();
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, onComplete, steps]);
|
||||
|
||||
if (!active && currentStepIdx === -1) return null;
|
||||
const isRestoring = status === "restoring";
|
||||
const isTimedOut = status === "timedOut";
|
||||
const statusLabel = isRestoring ? t("progress.title") : isTimedOut ? t("timeout.message") : t("progress.restarting");
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="md" bg="var(--mantine-color-dark-7)">
|
||||
<Paper
|
||||
component="output"
|
||||
p="lg"
|
||||
radius="md"
|
||||
bg="var(--mantine-color-dark-7)"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
aria-busy={!isTimedOut}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" color="blue" />
|
||||
<Text size="sm" fw={600}>
|
||||
{t("title")}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
{isTimedOut ? (
|
||||
<ThemeIcon size="lg" radius="xl" color="orange" variant="light" aria-hidden="true">
|
||||
<IconAlertTriangle size={18} />
|
||||
</ThemeIcon>
|
||||
) : !isRestoring ? (
|
||||
<ThemeIcon size="lg" radius="xl" color="green" variant="light" aria-hidden="true">
|
||||
<IconCheck size={18} />
|
||||
</ThemeIcon>
|
||||
) : reduceMotion ? (
|
||||
<ThemeIcon size="lg" radius="xl" color="blue" variant="light" aria-hidden="true">
|
||||
<IconDatabaseImport size={18} />
|
||||
</ThemeIcon>
|
||||
) : (
|
||||
<Loader size="sm" color="blue" aria-hidden="true" />
|
||||
)}
|
||||
<div>
|
||||
<Text size="sm" fw={600}>
|
||||
{isTimedOut ? t("timeout.title") : t("progress.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{statusLabel}
|
||||
</Text>
|
||||
</div>
|
||||
</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>
|
||||
{isTimedOut ? (
|
||||
<Group>
|
||||
<Button variant="light" onClick={onRetry} leftSection={<IconRefresh size={16} />}>
|
||||
{t("tryAgain")}
|
||||
</Button>
|
||||
<Button variant="default" onClick={onReload}>
|
||||
{tRoot("docker.action.refresh.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useTiks } from "@rexa-developer/tiks/react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -8,6 +9,8 @@ import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types";
|
||||
import { PREVIEW_TABLE_KEYS } from "./types";
|
||||
|
||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||
const MIGRATION_PREVIEW_DELAY_MS = 250;
|
||||
const MIGRATION_COMPLETE_DELAY_MS = 100;
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -58,6 +61,7 @@ export interface MigrationProgress {
|
||||
|
||||
export const useBackupAnalysis = () => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore");
|
||||
const sounds = useTiks({ theme: "soft", volume: 0.12, respectReducedMotion: true });
|
||||
const [analysis, setAnalysis] = useState<BackupAnalysis | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -118,7 +122,7 @@ export const useBackupAnalysis = () => {
|
||||
phase: "applying",
|
||||
});
|
||||
|
||||
await delay(100);
|
||||
await delay(MIGRATION_PREVIEW_DELAY_MS);
|
||||
|
||||
try {
|
||||
const statements = migration.sql
|
||||
@@ -135,6 +139,7 @@ export const useBackupAnalysis = () => {
|
||||
Date.now(),
|
||||
]);
|
||||
} catch (migrationErr) {
|
||||
sounds.error();
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
@@ -153,8 +158,9 @@ export const useBackupAnalysis = () => {
|
||||
tag: migration.tag,
|
||||
phase: "done",
|
||||
});
|
||||
sounds.success();
|
||||
|
||||
await delay(50);
|
||||
await delay(MIGRATION_COMPLETE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +179,7 @@ export const useBackupAnalysis = () => {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
[sounds, t],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
.canvas {
|
||||
min-height: 8rem;
|
||||
aspect-ratio: 16 / 7;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-dimmed);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 15% 10%,
|
||||
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
|
||||
transparent 35%
|
||||
),
|
||||
var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.lanes {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
gap: rem(8px);
|
||||
padding: rem(12px);
|
||||
}
|
||||
|
||||
.lane {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: rem(3px);
|
||||
padding: rem(4px);
|
||||
overflow: hidden;
|
||||
border: rem(1px) solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to right,
|
||||
color-mix(in srgb, var(--mantine-color-dimmed) 8%, transparent) rem(1px),
|
||||
transparent rem(1px)
|
||||
),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--mantine-color-dimmed) 8%, transparent) rem(1px),
|
||||
transparent rem(1px)
|
||||
);
|
||||
background-size: rem(12px) rem(12px);
|
||||
}
|
||||
|
||||
.lane[data-lane="left"],
|
||||
.lane[data-lane="right"] {
|
||||
background-color: color-mix(in srgb, var(--mantine-primary-color-light) 40%, transparent);
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: rem(1px) solid color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent);
|
||||
border-radius: max(rem(2px), calc(var(--mantine-radius-default) / 2));
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 18%, var(--mantine-color-body));
|
||||
box-shadow: 0 rem(1px) rem(3px) rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: rem(16px) !important;
|
||||
height: rem(16px) !important;
|
||||
max-width: 55%;
|
||||
max-height: 55%;
|
||||
object-fit: contain;
|
||||
color: var(--mantine-color-text);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.container {
|
||||
border-style: dashed;
|
||||
border-color: color-mix(in srgb, var(--mantine-color-teal-filled) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--mantine-color-teal-light) 55%, var(--mantine-color-body));
|
||||
}
|
||||
|
||||
.containerContents {
|
||||
position: absolute;
|
||||
inset: rem(2px);
|
||||
display: grid;
|
||||
gap: rem(1px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: $mantine-breakpoint-xs) {
|
||||
.canvas {
|
||||
min-height: 7rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Box, Center, Image } from "@mantine/core";
|
||||
import { IconLayoutGrid } from "@tabler/icons-react";
|
||||
|
||||
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||
import { projectBoardLayout } from "@homarr/boards/layout-preview";
|
||||
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import classes from "./board-layout-thumbnail.module.css";
|
||||
|
||||
interface BoardLayoutThumbnailProps {
|
||||
preview: BoardPreviewData | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const maxThumbnailRows = 12;
|
||||
|
||||
export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => {
|
||||
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
||||
if (!preview || !layout) {
|
||||
return (
|
||||
<Center className={classes.canvas} role="img" aria-label={label}>
|
||||
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const elements = projectBoardLayout(preview, layout, layout);
|
||||
const roots = preview.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
||||
const lanes = boardLanes.flatMap((lane) => {
|
||||
const root = roots.find((section) => getRootSectionLane(section.xOffset) === lane);
|
||||
const columnCount = getBoardLaneColumnCount(layout, lane);
|
||||
return root && columnCount > 0 ? [{ lane, root, columnCount }] : [];
|
||||
});
|
||||
if (lanes.length === 0) {
|
||||
return (
|
||||
<Center className={classes.canvas} role="img" aria-label={label}>
|
||||
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const renderItemIcon = (itemId: string) => {
|
||||
const item = preview.items.find((candidate) => candidate.id === itemId);
|
||||
const WidgetIcon = item?.kind ? widgetCatalogIcons[item.kind] : undefined;
|
||||
return item?.iconUrl ? (
|
||||
<Image className={classes.itemIcon} src={item.iconUrl} alt="" fit="contain" />
|
||||
) : WidgetIcon ? (
|
||||
<WidgetIcon className={classes.itemIcon} stroke={1.7} />
|
||||
) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={classes.canvas} role="img" aria-label={label}>
|
||||
<div
|
||||
className={classes.lanes}
|
||||
style={{ gridTemplateColumns: lanes.map(({ columnCount }) => `${columnCount}fr`).join(" ") }}
|
||||
>
|
||||
{lanes.map(({ lane, root, columnCount }) => {
|
||||
const laneElements = elements.filter(
|
||||
(element) =>
|
||||
element.sectionId === root.id &&
|
||||
element.xOffset >= 0 &&
|
||||
element.xOffset < columnCount &&
|
||||
element.yOffset >= 0 &&
|
||||
element.yOffset < maxThumbnailRows &&
|
||||
element.width > 0 &&
|
||||
element.height > 0,
|
||||
);
|
||||
const rowCount = Math.min(
|
||||
maxThumbnailRows,
|
||||
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lane}
|
||||
className={classes.lane}
|
||||
data-lane={lane}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${rowCount}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{laneElements.map((element) => {
|
||||
const nestedItems =
|
||||
element.type === "section"
|
||||
? elements.filter((candidate) => candidate.type === "item" && candidate.sectionId === element.id)
|
||||
: [];
|
||||
return (
|
||||
<span
|
||||
key={`${element.type}-${element.id}`}
|
||||
aria-hidden
|
||||
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
|
||||
style={{
|
||||
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`,
|
||||
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
|
||||
}}
|
||||
>
|
||||
{element.type === "section" ? (
|
||||
<span
|
||||
className={classes.containerContents}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${element.width}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${element.height}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{nestedItems.map((nestedItem) => (
|
||||
<span
|
||||
key={nestedItem.id}
|
||||
className={classes.tile}
|
||||
style={{
|
||||
gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min(
|
||||
nestedItem.width,
|
||||
element.width - nestedItem.xOffset,
|
||||
)}`,
|
||||
gridRow: `${nestedItem.yOffset + 1} / span ${Math.min(
|
||||
nestedItem.height,
|
||||
element.height - nestedItem.yOffset,
|
||||
)}`,
|
||||
}}
|
||||
>
|
||||
{renderItemIcon(nestedItem.id)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
renderItemIcon(element.id)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -196,6 +196,7 @@ const ItemSelectModalContent = ({
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
ariaLabel={t("item.create.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
|
||||
@@ -1,94 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core";
|
||||
import { IconCheck, IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
|
||||
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const schemeIcons: Record<ColorScheme, React.ElementType> = {
|
||||
auto: IconDeviceDesktop,
|
||||
light: IconSun,
|
||||
dark: IconMoon,
|
||||
};
|
||||
|
||||
interface ColorSchemeComboboxProps {
|
||||
value: ColorScheme;
|
||||
onChange: (value: ColorScheme) => void;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export const ColorSchemeCombobox = ({ value, onChange, width }: ColorSchemeComboboxProps) => {
|
||||
const tOptions = useScopedI18n("common.colorScheme.options");
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
|
||||
const handleOnOptionSubmit = React.useCallback(
|
||||
(val: string) => {
|
||||
if (!val) return;
|
||||
onChange(val as ColorScheme);
|
||||
combobox.closeDropdown();
|
||||
},
|
||||
[onChange, combobox],
|
||||
);
|
||||
|
||||
const handleOnClick = React.useCallback(() => {
|
||||
combobox.toggleDropdown();
|
||||
}, [combobox]);
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
rightSection={<Combobox.Chevron />}
|
||||
rightSectionPointerEvents="none"
|
||||
onClick={handleOnClick}
|
||||
variant="filled"
|
||||
w={width}
|
||||
>
|
||||
<OptionItem scheme={value} currentScheme={value} tOptions={tOptions} />
|
||||
</InputBase>
|
||||
</Combobox.Target>
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{colorSchemes.map((scheme) => (
|
||||
<Combobox.Option value={scheme} key={scheme}>
|
||||
<OptionItem scheme={scheme} currentScheme={value} tOptions={tOptions} showCheck />
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionItem = ({
|
||||
scheme,
|
||||
currentScheme,
|
||||
tOptions,
|
||||
showCheck,
|
||||
}: {
|
||||
scheme: ColorScheme;
|
||||
currentScheme: ColorScheme;
|
||||
tOptions: (key: ColorScheme) => string;
|
||||
showCheck?: boolean;
|
||||
}) => {
|
||||
const Icon = schemeIcons[scheme];
|
||||
|
||||
return (
|
||||
<Group wrap="nowrap" justify="space-between">
|
||||
<Group wrap="nowrap">
|
||||
<Icon size={16} stroke={1.5} />
|
||||
<Text>{tOptions(scheme)}</Text>
|
||||
</Group>
|
||||
{showCheck && scheme === currentScheme && <IconCheck color="currentColor" size={16} />}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
export { ColorSchemeCombobox } from "@homarr/ui";
|
||||
@@ -1,76 +1,6 @@
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import {
|
||||
featuredIntegrations,
|
||||
getIntegrationName,
|
||||
hiddenFromOnboarding,
|
||||
integrationDefs,
|
||||
integrationKinds,
|
||||
getWidgetKindsForIntegration,
|
||||
} from "@homarr/definitions";
|
||||
|
||||
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",
|
||||
beszel: "integration.category.beszel",
|
||||
search: "integration.category.search",
|
||||
mediaTranscoding: "integration.category.mediaTranscoding",
|
||||
networkController: "integration.category.networkController",
|
||||
notifications: "integration.category.notifications",
|
||||
firewall: "integration.category.firewall",
|
||||
photoService: "integration.category.photoService",
|
||||
notes: "integration.category.notes",
|
||||
mediaMonitoring: "integration.category.mediaMonitoring",
|
||||
speedtest: "integration.category.speedtest",
|
||||
analytics: "integration.category.analytics",
|
||||
vpn: "integration.category.vpn",
|
||||
documents: "integration.category.documents",
|
||||
mediaLibrary: "integration.category.mediaLibrary",
|
||||
uptimeMonitoring: "integration.category.uptimeMonitoring",
|
||||
};
|
||||
|
||||
export interface IntegrationGridItem {
|
||||
kind: IntegrationKind;
|
||||
name: string;
|
||||
categories: string[];
|
||||
widgets: WidgetKind[];
|
||||
}
|
||||
|
||||
export const buildSortedIntegrations = (
|
||||
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
|
||||
): IntegrationGridItem[] => {
|
||||
return integrationKinds
|
||||
.filter((kind) => {
|
||||
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
|
||||
if (options.onboarding && getWidgetKindsForIntegration(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: getWidgetKindsForIntegration(kind),
|
||||
}))
|
||||
.toSorted((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()));
|
||||
export {
|
||||
buildSortedIntegrations,
|
||||
categoryTranslationKeys,
|
||||
filterIntegrations,
|
||||
} from "@homarr/ui/integration-select-grid";
|
||||
export type { IntegrationGridItem } from "@homarr/ui/integration-select-grid";
|
||||
@@ -1,140 +1,2 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
export { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid";
|
||||
export type { IntegrationMultiSelectGridProps } from "@homarr/ui/integration-select-grid";
|
||||
@@ -1,90 +1,2 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
export { IntegrationSelectGrid } from "@homarr/ui/integration-select-grid";
|
||||
export type { IntegrationSelectGridProps } from "@homarr/ui/integration-select-grid";
|
||||
@@ -1,105 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Combobox, Group, InputBase, Loader, ScrollArea, Text, useCombobox } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
|
||||
import { LanguageIcon } from "@homarr/ui";
|
||||
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
|
||||
interface LanguageComboboxProps {
|
||||
label?: string;
|
||||
value: SupportedLanguage;
|
||||
onChange: (value: SupportedLanguage) => void;
|
||||
isPending?: boolean;
|
||||
width?: string;
|
||||
withinPortal?: boolean;
|
||||
}
|
||||
|
||||
export const LanguageCombobox = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
isPending,
|
||||
width,
|
||||
withinPortal = false,
|
||||
}: LanguageComboboxProps) => {
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
|
||||
const handleOnOptionSubmit = React.useCallback(
|
||||
(value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
onChange(value as SupportedLanguage);
|
||||
combobox.closeDropdown();
|
||||
},
|
||||
[onChange, combobox],
|
||||
);
|
||||
|
||||
const handleOnClick = React.useCallback(() => {
|
||||
combobox.toggleDropdown();
|
||||
}, [combobox]);
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} withinPortal={withinPortal} onOptionSubmit={handleOnOptionSubmit}>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
label={label}
|
||||
leftSection={isPending ? <Loader size={16} /> : null}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
rightSectionPointerEvents="none"
|
||||
onClick={handleOnClick}
|
||||
variant="filled"
|
||||
w={width}
|
||||
>
|
||||
<OptionItem currentLocale={value} localeKey={value} />
|
||||
</InputBase>
|
||||
</Combobox.Target>
|
||||
<Combobox.Dropdown>
|
||||
<ScrollArea h={300}>
|
||||
<Combobox.Options>
|
||||
{supportedLanguages.map((languageKey) => (
|
||||
<Combobox.Option value={languageKey} key={languageKey}>
|
||||
<OptionItem currentLocale={value} localeKey={languageKey} showCheck />
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</ScrollArea>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionItem = ({
|
||||
currentLocale,
|
||||
localeKey,
|
||||
showCheck,
|
||||
}: {
|
||||
currentLocale: SupportedLanguage;
|
||||
localeKey: SupportedLanguage;
|
||||
showCheck?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Group wrap="nowrap" justify="space-between">
|
||||
<Group wrap="nowrap">
|
||||
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<Text>{localeConfigurations[localeKey].name}</Text>
|
||||
<Text size="xs" c="dimmed" inherit>
|
||||
({localeConfigurations[localeKey].translatedName})
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
{showCheck && localeKey === currentLocale && <IconCheck color="currentColor" size={16} />}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
export { LanguageCombobox } from "@homarr/ui";
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Group, Paper, Stack } from "@mantine/core";
|
||||
|
||||
import type { ManagePageLayoutProps } from "./manage-page-layout";
|
||||
import { ManagePageLayout } from "./manage-page-layout";
|
||||
|
||||
export interface ManageCollectionPageProps extends Omit<ManagePageLayoutProps, "children"> {
|
||||
ariaLabel: string;
|
||||
itemCount: number;
|
||||
emptyState: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ManageCollectionPage = ({
|
||||
ariaLabel,
|
||||
itemCount,
|
||||
emptyState,
|
||||
children,
|
||||
...layoutProps
|
||||
}: ManageCollectionPageProps) => (
|
||||
<ManagePageLayout {...layoutProps}>
|
||||
{itemCount === 0 ? (
|
||||
emptyState
|
||||
) : (
|
||||
<Stack component="ul" aria-label={ariaLabel} gap="sm" m={0} p={0} style={{ listStyle: "none" }}>
|
||||
{children}
|
||||
</Stack>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
|
||||
export interface ManageCollectionItemProps {
|
||||
leading?: ReactNode;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
metadata?: ReactNode;
|
||||
badges?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export const ManageCollectionItem = ({
|
||||
leading,
|
||||
title,
|
||||
description,
|
||||
metadata,
|
||||
badges,
|
||||
actions,
|
||||
}: ManageCollectionItemProps) => (
|
||||
<Paper component="li" p="sm" withBorder radius="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
|
||||
{leading && <Box style={{ flexShrink: 0 }}>{leading}</Box>}
|
||||
<Stack gap={4} flex={1} miw={0}>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{title}
|
||||
{badges && <Box style={{ flexShrink: 0 }}>{badges}</Box>}
|
||||
</Group>
|
||||
{description}
|
||||
{metadata}
|
||||
</Stack>
|
||||
{actions && (
|
||||
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{actions}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
@@ -6,7 +6,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { ManageContainer } from "./manage-container";
|
||||
import { MANAGE_FLOATING_ACTION_BOTTOM_OFFSET } from "./manage-page.constants";
|
||||
|
||||
interface ManagePageLayoutProps {
|
||||
export interface ManagePageLayoutProps {
|
||||
title: ReactNode;
|
||||
primaryAction?: ReactNode;
|
||||
toolbar?: ReactNode;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Anchor, Card, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
@@ -5,6 +6,7 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
interface NoResultsProps {
|
||||
icon: TablerIcon;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
action?: {
|
||||
label: string;
|
||||
href: string;
|
||||
@@ -12,7 +14,7 @@ interface NoResultsProps {
|
||||
};
|
||||
}
|
||||
|
||||
export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
|
||||
export const NoResults = ({ icon: Icon, title, description, action }: NoResultsProps) => {
|
||||
return (
|
||||
<Card bg="transparent" withBorder={false}>
|
||||
<Stack align="center" gap="sm">
|
||||
@@ -20,6 +22,11 @@ export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
|
||||
<Text fw={500} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text c="dimmed" ta="center" maw={560}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{action && !action.hidden && <Anchor href={action.href}>{action.label}</Anchor>}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -93,7 +93,7 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
return (
|
||||
<TourTargetsProvider enabled={started}>
|
||||
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
|
||||
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", md: "left" }}>
|
||||
{children}
|
||||
</TourShell>
|
||||
</TourTargetsProvider>
|
||||
|
||||
@@ -181,8 +181,8 @@ export const TourShell = ({ steps, started, onEnd, stepRoutes, position, childre
|
||||
focusRevealProps={{
|
||||
disableTargetInteraction: true,
|
||||
popoverProps: {
|
||||
position: position ?? { base: "bottom", sm: "right" },
|
||||
width: 420,
|
||||
position: position ?? { base: "bottom", md: "right" },
|
||||
width: { base: "calc(100vw - 32px)", md: 420 },
|
||||
shadow: "xl",
|
||||
radius: "lg",
|
||||
middlewares: { shift: { padding: 16 }, flip: true },
|
||||
|
||||
@@ -62,6 +62,27 @@ describe("locale and onboarding proxy", () => {
|
||||
expect(mocks.getDefaultLocale).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("allows administrator sign-in while onboarding is unfinished", async () => {
|
||||
mocks.getOnboardingStep.mockResolvedValue("setup");
|
||||
const { proxy } = await loadProxy();
|
||||
|
||||
const response = await proxy(createRequest("/en/auth/login"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.getOnboardingStep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not treat nested routes ending in init or auth/login as onboarding access routes", async () => {
|
||||
mocks.getOnboardingStep.mockResolvedValue("setup");
|
||||
const { proxy } = await loadProxy();
|
||||
|
||||
const initResponse = await proxy(createRequest("/en/boards/init"));
|
||||
const loginResponse = await proxy(createRequest("/en/settings/auth/login"));
|
||||
|
||||
expect(initResponse.headers.get("location")).toBe("http://localhost/init");
|
||||
expect(loginResponse.headers.get("location")).toBe("http://localhost/init");
|
||||
});
|
||||
|
||||
it("uses the configured default locale for a missing or invalid locale cookie", async () => {
|
||||
mocks.getDefaultLocale.mockResolvedValue("de");
|
||||
const { proxy } = await loadProxy();
|
||||
|
||||
@@ -42,8 +42,13 @@ const getDefaultLocaleDedupedAsync = () => {
|
||||
export async function proxy(request: NextRequest) {
|
||||
// Redirect to onboarding if it's not finished yet
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const routeSegments = supportedLanguages.includes(segments[0] as SupportedLanguage) ? segments.slice(1) : segments;
|
||||
const isOnboardingAccessRoute =
|
||||
(routeSegments.length === 1 && routeSegments[0] === "init") ||
|
||||
(routeSegments.length === 2 && routeSegments[0] === "auth" && routeSegments[1] === "login");
|
||||
|
||||
if (!isOnboardingFinished && !pathname.endsWith("/init")) {
|
||||
if (!isOnboardingFinished && !isOnboardingAccessRoute) {
|
||||
const currentOnboardingStep = await getOnboardingStepDedupedAsync();
|
||||
if (currentOnboardingStep !== "finish") {
|
||||
return NextResponse.redirect(new URL("/init", request.url));
|
||||
|
||||
@@ -1,509 +0,0 @@
|
||||
import type { Browser, BrowserContext, Page } from "@playwright/test";
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { stringify } from "superjson";
|
||||
import { afterAll, beforeAll, describe, test } from "vitest";
|
||||
|
||||
import { hashPasswordAsync } from "../packages/auth/security";
|
||||
import {
|
||||
apps,
|
||||
boardUserPermissions,
|
||||
boards,
|
||||
itemLayouts,
|
||||
items,
|
||||
layouts,
|
||||
sections,
|
||||
users,
|
||||
} from "../packages/db/schema/sqlite";
|
||||
import type { SqliteDatabase } from "./shared/e2e-db";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { loginAsync } from "./shared/login";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
const ownerCredentials = { username: "owner", password: "Comp(exP4sswOrd" };
|
||||
const disabledModifierCredentials = { username: "modifier", password: "Comp(exP4sswOrd" };
|
||||
const viewerCredentials = { username: "viewer", password: "Comp(exP4sswOrd" };
|
||||
const boardName = "advanced-interactions-e2e";
|
||||
|
||||
describe("Board advanced interactions", () => {
|
||||
let browser: Browser;
|
||||
let baseUrl: string;
|
||||
let stopContainerAsync: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const { userId: ownerId } = await seedAdminUserAsync(db, ownerCredentials);
|
||||
await seedInteractionBoardAsync(db, ownerId);
|
||||
await seedCredentialsUserAsync(db, {
|
||||
...disabledModifierCredentials,
|
||||
enableRightClickOnWidgets: false,
|
||||
boardPermission: "modify",
|
||||
});
|
||||
await seedCredentialsUserAsync(db, {
|
||||
...viewerCredentials,
|
||||
enableRightClickOnWidgets: true,
|
||||
boardPermission: "view",
|
||||
});
|
||||
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: { AUTH_PROVIDERS: "credentials" },
|
||||
mounts: { "/appdata": localMountPath },
|
||||
}).start();
|
||||
stopContainerAsync = async () => {
|
||||
await homarrContainer.stop();
|
||||
};
|
||||
baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
browser = await chromium.launch();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await stopContainerAsync?.();
|
||||
});
|
||||
|
||||
test("opens advanced view from Shift-hover, keyboard, and the widget context menu", async () => {
|
||||
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
|
||||
|
||||
try {
|
||||
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
|
||||
const advancedViewTrigger = widget.getByRole("button", { name: "Open advanced view" });
|
||||
const compactSurface = page.locator(".clock-wrapper").first();
|
||||
const previewSurface = page.getByRole("region", { name: "Date and time advanced view" });
|
||||
const manualSurface = page.getByRole("dialog", { name: "Date and time advanced view" });
|
||||
const bookmarksPreviewSurface = page.getByRole("region", { name: "Bookmarks advanced view" });
|
||||
const dimmingOverlay = page.locator("[data-advanced-focus-overlay]");
|
||||
const otherWidget = page
|
||||
.locator("[data-grid-item-id][data-kind='bookmarks'] [data-advanced-focus-source]")
|
||||
.first();
|
||||
const header = page.locator("header[data-advanced-focus-background]");
|
||||
const boardMain = page.locator("main[data-advanced-focus-background]");
|
||||
|
||||
await compactSurface.evaluate((element) => {
|
||||
element.setAttribute("data-lifecycle-probe", "same-instance");
|
||||
});
|
||||
await widget.hover();
|
||||
await advancedViewTrigger.focus();
|
||||
await page.keyboard.down("Shift");
|
||||
await expect(previewSurface).toBeVisible({ timeout: 2_000 });
|
||||
await expect(previewSurface).toHaveCSS("animation-name", "none");
|
||||
const compactBounds = await widget.boundingBox();
|
||||
const advancedBounds = await previewSurface.boundingBox();
|
||||
if (!compactBounds || !advancedBounds) throw new Error("Advanced widget has no bounds");
|
||||
expect(advancedBounds?.width).toBeGreaterThan(compactBounds?.width ?? 0);
|
||||
await expect(previewSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
|
||||
await expect(dimmingOverlay).toBeVisible();
|
||||
await expect(dimmingOverlay).toHaveCSS("pointer-events", "none");
|
||||
await expect(otherWidget).toBeVisible();
|
||||
await expect(advancedViewTrigger).toBeFocused();
|
||||
await expect(header).not.toHaveAttribute("inert", "");
|
||||
await expect(boardMain).not.toHaveAttribute("inert", "");
|
||||
|
||||
await previewSurface.click({ button: "right", position: { x: 80, y: 80 } });
|
||||
const portalledMenuItem = page.getByRole("menuitem", { name: "Open advanced view" });
|
||||
await expect(portalledMenuItem).toBeVisible();
|
||||
await portalledMenuItem.hover();
|
||||
await expect(previewSurface).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(portalledMenuItem).toBeHidden();
|
||||
await expect(previewSurface).toBeVisible();
|
||||
|
||||
const otherBounds = await otherWidget.boundingBox();
|
||||
expect(otherBounds).not.toBeNull();
|
||||
if (!otherBounds) throw new Error("Bookmarks widget has no bounds");
|
||||
await previewSurface.evaluate(
|
||||
(element, position) => {
|
||||
const probe = document.createElement("button");
|
||||
probe.dataset.interactivePreviewProbe = "true";
|
||||
probe.style.cssText = `position:absolute;z-index:20;width:40px;height:40px;left:${position.left}px;top:${position.top}px`;
|
||||
element.append(probe);
|
||||
},
|
||||
{
|
||||
left: otherBounds.x + otherBounds.width / 2 - advancedBounds.x - 20,
|
||||
top: otherBounds.y + otherBounds.height / 2 - advancedBounds.y - 20,
|
||||
},
|
||||
);
|
||||
const interactivePreviewProbe = previewSurface.locator("[data-interactive-preview-probe='true']");
|
||||
await interactivePreviewProbe.hover();
|
||||
await expect(previewSurface).toBeVisible();
|
||||
await expect(bookmarksPreviewSurface).toBeHidden();
|
||||
await interactivePreviewProbe.evaluate((element) => element.remove());
|
||||
await page.mouse.move(compactBounds.x + 4, compactBounds.y + 4);
|
||||
await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2);
|
||||
await expect(previewSurface).toBeVisible();
|
||||
await expect(bookmarksPreviewSurface).toBeHidden();
|
||||
await page.mouse.move(4, 4);
|
||||
await expect(previewSurface).toBeHidden();
|
||||
await otherWidget.hover({ position: { x: 4, y: 4 } });
|
||||
await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 });
|
||||
await page.keyboard.up("Shift");
|
||||
await expect(bookmarksPreviewSurface).toBeHidden();
|
||||
await expect(dimmingOverlay).toBeHidden();
|
||||
await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
|
||||
await advancedViewTrigger.focus();
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
await expect(manualSurface).toBeVisible();
|
||||
await expect(manualSurface).toHaveAttribute("aria-modal", "true");
|
||||
await expect(header).toHaveAttribute("inert", "");
|
||||
await expect(boardMain).toHaveAttribute("inert", "");
|
||||
expect(await manualSurface.evaluate((element) => element.closest("[data-grid-item-id]"))).toBeNull();
|
||||
await expect(manualSurface.locator(".clock-wrapper")).toHaveAttribute("data-lifecycle-probe", "same-instance");
|
||||
await expect(dimmingOverlay).toHaveCSS("pointer-events", "auto");
|
||||
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).toBe("hidden");
|
||||
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
|
||||
await expect(page.getByRole("button", { name: /advanced view is pinned|keep advanced view open/i })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await manualSurface.evaluate((element) => {
|
||||
const mainViewport = document.createElement("div");
|
||||
mainViewport.dataset.scrollbars = "y";
|
||||
mainViewport.style.cssText = "height:80px;overflow-y:auto";
|
||||
|
||||
const probe = document.createElement("div");
|
||||
probe.dataset.shiftScrollProbe = "true";
|
||||
probe.style.cssText = "height:80px;overflow-y:auto";
|
||||
probe.innerHTML = '<svg data-shift-scroll-target="true"></svg><div style="height:600px"></div>';
|
||||
probe.addEventListener("wheel", (event) => event.stopPropagation());
|
||||
mainViewport.append(probe);
|
||||
mainViewport.insertAdjacentHTML("beforeend", '<div style="height:600px"></div>');
|
||||
element.append(mainViewport);
|
||||
|
||||
const unrelated = document.createElement("div");
|
||||
unrelated.dataset.shiftScrollProbe = "unrelated";
|
||||
unrelated.style.cssText = "height:80px;overflow-y:hidden";
|
||||
unrelated.innerHTML = '<div style="height:600px"></div>';
|
||||
element.append(unrelated);
|
||||
});
|
||||
const scrollProbe = manualSurface.locator("[data-shift-scroll-probe='true']");
|
||||
await page.keyboard.down("Shift");
|
||||
const pixelEventCancelled = await scrollProbe.evaluate(
|
||||
(element) =>
|
||||
!element.dispatchEvent(
|
||||
new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 120 }),
|
||||
),
|
||||
);
|
||||
expect(pixelEventCancelled).toBe(true);
|
||||
await scrollProbe.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
});
|
||||
const lineEventCancelled = await scrollProbe
|
||||
.locator("[data-shift-scroll-target='true']")
|
||||
.evaluate(
|
||||
(element) =>
|
||||
!element.dispatchEvent(
|
||||
new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 3, deltaMode: 1 }),
|
||||
),
|
||||
);
|
||||
await page.keyboard.up("Shift");
|
||||
expect(lineEventCancelled).toBe(true);
|
||||
expect(await scrollProbe.evaluate((element) => element.scrollTop)).toBeGreaterThanOrEqual(48);
|
||||
await scrollProbe.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(
|
||||
new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 120 }),
|
||||
);
|
||||
});
|
||||
expect(await manualSurface.locator("[data-scrollbars='y']").evaluate((element) => element.scrollTop)).toBe(120);
|
||||
expect(
|
||||
await manualSurface.locator("[data-shift-scroll-probe='unrelated']").evaluate((element) => element.scrollTop),
|
||||
).toBe(0);
|
||||
|
||||
await manualSurface.click({ button: "right", position: { x: 80, y: 80 } });
|
||||
await expect(portalledMenuItem).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(portalledMenuItem).toBeHidden();
|
||||
await expect(manualSurface).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(manualSurface).toBeHidden();
|
||||
await expect(advancedViewTrigger).toBeFocused();
|
||||
await expect(header).not.toHaveAttribute("inert", "");
|
||||
await expect(boardMain).not.toHaveAttribute("inert", "");
|
||||
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe("hidden");
|
||||
|
||||
await widget.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
|
||||
await expect(manualSurface).toBeVisible();
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error("Board page has no viewport");
|
||||
await page.mouse.click(4, viewport.height - 4);
|
||||
await expect(manualSurface).toBeHidden();
|
||||
await expect(advancedViewTrigger).toBeFocused();
|
||||
|
||||
for (const compactOnlyKind of ["app", "iframe"]) {
|
||||
const compactOnlyWidget = page
|
||||
.locator(`[data-grid-item-id][data-kind='${compactOnlyKind}'] [data-advanced-focus-source]`)
|
||||
.first();
|
||||
await expect(compactOnlyWidget.locator("[data-advanced-focus-trigger]")).toHaveCount(0);
|
||||
await compactOnlyWidget.dispatchEvent("contextmenu", { button: 2 });
|
||||
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.down("Shift");
|
||||
await compactOnlyWidget.hover({ position: { x: 4, y: 4 } });
|
||||
await expect(previewSurface).toBeHidden();
|
||||
await expect(dimmingOverlay).toBeHidden();
|
||||
await page.keyboard.up("Shift");
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("keeps compact-only iframe state and dismisses retained focus when a responsive layout remounts", async () => {
|
||||
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
|
||||
|
||||
try {
|
||||
const iframeSlot = page.locator("[data-grid-item-id][data-kind='iframe'] [data-advanced-focus-source]").first();
|
||||
const iframe = iframeSlot.locator("iframe");
|
||||
await iframe.evaluate((element) => {
|
||||
(element as HTMLIFrameElement).srcdoc = '<label>State <input id="state" value="initial"></label>';
|
||||
(element as HTMLIFrameElement).dataset.advancedFocusProbe = "true";
|
||||
});
|
||||
const frame = page.frameLocator("iframe[data-advanced-focus-probe='true']");
|
||||
await frame.locator("#state").fill("changed");
|
||||
|
||||
await expect(iframeSlot.locator("[data-advanced-focus-trigger]")).toHaveCount(0);
|
||||
await iframeSlot.dispatchEvent("contextmenu", { button: 2 });
|
||||
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.down("Shift");
|
||||
await iframeSlot.hover({ position: { x: 4, y: 4 } });
|
||||
await expect(page.getByRole("region", { name: "iFrame advanced view" })).toHaveCount(0);
|
||||
await page.keyboard.up("Shift");
|
||||
await expect(frame.locator("#state")).toHaveValue("changed");
|
||||
|
||||
const bookmarksSlot = page
|
||||
.locator("[data-grid-item-id][data-kind='bookmarks'] [data-advanced-focus-source]")
|
||||
.first();
|
||||
await bookmarksSlot.getByRole("button", { name: "Open advanced view" }).focus();
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeVisible();
|
||||
await page.setViewportSize({ width: 800, height: 900 });
|
||||
await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeHidden();
|
||||
await expect(page.locator("[data-grid-item-id][data-kind='iframe'] iframe")).toBeVisible();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("keeps advanced controls below the fixed header on short screens", async () => {
|
||||
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials, { width: 1366, height: 768 });
|
||||
|
||||
try {
|
||||
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
|
||||
await widget.getByRole("button", { name: "Open advanced view" }).focus();
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
const closeButton = page.getByRole("button", { name: "Close advanced view" });
|
||||
const bounds = await closeButton.boundingBox();
|
||||
expect(bounds).not.toBeNull();
|
||||
if (!bounds) return;
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(60);
|
||||
expect(bounds.width).toBeGreaterThanOrEqual(44);
|
||||
expect(bounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366);
|
||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(768);
|
||||
const surfaceBounds = await page.getByRole("dialog", { name: "Date and time advanced view" }).boundingBox();
|
||||
const contentBounds = await page.locator(".clock-widget-container").boundingBox();
|
||||
expect(surfaceBounds).not.toBeNull();
|
||||
expect(contentBounds).not.toBeNull();
|
||||
if (surfaceBounds && contentBounds) {
|
||||
expect(contentBounds.y - surfaceBounds.y).toBeLessThan(32);
|
||||
expect(bounds.x >= surfaceBounds.x + surfaceBounds.width || bounds.x + bounds.width <= surfaceBounds.x).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
await closeButton.click();
|
||||
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("honors the user setting gate even when the user can modify the board", async () => {
|
||||
const { context, page } = await openBoardAsync(browser, baseUrl, disabledModifierCredentials);
|
||||
|
||||
try {
|
||||
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
|
||||
|
||||
await widget.click({ button: "right" });
|
||||
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("keeps read-only advanced viewing", async () => {
|
||||
const { context, page } = await openBoardAsync(browser, baseUrl, viewerCredentials);
|
||||
|
||||
try {
|
||||
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
|
||||
|
||||
await widget.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
const openBoardAsync = async (
|
||||
browser: Browser,
|
||||
baseUrl: string,
|
||||
credentials: { username: string; password: string },
|
||||
viewport = { width: 1280, height: 900 },
|
||||
): Promise<{ context: BrowserContext; page: Page }> => {
|
||||
const context = await browser.newContext({ reducedMotion: "reduce", viewport });
|
||||
const page = await context.newPage();
|
||||
|
||||
await loginAsync({ page, baseUrl, credentials, destination: `/boards/${boardName}` });
|
||||
await expect(page.locator(".clock-wrapper").first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
return { context, page };
|
||||
};
|
||||
|
||||
const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => {
|
||||
const boardId = createId();
|
||||
const baseLayoutId = createId();
|
||||
const desktopLayoutId = createId();
|
||||
const sectionId = createId();
|
||||
const clockItemId = createId();
|
||||
const bookmarksItemId = createId();
|
||||
const iframeItemId = createId();
|
||||
const appItemId = createId();
|
||||
const appId = createId();
|
||||
|
||||
await db.insert(boards).values({ id: boardId, name: boardName, creatorId: ownerId, isPublic: false });
|
||||
await db.insert(apps).values({
|
||||
id: appId,
|
||||
name: "Example app",
|
||||
iconUrl: "/favicon.ico",
|
||||
href: "https://example.com",
|
||||
});
|
||||
await db.insert(layouts).values([
|
||||
{ id: baseLayoutId, name: "Base", columnCount: 12, breakpoint: 0, boardId },
|
||||
{ id: desktopLayoutId, name: "Desktop", columnCount: 12, breakpoint: 1000, boardId },
|
||||
]);
|
||||
await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId });
|
||||
await db.insert(items).values([
|
||||
{
|
||||
id: clockItemId,
|
||||
kind: "clock",
|
||||
boardId,
|
||||
options: stringify({ is24HourFormat: true, showSeconds: false, showDate: true }),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
{
|
||||
id: bookmarksItemId,
|
||||
kind: "bookmarks",
|
||||
boardId,
|
||||
options: stringify({
|
||||
title: "",
|
||||
layout: "column",
|
||||
hideTitle: false,
|
||||
hideIcon: false,
|
||||
hideHostname: false,
|
||||
openNewTab: true,
|
||||
withBorder: false,
|
||||
items: [appId],
|
||||
}),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
{
|
||||
id: iframeItemId,
|
||||
kind: "iframe",
|
||||
boardId,
|
||||
options: stringify({ embedUrl: "http://127.0.0.1:9", allowScrolling: true }),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
{
|
||||
id: appItemId,
|
||||
kind: "app",
|
||||
boardId,
|
||||
options: stringify({ appId, openInNewTab: true, showTitle: true, pingEnabled: false }),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
]);
|
||||
await db.insert(itemLayouts).values(
|
||||
[baseLayoutId, desktopLayoutId].flatMap((layoutId) => [
|
||||
{
|
||||
itemId: clockItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 3,
|
||||
},
|
||||
{
|
||||
itemId: bookmarksItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 3,
|
||||
width: 4,
|
||||
height: 3,
|
||||
},
|
||||
{
|
||||
itemId: iframeItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 8,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 3,
|
||||
},
|
||||
{
|
||||
itemId: appItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 4,
|
||||
yOffset: 0,
|
||||
width: 3,
|
||||
height: 3,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ homeBoardId: boardId, completedBoardTour: true, completedManageTour: true })
|
||||
.where(eq(users.id, ownerId));
|
||||
};
|
||||
|
||||
const seedCredentialsUserAsync = async (
|
||||
db: SqliteDatabase,
|
||||
input: {
|
||||
username: string;
|
||||
password: string;
|
||||
enableRightClickOnWidgets: boolean;
|
||||
boardPermission: "view" | "modify";
|
||||
},
|
||||
) => {
|
||||
const userId = createId();
|
||||
const board = await db.query.boards.findFirst({ where: eq(boards.name, boardName) });
|
||||
if (!board) throw new Error("Interaction board was not seeded");
|
||||
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: input.username,
|
||||
provider: "credentials",
|
||||
password: await hashPasswordAsync(input.password),
|
||||
enableRightClickOnWidgets: input.enableRightClickOnWidgets,
|
||||
homeBoardId: board.id,
|
||||
completedBoardTour: true,
|
||||
completedManageTour: true,
|
||||
});
|
||||
await db.insert(boardUserPermissions).values({
|
||||
boardId: board.id,
|
||||
userId,
|
||||
permission: input.boardPermission,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { stringify as stringifySuperJSON } from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import * as sqliteSchema from "../packages/db/schema/sqlite";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import { loginAsync } from "./shared/login";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
const credentials = { username: "admin", password: "Comp(exP4sswOrd" };
|
||||
|
||||
const captureScreenshotAsync = async (page: Page, name: string) => {
|
||||
const directory = process.env.HOMARR_E2E_SCREENSHOT_DIR?.trim();
|
||||
if (!directory) return;
|
||||
await mkdir(directory, { recursive: true });
|
||||
await page.screenshot({ path: path.join(directory, `manage-${name}.png`), fullPage: true });
|
||||
};
|
||||
|
||||
describe("Management collections", () => {
|
||||
test("renders unified collections and a compact board preview on desktop and mobile", async () => {
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const { userId } = await seedAdminUserAsync(db, credentials);
|
||||
const boardId = "manage-board";
|
||||
const layoutId = "manage-layout";
|
||||
const sectionId = "manage-section";
|
||||
const appId = "manage-app";
|
||||
|
||||
await db.insert(sqliteSchema.apps).values({
|
||||
id: appId,
|
||||
name: "Very long service name used to verify responsive management rows",
|
||||
description: "A useful service with enough detail to exercise the shared collection description slot.",
|
||||
iconUrl: "/favicon.ico",
|
||||
href: "https://service.example.internal/a/very/long/reverse/proxy/path/that/must/not/overflow",
|
||||
pingUrl: "https://service.example.internal/health",
|
||||
});
|
||||
await db.insert(sqliteSchema.integrations).values({
|
||||
id: "manage-integration",
|
||||
name: "Living room Sonarr",
|
||||
url: "https://sonarr.example.internal/a/long/reverse/proxy/path",
|
||||
kind: "sonarr",
|
||||
appId,
|
||||
});
|
||||
await db.insert(sqliteSchema.searchEngines).values({
|
||||
id: "manage-search-engine",
|
||||
iconUrl: "/favicon.ico",
|
||||
name: "Internal documentation",
|
||||
short: "docs",
|
||||
description: "Search private documentation from Spotlight.",
|
||||
urlTemplate: "https://docs.example.internal/search?q=%s",
|
||||
type: "generic",
|
||||
});
|
||||
await db.insert(sqliteSchema.boards).values({
|
||||
id: boardId,
|
||||
name: "management-preview",
|
||||
creatorId: userId,
|
||||
isPublic: false,
|
||||
});
|
||||
await db.insert(sqliteSchema.layouts).values({
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
boardId,
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
});
|
||||
await db.insert(sqliteSchema.sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
await db.insert(sqliteSchema.items).values([
|
||||
{
|
||||
id: "manage-clock",
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: stringifySuperJSON({ is24HourFormat: true }),
|
||||
},
|
||||
{
|
||||
id: "manage-app-item",
|
||||
boardId,
|
||||
kind: "app",
|
||||
options: stringifySuperJSON({ appId, openInNewTab: true, showTitle: true }),
|
||||
},
|
||||
]);
|
||||
await db.insert(sqliteSchema.itemLayouts).values([
|
||||
{ itemId: "manage-clock", sectionId, layoutId, xOffset: 0, yOffset: 0, width: 2, height: 2 },
|
||||
{ itemId: "manage-app-item", sectionId, layoutId, xOffset: 2, yOffset: 0, width: 1, height: 1 },
|
||||
]);
|
||||
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: { AUTH_PROVIDERS: "credentials" },
|
||||
mounts: { "/appdata": localMountPath },
|
||||
}).start();
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await loginAsync({ page, baseUrl, credentials, destination: "/manage/apps" });
|
||||
await page.getByRole("heading", { name: "Apps", exact: true }).waitFor();
|
||||
expect(await page.getByRole("list", { name: "Configured apps" }).getByRole("listitem").count()).toBe(1);
|
||||
const editApp = page.getByRole("link", { name: /Edit Very long service name/ });
|
||||
await editApp.focus();
|
||||
expect(await editApp.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
expect((await editApp.boundingBox())?.height).toBeGreaterThanOrEqual(44);
|
||||
|
||||
await page.goto(`${baseUrl}/manage/integrations`);
|
||||
await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor();
|
||||
expect(await page.getByRole("list", { name: "Configured integrations" }).getByRole("listitem").count()).toBe(1);
|
||||
expect(
|
||||
await page
|
||||
.getByRole("list", { name: "Configured integrations" })
|
||||
.getByRole("listitem")
|
||||
.getByText("Sonarr", { exact: true })
|
||||
.isVisible(),
|
||||
).toBe(true);
|
||||
|
||||
await page.goto(`${baseUrl}/manage/search-engines`);
|
||||
await page.getByRole("heading", { name: "Search engines", exact: true }).waitFor();
|
||||
expect(await page.getByRole("list", { name: "Configured search engines" }).getByRole("listitem").count()).toBe(1);
|
||||
|
||||
await page.goto(`${baseUrl}/manage/boards`);
|
||||
await page.getByRole("heading", { name: "Your boards", exact: true }).waitFor();
|
||||
const preview = page.getByRole("img", { name: "Layout preview for management-preview, with 2 items" });
|
||||
await preview.waitFor();
|
||||
expect(await preview.locator('[data-lane="main"]').count()).toBe(1);
|
||||
await captureScreenshotAsync(page, "desktop-boards");
|
||||
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
for (const route of ["apps", "integrations", "search-engines", "boards"]) {
|
||||
await page.goto(`${baseUrl}/manage/${route}`);
|
||||
await page.locator("h1:visible").last().waitFor();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
}
|
||||
await captureScreenshotAsync(page, "mobile-boards");
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
+397
-13
@@ -1,15 +1,244 @@
|
||||
import { chromium } from "@playwright/test";
|
||||
import { describe, test } from "vitest";
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import AdmZip from "adm-zip";
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import * as sqliteSchema from "../packages/db/schema/sqlite";
|
||||
import { OnboardingActions } from "./shared/actions/onboarding-actions";
|
||||
import { OnboardingAssertions } from "./shared/assertions/onboarding-assertions";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import { loginAsync } from "./shared/login";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
const captureScreenshotAsync = async (page: Page, name: string) => {
|
||||
const directory = process.env.HOMARR_E2E_SCREENSHOT_DIR?.trim();
|
||||
if (!directory) return;
|
||||
await mkdir(directory, { recursive: true });
|
||||
await page.screenshot({ path: path.join(directory, `onboarding-${name}.png`), fullPage: true });
|
||||
};
|
||||
|
||||
type SqliteTestDatabase = Awaited<ReturnType<typeof createSqliteDbFileAsync>>;
|
||||
|
||||
const cleanupSqliteDbAsync = async ({ db, localMountPath }: SqliteTestDatabase) => {
|
||||
if (db.$client.open) db.$client.close();
|
||||
await rm(localMountPath, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
const createRestoreArchiveAsync = async () => {
|
||||
const sqlite = await createSqliteDbFileAsync();
|
||||
const { db, localMountPath } = sqlite;
|
||||
try {
|
||||
await db.insert(sqliteSchema.onboarding).values({ id: "onboarding", step: "finish", previousStep: "setup" });
|
||||
await db.insert(sqliteSchema.boards).values({ id: "restored-board", name: "restored", isPublic: true });
|
||||
await db.insert(sqliteSchema.layouts).values({
|
||||
id: "restored-base",
|
||||
boardId: "restored-board",
|
||||
name: "Base",
|
||||
role: "base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
});
|
||||
await db.insert(sqliteSchema.sections).values({
|
||||
id: "restored-root",
|
||||
boardId: "restored-board",
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
db.$client.close();
|
||||
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", await readFile(path.join(localMountPath, "db", "db.sqlite")));
|
||||
zip.addFile(
|
||||
"metadata.json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
homarrVersion: "e2e",
|
||||
exportedAt: new Date().toISOString(),
|
||||
dbDialect: "sqlite",
|
||||
encryptionKey: "0".repeat(64),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return zip.toBuffer();
|
||||
} finally {
|
||||
await cleanupSqliteDbAsync(sqlite);
|
||||
}
|
||||
};
|
||||
|
||||
describe("Onboarding", () => {
|
||||
test.each([
|
||||
{ name: "mobile", width: 320, height: 740 },
|
||||
{ name: "tablet", width: 768, height: 1024 },
|
||||
{ name: "desktop", width: 1440, height: 900 },
|
||||
])(
|
||||
"Onboarding is usable at the $name viewport",
|
||||
async ({ name, width, height }) => {
|
||||
const sqlite = await createSqliteDbFileAsync();
|
||||
const { localMountPath } = sqlite;
|
||||
const homarrContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width, height }, reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/init`);
|
||||
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
|
||||
const start = page.getByRole("button", { name: "Get started" });
|
||||
await start.focus();
|
||||
await start.press("Enter");
|
||||
await page.getByRole("heading", { name: "Create your administrator" }).waitFor();
|
||||
|
||||
await page.getByLabel("Administrator username").fill("admin");
|
||||
await page.getByLabel("Password", { exact: true }).fill("Comp(exP4sswOrd");
|
||||
await page.getByLabel("Confirm password").fill("Comp(exP4sswOrd");
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor({ timeout: 30_000 });
|
||||
|
||||
const serverOrigin = page.getByLabel("Usual server address");
|
||||
await serverOrigin.fill("https://home.example/homarr");
|
||||
await page.getByLabel("Default language").click();
|
||||
await page.getByRole("option", { name: /Français/ }).click();
|
||||
expect(await serverOrigin.inputValue()).toBe("https://home.example/homarr");
|
||||
expect(await page.getByRole("heading", { name: "Start with familiar defaults" }).isVisible()).toBe(true);
|
||||
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
await captureScreenshotAsync(page, `${name}-studio`);
|
||||
|
||||
const activeSection = page.locator('[aria-current="step"]');
|
||||
await activeSection.focus();
|
||||
await activeSection.press("ArrowLeft");
|
||||
await page.getByRole("heading", { name: "One board, built around your services" }).waitFor();
|
||||
expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
await activeSection.press("ArrowRight");
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
await activeSection.press("End");
|
||||
await page.getByRole("heading", { name: "One board, built around your services" }).waitFor();
|
||||
await activeSection.press("Home");
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
|
||||
const sections = [
|
||||
["Discover", "See what this installation can actually reach"],
|
||||
["Connect", "Connect what makes the board useful"],
|
||||
["Board", "Shape the board before it is built"],
|
||||
["Extend", "Advanced features"],
|
||||
] as const;
|
||||
for (const [section, heading] of sections) {
|
||||
await page.getByRole("button", { name: new RegExp(`^${section}`) }).click();
|
||||
await page.getByRole("heading", { name: heading }).waitFor();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: /^Board/ }).click();
|
||||
const boardPreview = page.locator('figure[aria-label="Live preview of the first board layout"]');
|
||||
expect(await boardPreview.getAttribute("data-layout-role")).toBe("base");
|
||||
expect(await boardPreview.getAttribute("data-layout-columns")).toBe("10");
|
||||
const columnSlider = page.getByRole("slider", { name: "Board columns" });
|
||||
await columnSlider.press("ArrowRight");
|
||||
await columnSlider.press("ArrowRight");
|
||||
expect(await boardPreview.getAttribute("data-layout-columns")).toBe("12");
|
||||
|
||||
await page.getByRole("button", { name: /^Connect/ }).click();
|
||||
const sonarr = page.locator('button[aria-label="Sonarr"]');
|
||||
await sonarr.click();
|
||||
await page.getByLabel("Service URL").waitFor();
|
||||
await captureScreenshotAsync(page, `${name}-integration-details`);
|
||||
await sonarr.click();
|
||||
await page.getByLabel("Service URL").waitFor({ state: "detached" });
|
||||
|
||||
await activeSection.focus();
|
||||
await activeSection.press("End");
|
||||
await page.getByRole("heading", { name: "One board, built around your services" }).waitFor();
|
||||
await captureScreenshotAsync(page, `${name}-review`);
|
||||
|
||||
await page.getByRole("button", { name: "Build my board" }).click();
|
||||
await page.getByRole("heading", { name: "Your first board is ready." }).waitFor({ timeout: 30_000 });
|
||||
await captureScreenshotAsync(page, `${name}-finish`);
|
||||
|
||||
await page.getByRole("link", { name: "Open my board" }).click();
|
||||
await page.waitForURL(/\/boards\/dashboard(?:[/?#]|$)/);
|
||||
const boardCanvas = page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]');
|
||||
await boardCanvas.waitFor({ timeout: 30_000 });
|
||||
expect(await boardCanvas.locator("[data-grid-item-id]").count()).toBeGreaterThanOrEqual(3);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
} finally {
|
||||
try {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
} finally {
|
||||
await cleanupSqliteDbAsync(sqlite);
|
||||
}
|
||||
}
|
||||
},
|
||||
90_000,
|
||||
);
|
||||
|
||||
test.skip("SQLite onboarding restores a validated backup and survives the restart", async () => {
|
||||
const restoreArchive = await createRestoreArchiveAsync();
|
||||
const invalidArchive = new AdmZip();
|
||||
invalidArchive.addFile("db.sqlite", Buffer.from("not-a-database"));
|
||||
const sqlite = await createSqliteDbFileAsync();
|
||||
const { localMountPath } = sqlite;
|
||||
const initialContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
|
||||
let initialContainerStopped = false;
|
||||
let restoredContainer: StartedTestContainer | undefined;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
const initialBaseUrl = `http://${initialContainer.getHost()}:${initialContainer.getMappedPort(7575)}`;
|
||||
await page.goto(`${initialBaseUrl}/init`);
|
||||
await page.getByRole("button", { name: "Restore backup" }).click();
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: "missing-metadata.zip",
|
||||
mimeType: "application/zip",
|
||||
buffer: invalidArchive.toBuffer(),
|
||||
});
|
||||
await page.getByText("Invalid backup: missing metadata.json").waitFor();
|
||||
await page.getByRole("button", { name: "Change file" }).click();
|
||||
|
||||
await fileInput.setInputFiles({ name: "homarr-backup.zip", mimeType: "application/zip", buffer: restoreArchive });
|
||||
await page.getByRole("heading", { name: "Backup Contents" }).waitFor({ timeout: 30_000 });
|
||||
expect(await page.getByText("restored", { exact: true }).isVisible()).toBe(true);
|
||||
await page.getByRole("button", { name: "Continue to restore" }).click();
|
||||
await page.getByPlaceholder("I understand").fill("I understand");
|
||||
const restoreResponse = page.waitForResponse(
|
||||
(response) => response.url().endsWith("/api/backup/import") && response.request().method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: "Restore Database" }).click();
|
||||
expect((await restoreResponse).status()).toBe(200);
|
||||
|
||||
await initialContainer.stop();
|
||||
initialContainerStopped = true;
|
||||
restoredContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
|
||||
const restoredBaseUrl = `http://${restoredContainer.getHost()}:${restoredContainer.getMappedPort(7575)}`;
|
||||
await page.goto(`${restoredBaseUrl}/boards/restored`);
|
||||
await page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]').waitFor({ timeout: 30_000 });
|
||||
expect(page.url()).toMatch(/\/boards\/restored(?:[/?#]|$)/);
|
||||
} finally {
|
||||
try {
|
||||
await browser.close();
|
||||
if (!initialContainerStopped) await initialContainer.stop();
|
||||
if (restoredContainer) await restoredContainer.stop();
|
||||
} finally {
|
||||
await cleanupSqliteDbAsync(sqlite);
|
||||
}
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("Credentials onboarding should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const sqlite = await createSqliteDbFileAsync();
|
||||
const { db, localMountPath } = sqlite;
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
@@ -25,7 +254,15 @@ describe("Onboarding", () => {
|
||||
try {
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.startOnboardingAsync();
|
||||
|
||||
const competingContext = await browser.newContext();
|
||||
const claimResponse = await competingContext.request.post(
|
||||
`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/api/onboarding/claim`,
|
||||
);
|
||||
expect(claimResponse.status()).toBe(423);
|
||||
await competingContext.close();
|
||||
|
||||
await actions.processUserStepAsync({
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
@@ -39,13 +276,52 @@ describe("Onboarding", () => {
|
||||
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
try {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
} finally {
|
||||
await cleanupSqliteDbAsync(sqlite);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("External provider onboarding setup should be successful", async () => {
|
||||
test("Credentials onboarding recovers when automatic sign-in fails after account creation", async () => {
|
||||
const sqlite = await createSqliteDbFileAsync();
|
||||
const { db, localMountPath } = sqlite;
|
||||
const homarrContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
await page.goto(`${baseUrl}/init`);
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await page.getByRole("heading", { name: "Create your administrator" }).waitFor();
|
||||
await page.route(/\/api\/auth\/callback\/credentials(?:\?.*)?$/, (route) => route.abort("failed"));
|
||||
await page.getByLabel("Administrator username").fill("admin");
|
||||
await page.getByLabel("Password", { exact: true }).fill("Comp(exP4sswOrd");
|
||||
await page.getByLabel("Confirm password").fill("Comp(exP4sswOrd");
|
||||
await page.locator("button[type='submit']").click();
|
||||
|
||||
await page.getByRole("heading", { name: "Administrator account created" }).waitFor();
|
||||
const recovery = page.getByRole("link", { name: "Sign in and continue" });
|
||||
expect(await recovery.getAttribute("href")).toBe("/auth/login?callbackUrl=/init");
|
||||
expect(await recovery.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
expect((await db.query.users.findMany()).length).toBe(1);
|
||||
expect((await db.query.onboarding.findFirst())?.step).toBe("setup");
|
||||
} finally {
|
||||
try {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
} finally {
|
||||
await cleanupSqliteDbAsync(sqlite);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("External provider onboarding requires sign-in before privileged setup", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const sqlite = await createSqliteDbFileAsync();
|
||||
const { db, localMountPath } = sqlite;
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
AUTH_PROVIDERS: "ldap",
|
||||
@@ -69,19 +345,127 @@ describe("Onboarding", () => {
|
||||
try {
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.startOnboardingAsync();
|
||||
await actions.processExternalGroupStepAsync({
|
||||
name: externalGroupName,
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
await actions.processIntegrationsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await page.getByRole("heading", { name: "Administrator sign-in required" }).waitFor();
|
||||
expect(await page.getByRole("link", { name: "Sign in" }).getAttribute("href")).toBe(
|
||||
"/auth/login?callbackUrl=%2Finit",
|
||||
);
|
||||
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
await assertions.assertDbOnboardingStepAsync("setup");
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
try {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
} finally {
|
||||
await cleanupSqliteDbAsync(sqlite);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Legacy setup requires sign-in without exposing private board metadata", async () => {
|
||||
const credentials = { username: "admin", password: "Comp(exP4sswOrd" };
|
||||
const sqlite = await createSqliteDbFileAsync();
|
||||
const { db, localMountPath } = sqlite;
|
||||
await seedAdminUserAsync(db, credentials);
|
||||
await db.update(sqliteSchema.onboarding).set({ step: "import" as never });
|
||||
const privateBoardName = "private-board-must-not-leak";
|
||||
await db.insert(sqliteSchema.boards).values({ id: createId(), name: privateBoardName, isPublic: false });
|
||||
|
||||
const privateWorkshopApiUrl = "http://internal-workshop-api.invalid";
|
||||
const privateWorkshopWebUrl = "http://internal-workshop-web.invalid";
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
WORKSHOP_API_URL: privateWorkshopApiUrl,
|
||||
WORKSHOP_WEB_URL: privateWorkshopWebUrl,
|
||||
},
|
||||
mounts: { "/appdata": localMountPath },
|
||||
}).start();
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
const response = await context.request.get(`${baseUrl}/init`);
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(response.url()).toBe(`${baseUrl}/init`);
|
||||
const responseBody = await response.text();
|
||||
expect(responseBody).not.toContain(privateBoardName);
|
||||
expect(responseBody).not.toContain(privateWorkshopApiUrl);
|
||||
expect(responseBody).not.toContain(privateWorkshopWebUrl);
|
||||
|
||||
await page.goto(`${baseUrl}/init`);
|
||||
await page.getByRole("heading", { name: "Administrator sign-in required" }).waitFor();
|
||||
expect(await page.getByRole("link", { name: "Sign in and resume" }).getAttribute("href")).toBe(
|
||||
"/auth/login?callbackUrl=%2Finit",
|
||||
);
|
||||
|
||||
await loginAsync({ page, baseUrl, credentials, destination: "/init" });
|
||||
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
expect(await page.getByRole("heading", { name: "Create your administrator" }).count()).toBe(0);
|
||||
} finally {
|
||||
try {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
} finally {
|
||||
await cleanupSqliteDbAsync(sqlite);
|
||||
}
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test("Assistant setup restores saved configuration and accepts a manual model", async () => {
|
||||
const credentials = { username: "admin", password: "Comp(exP4sswOrd" };
|
||||
const sqlite = await createSqliteDbFileAsync();
|
||||
const { db, localMountPath } = sqlite;
|
||||
await seedAdminUserAsync(db, credentials);
|
||||
await db.update(sqliteSchema.onboarding).set({ step: "setup", previousStep: "group" });
|
||||
await db.insert(sqliteSchema.assistantConfigurations).values({
|
||||
id: "default",
|
||||
enabled: true,
|
||||
provider: "custom",
|
||||
baseUrl: "http://assistant.local/v1",
|
||||
modelDiscoveryPath: null,
|
||||
modelId: "local-model",
|
||||
});
|
||||
|
||||
const configuredWorkshopApiUrl = "http://authenticated-workshop-api.invalid";
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: { WORKSHOP_API_URL: configuredWorkshopApiUrl },
|
||||
mounts: { "/appdata": localMountPath },
|
||||
}).start();
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await loginAsync({ page, baseUrl, credentials, destination: "/init" });
|
||||
expect(await page.locator('meta[name="homarr-workshop-api-url"]').getAttribute("content")).toBe(
|
||||
configuredWorkshopApiUrl,
|
||||
);
|
||||
await page.getByRole("button", { name: "Extend (5/6)" }).click();
|
||||
await page.getByRole("heading", { name: "Advanced features" }).waitFor();
|
||||
await page.getByRole("tab", { name: "Assistant" }).click();
|
||||
await page.getByLabel("API base URL").waitFor();
|
||||
|
||||
const provider = page.getByRole("combobox", { name: "Provider", exact: true });
|
||||
await expect.poll(() => provider.inputValue(), { timeout: 10_000 }).toBe("Custom endpoint");
|
||||
expect(await page.getByLabel("API base URL").inputValue()).toBe("http://assistant.local/v1");
|
||||
expect(await page.locator('input[value="local-model"]').inputValue()).toBe("local-model");
|
||||
expect(await page.getByText("Discovery is unavailable. You can still enter a model ID manually.").count()).toBe(
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
} finally {
|
||||
await cleanupSqliteDbAsync(sqlite);
|
||||
}
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -27,14 +27,14 @@ export class OnboardingActions {
|
||||
}
|
||||
}
|
||||
|
||||
public async startOnboardingAsync(type: "scratch" | "before 1.0") {
|
||||
await this.page.locator("button", { hasText: type }).click();
|
||||
public async startOnboardingAsync() {
|
||||
await this.page.getByRole("button", { name: "Get started" }).click();
|
||||
}
|
||||
|
||||
public async processUserStepAsync(input: { username: string; password: string; confirmPassword: string }) {
|
||||
await this.page.waitForSelector("text=administrator user");
|
||||
await this.page.getByRole("heading", { name: "Create your administrator" }).waitFor();
|
||||
|
||||
await this.page.getByLabel("Username").fill(input.username);
|
||||
await this.page.getByLabel("Administrator username").fill(input.username);
|
||||
await this.page.getByLabel("Password", { exact: true }).fill(input.password);
|
||||
await this.page.getByLabel("Confirm password").fill(input.confirmPassword);
|
||||
|
||||
@@ -42,18 +42,17 @@ export class OnboardingActions {
|
||||
}
|
||||
|
||||
public async processExternalGroupStepAsync(input: { name: string }) {
|
||||
await this.page.waitForSelector("text=external provider");
|
||||
await this.page.locator("input").fill(input.name);
|
||||
await this.page.getByRole("heading", { name: "Connect your administrator group" }).waitFor();
|
||||
await this.page.getByLabel("External administrator group").fill(input.name);
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
}
|
||||
|
||||
public async processSettingsStepAsync() {
|
||||
await this.page.waitForSelector("text=Analytics");
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
await this.page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
}
|
||||
|
||||
public async processIntegrationsStepAsync() {
|
||||
await this.page.waitForSelector("text=integrations you'd like to connect");
|
||||
await this.page.locator("button", { hasText: "Skip for now" }).click();
|
||||
await this.page.getByRole("button", { name: "Review (6/6)" }).click();
|
||||
await this.page.getByRole("button", { name: "Build my board" }).click();
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,6 @@ export class OnboardingAssertions {
|
||||
}
|
||||
|
||||
public async assertFinishStepVisibleAsync() {
|
||||
await this.page.waitForSelector("text=completed the setup", { timeout: 30_000 });
|
||||
await this.page.getByRole("heading", { name: "Your first board is ready." }).waitFor({ timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,13 @@ export const seedAdminUserAsync = async (db: SqliteDatabase, input: SeedAdminUse
|
||||
if (existingOnboarding) {
|
||||
await db.update(sqliteSchema.onboarding).set({
|
||||
step: "finish",
|
||||
previousStep: "settings",
|
||||
previousStep: "setup",
|
||||
});
|
||||
} else {
|
||||
await db.insert(sqliteSchema.onboarding).values({
|
||||
id: createId(),
|
||||
step: "finish",
|
||||
previousStep: "settings",
|
||||
previousStep: "setup",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"./websocket": "./src/websocket.ts",
|
||||
"./shared": "./src/shared.ts",
|
||||
"./open-api": "./src/open-api.ts",
|
||||
"./onboarding-claim": "./src/onboarding-claim.ts",
|
||||
"./assistant": "./src/assistant.ts",
|
||||
"./onboarding-step": "./src/onboarding-step.ts",
|
||||
"./custom-widget/display-data": "./src/router/custom-widget/display-data.ts",
|
||||
"./mcp": "./src/mcp.ts",
|
||||
"./custom-widget-configuration": "./src/custom-widget-configuration.ts"
|
||||
@@ -41,8 +43,6 @@
|
||||
"@homarr/docker": "workspace:^0.1.0",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/old-import": "workspace:^0.1.0",
|
||||
"@homarr/old-schema": "workspace:^0.1.0",
|
||||
"@homarr/ping": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/request-handler": "workspace:^0.1.0",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { serverSettings } from "@homarr/db/schema";
|
||||
|
||||
export const onboardingClaimCookieName = "homarr-onboarding-claim";
|
||||
export const onboardingClaimSettingKey = "__onboarding_claim";
|
||||
export const onboardingClaimTtlMs = 4 * 60 * 60 * 1000;
|
||||
|
||||
interface StoredOnboardingClaim {
|
||||
hash: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export type ClaimOnboardingResult =
|
||||
| { status: "issued" | "active"; token: string; expiresAt: number }
|
||||
| { status: "locked"; expiresAt: number }
|
||||
| { status: "finished" | "forbidden" };
|
||||
|
||||
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
|
||||
|
||||
const parseStoredClaim = (value: string): StoredOnboardingClaim | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<StoredOnboardingClaim>;
|
||||
if (typeof parsed.hash !== "string" || !/^[0-9a-f]{64}$/.test(parsed.hash)) return null;
|
||||
if (typeof parsed.expiresAt !== "number" || !Number.isSafeInteger(parsed.expiresAt)) return null;
|
||||
return { hash: parsed.hash, expiresAt: parsed.expiresAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const tokenMatches = (claim: StoredOnboardingClaim, token: string, now: number) => {
|
||||
if (claim.expiresAt <= now || token.length === 0) return false;
|
||||
const expected = Buffer.from(claim.hash, "hex");
|
||||
const actual = Buffer.from(hashToken(token), "hex");
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
};
|
||||
|
||||
const getClaimRowAsync = async (db: Database) =>
|
||||
await db.query.serverSettings.findFirst({ where: eq(serverSettings.settingKey, onboardingClaimSettingKey) });
|
||||
|
||||
export const getOnboardingClaimTokenFromCookieHeader = (cookieHeader: string | null) => {
|
||||
if (!cookieHeader) return undefined;
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const separator = part.indexOf("=");
|
||||
if (separator < 0) continue;
|
||||
const name = part.slice(0, separator).trim();
|
||||
if (name !== onboardingClaimCookieName) continue;
|
||||
const value = part.slice(separator + 1).trim();
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const isOnboardingClaimValidAsync = async (db: Database, token: string | undefined, now = Date.now()) => {
|
||||
if (!token) return false;
|
||||
const row = await getClaimRowAsync(db);
|
||||
const claim = row ? parseStoredClaim(row.value) : null;
|
||||
return claim ? tokenMatches(claim, token, now) : false;
|
||||
};
|
||||
|
||||
export const isClaimOnlyOnboardingAccessAllowedAsync = async (
|
||||
db: Database,
|
||||
token: string | undefined,
|
||||
now = Date.now(),
|
||||
) => {
|
||||
const existingUser = await db.query.users.findFirst({ columns: { id: true } });
|
||||
return !existingUser && (await isOnboardingClaimValidAsync(db, token, now));
|
||||
};
|
||||
|
||||
export const claimOnboardingAsync = async (
|
||||
db: Database,
|
||||
options: { currentToken?: string; force?: boolean; now?: number; ttlMs?: number } = {},
|
||||
): Promise<ClaimOnboardingResult> => {
|
||||
const onboarding = await db.query.onboarding.findFirst();
|
||||
if (onboarding?.step === "finish") return { status: "finished" };
|
||||
const existingUser = await db.query.users.findFirst({ columns: { id: true } });
|
||||
if (existingUser && !options.force) return { status: "forbidden" };
|
||||
|
||||
const now = options.now ?? Date.now();
|
||||
const currentRow = await getClaimRowAsync(db);
|
||||
const currentClaim = currentRow ? parseStoredClaim(currentRow.value) : null;
|
||||
if (currentClaim && options.currentToken && tokenMatches(currentClaim, options.currentToken, now)) {
|
||||
return { status: "active", token: options.currentToken, expiresAt: currentClaim.expiresAt };
|
||||
}
|
||||
if (currentClaim && currentClaim.expiresAt > now && !options.force) {
|
||||
return { status: "locked", expiresAt: currentClaim.expiresAt };
|
||||
}
|
||||
|
||||
if (currentRow) {
|
||||
await db
|
||||
.delete(serverSettings)
|
||||
.where(and(eq(serverSettings.settingKey, onboardingClaimSettingKey), eq(serverSettings.value, currentRow.value)));
|
||||
}
|
||||
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = now + (options.ttlMs ?? onboardingClaimTtlMs);
|
||||
const value = JSON.stringify({ hash: hashToken(token), expiresAt } satisfies StoredOnboardingClaim);
|
||||
try {
|
||||
await db.insert(serverSettings).values({ settingKey: onboardingClaimSettingKey, value });
|
||||
} catch (error) {
|
||||
const winner = await getClaimRowAsync(db);
|
||||
const winnerClaim = winner ? parseStoredClaim(winner.value) : null;
|
||||
if (winnerClaim?.expiresAt && winnerClaim.expiresAt > now) {
|
||||
return { status: "locked", expiresAt: winnerClaim.expiresAt };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { status: "issued", token, expiresAt };
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { normalizeOnboardingStep } from "./router/onboard/onboard-queries";
|
||||
@@ -16,7 +16,6 @@ export const appRouter = createTRPCRouter({
|
||||
location: lazy(() => import("./router/location").then((mod) => mod.locationRouter)),
|
||||
log: lazy(() => import("./router/log").then((mod) => mod.logRouter)),
|
||||
icon: lazy(() => import("./router/icons").then((mod) => mod.iconsRouter)),
|
||||
import: lazy(() => import("./router/import/import-router").then((mod) => mod.importRouter)),
|
||||
onboard: lazy(() => import("./router/onboard/onboard-router").then((mod) => mod.onboardRouter)),
|
||||
home: lazy(() => import("./router/home").then((mod) => mod.homeRouter)),
|
||||
docker: lazy(() => import("./router/docker/docker-router").then((mod) => mod.dockerRouter)),
|
||||
|
||||
@@ -18,10 +18,7 @@ import { AppAccessControl } from "./app/app-access-control";
|
||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
getPaginated: permissionRequiredProcedure
|
||||
.requiresPermission("board-modify-all")
|
||||
.input(paginatedSchema)
|
||||
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
|
||||
getPaginated: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
@@ -35,6 +32,8 @@ export const appRouter = createTRPCRouter({
|
||||
"List apps with pagination. OPTIONAL: search (string to filter by name), pageSize (number, default 10), page (number, default 1). All fields are optional — call with no arguments to get the first page",
|
||||
},
|
||||
})
|
||||
.input(paginatedSchema)
|
||||
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined;
|
||||
const totalCount = await ctx.db.$count(apps, whereQuery);
|
||||
|
||||
@@ -534,8 +534,8 @@ export const assistantRouter = createTRPCRouter({
|
||||
connectionConfigured: Boolean(configuration),
|
||||
enabled: configuration?.enabled ?? false,
|
||||
webSearchEnabled: configuration?.webSearchEnabled ?? false,
|
||||
provider: configuration?.provider ?? "openrouter",
|
||||
baseUrl: configuration?.baseUrl ?? "https://openrouter.ai/api/v1",
|
||||
provider: configuration?.provider ?? "homarr",
|
||||
baseUrl: configuration?.baseUrl ?? getHomarrProviderBaseUrl(),
|
||||
modelDiscoveryPath: configuration?.modelDiscoveryPath ?? "/models",
|
||||
apiKeyConfigured: Boolean(configuration?.encryptedApiKey),
|
||||
customHeadersConfigured: Boolean(configuration?.encryptedHeaders),
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { GridAlgorithmItem } from "@homarr/common";
|
||||
import type { DeviceType } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
|
||||
import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
|
||||
import { and, asc, eq, gt, gte, handleTransactionsAsync, inArray, isNull, like, lt, not, or, sql } from "@homarr/db";
|
||||
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
|
||||
import { seedProtectedBoardLayoutsAsync } from "@homarr/db/migrations/seed";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
integrationItems,
|
||||
integrationUserPermissions,
|
||||
integrations,
|
||||
apps,
|
||||
itemLayouts,
|
||||
items,
|
||||
layouts,
|
||||
@@ -31,26 +32,22 @@ import {
|
||||
sections,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import type { BoardLane, WidgetKind } from "@homarr/definitions";
|
||||
import type { BoardLane, IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import {
|
||||
boardLanes,
|
||||
emptySuperJSON,
|
||||
everyoneGroup,
|
||||
getBoardLaneColumnCount,
|
||||
getRootSectionLane,
|
||||
getWidgetIntegrationIssue,
|
||||
getWidgetIntegrationIssueMessage,
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
normalizeBoardLayoutRoles,
|
||||
rootSectionOffsets,
|
||||
widgetDefaultSizes,
|
||||
widgetIntegrationLimits,
|
||||
widgetIntegrationSupport,
|
||||
widgetKindsWithOptionalIntegrations,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
import {
|
||||
addItemToBoardSchema,
|
||||
boardByNameSchema,
|
||||
@@ -88,6 +85,7 @@ interface BoardItemPlacementRectangle {
|
||||
}
|
||||
|
||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||
const maxManageOverviewPreviewRows = 12;
|
||||
|
||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
||||
@@ -193,31 +191,8 @@ export const boardRouter = createTRPCRouter({
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
});
|
||||
|
||||
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const boardIds = permissionsOfCurrentUserWhenPresent
|
||||
.map((permission) => permission.boardId)
|
||||
.concat(
|
||||
permissionsOfCurrentUserGroupsWhenPresent
|
||||
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
||||
.flat(),
|
||||
);
|
||||
|
||||
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId ?? ""),
|
||||
});
|
||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||
|
||||
const dbBoards = await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
@@ -239,30 +214,273 @@ export const boardRouter = createTRPCRouter({
|
||||
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where:
|
||||
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
|
||||
)
|
||||
: undefined,
|
||||
where: groupPermissionWhere,
|
||||
},
|
||||
},
|
||||
// Allow viewing all boards if the user has the permission
|
||||
where: ctx.session?.user.permissions.includes("board-view-all")
|
||||
? undefined
|
||||
: or(
|
||||
eq(boards.isPublic, true),
|
||||
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
||||
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
|
||||
),
|
||||
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
|
||||
});
|
||||
return dbBoards.map((board) => ({
|
||||
...board,
|
||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
||||
isHome: currentUser?.homeBoardId === board.id,
|
||||
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
||||
}));
|
||||
}),
|
||||
getManageOverview: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||
|
||||
const dbBoards = await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
logoImageUrl: true,
|
||||
isPublic: true,
|
||||
},
|
||||
with: {
|
||||
creator: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
userPermissions: {
|
||||
columns: { permission: true },
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
columns: { permission: true },
|
||||
where: groupPermissionWhere,
|
||||
},
|
||||
layouts: {
|
||||
columns: {
|
||||
id: true,
|
||||
columnCount: true,
|
||||
leftGutterColumnCount: true,
|
||||
rightGutterColumnCount: true,
|
||||
breakpoint: true,
|
||||
role: true,
|
||||
},
|
||||
orderBy: (layout, { desc, sql }) => [
|
||||
sql`CASE WHEN ${layout.role} = 'base' THEN 0 ELSE 1 END`,
|
||||
desc(layout.breakpoint),
|
||||
],
|
||||
limit: 1,
|
||||
},
|
||||
sections: {
|
||||
columns: {
|
||||
id: true,
|
||||
kind: true,
|
||||
xOffset: true,
|
||||
},
|
||||
where: eq(sections.kind, "empty"),
|
||||
orderBy: (section, { asc }) => [asc(section.xOffset), asc(section.id)],
|
||||
},
|
||||
},
|
||||
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
|
||||
});
|
||||
|
||||
const previewLayoutIds = dbBoards.flatMap((board) => board.layouts.map((layout) => layout.id));
|
||||
const rootSectionIds = dbBoards.flatMap((board) => board.sections.map((section) => section.id));
|
||||
const [rootPreviewItemLayouts, previewSectionLayouts] =
|
||||
previewLayoutIds.length > 0 && rootSectionIds.length > 0
|
||||
? await Promise.all([
|
||||
ctx.db.query.itemLayouts.findMany({
|
||||
columns: {
|
||||
itemId: true,
|
||||
layoutId: true,
|
||||
sectionId: true,
|
||||
xOffset: true,
|
||||
yOffset: true,
|
||||
width: true,
|
||||
height: true,
|
||||
},
|
||||
with: {
|
||||
item: {
|
||||
columns: { kind: true, options: true },
|
||||
},
|
||||
},
|
||||
where: and(
|
||||
inArray(itemLayouts.layoutId, previewLayoutIds),
|
||||
inArray(itemLayouts.sectionId, rootSectionIds),
|
||||
gte(itemLayouts.xOffset, 0),
|
||||
gte(itemLayouts.yOffset, 0),
|
||||
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(itemLayouts.width, 0),
|
||||
gt(itemLayouts.height, 0),
|
||||
),
|
||||
}),
|
||||
ctx.db.query.sectionLayouts.findMany({
|
||||
columns: {
|
||||
sectionId: true,
|
||||
layoutId: true,
|
||||
parentSectionId: true,
|
||||
xOffset: true,
|
||||
yOffset: true,
|
||||
width: true,
|
||||
height: true,
|
||||
},
|
||||
with: {
|
||||
section: {
|
||||
columns: { kind: true },
|
||||
},
|
||||
},
|
||||
where: and(
|
||||
inArray(sectionLayouts.layoutId, previewLayoutIds),
|
||||
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
||||
gte(sectionLayouts.xOffset, 0),
|
||||
gte(sectionLayouts.yOffset, 0),
|
||||
lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(sectionLayouts.width, 0),
|
||||
gt(sectionLayouts.height, 0),
|
||||
),
|
||||
}),
|
||||
])
|
||||
: [[], []];
|
||||
|
||||
const previewContainerSectionIds = [
|
||||
...new Set(
|
||||
previewSectionLayouts.filter((layout) => layout.section.kind === "container").map((layout) => layout.sectionId),
|
||||
),
|
||||
];
|
||||
const nestedPreviewItemLayouts =
|
||||
previewLayoutIds.length > 0 && previewContainerSectionIds.length > 0
|
||||
? await ctx.db.query.itemLayouts.findMany({
|
||||
columns: {
|
||||
itemId: true,
|
||||
layoutId: true,
|
||||
sectionId: true,
|
||||
xOffset: true,
|
||||
yOffset: true,
|
||||
width: true,
|
||||
height: true,
|
||||
},
|
||||
with: {
|
||||
item: {
|
||||
columns: { kind: true, options: true },
|
||||
},
|
||||
},
|
||||
where: and(
|
||||
inArray(itemLayouts.layoutId, previewLayoutIds),
|
||||
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
||||
gte(itemLayouts.xOffset, 0),
|
||||
gte(itemLayouts.yOffset, 0),
|
||||
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(itemLayouts.width, 0),
|
||||
gt(itemLayouts.height, 0),
|
||||
),
|
||||
})
|
||||
: [];
|
||||
const previewItemLayouts = [...rootPreviewItemLayouts, ...nestedPreviewItemLayouts];
|
||||
|
||||
const appIdByItemId = new Map(
|
||||
previewItemLayouts.flatMap((layout) => {
|
||||
if (layout.item.kind !== "app") return [];
|
||||
try {
|
||||
const { appId } = superjson.parse<{ appId?: unknown }>(layout.item.options);
|
||||
return typeof appId === "string" ? [[layout.itemId, appId] as const] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
const previewAppIds = [...new Set(appIdByItemId.values())];
|
||||
const previewApps =
|
||||
previewAppIds.length > 0
|
||||
? await ctx.db.query.apps.findMany({
|
||||
columns: { id: true, iconUrl: true },
|
||||
where: inArray(apps.id, previewAppIds),
|
||||
})
|
||||
: [];
|
||||
const appIconUrlById = new Map(previewApps.map((app) => [app.id, app.iconUrl]));
|
||||
|
||||
return dbBoards.map(({ layouts: boardLayouts, sections: boardSections, ...board }) => {
|
||||
const previewLayout = boardLayouts.at(0);
|
||||
|
||||
const rootsByLane = new Map<BoardLane, (typeof boardSections)[number]>();
|
||||
for (const section of boardSections) {
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (!rootsByLane.has(lane)) rootsByLane.set(lane, section);
|
||||
}
|
||||
const previewRoots = boardLanes.flatMap((lane) => {
|
||||
const root = rootsByLane.get(lane);
|
||||
return root ? [{ id: root.id, kind: "empty" as const, xOffset: root.xOffset, layouts: [] }] : [];
|
||||
});
|
||||
|
||||
const rootColumnCountById = new Map(
|
||||
boardLanes.flatMap((lane) => {
|
||||
const root = rootsByLane.get(lane);
|
||||
return root && previewLayout ? [[root.id, getBoardLaneColumnCount(previewLayout, lane)] as const] : [];
|
||||
}),
|
||||
);
|
||||
const isInsideRootLane = (layout: { sectionId: string; xOffset: number }) => {
|
||||
const columnCount = rootColumnCountById.get(layout.sectionId);
|
||||
return columnCount !== undefined && columnCount > 0 && layout.xOffset < columnCount;
|
||||
};
|
||||
const isInsideParentRootLane = (layout: { parentSectionId: string | null; xOffset: number }) => {
|
||||
if (!layout.parentSectionId) return false;
|
||||
const columnCount = rootColumnCountById.get(layout.parentSectionId);
|
||||
return columnCount !== undefined && columnCount > 0 && layout.xOffset < columnCount;
|
||||
};
|
||||
|
||||
const containerPreview = previewLayout
|
||||
? previewSectionLayouts
|
||||
.filter(
|
||||
(layout) =>
|
||||
layout.layoutId === previewLayout.id &&
|
||||
layout.section.kind === "container" &&
|
||||
isInsideParentRootLane(layout),
|
||||
)
|
||||
.map((layout) => ({
|
||||
id: layout.sectionId,
|
||||
kind: "container" as const,
|
||||
xOffset: null,
|
||||
layouts: [layout],
|
||||
}))
|
||||
: [];
|
||||
const visibleContainerSizeById = new Map(
|
||||
containerPreview.map((section) => [section.id, section.layouts[0]] as const),
|
||||
);
|
||||
const isInsideVisibleContainer = (layout: { sectionId: string; xOffset: number; yOffset: number }) => {
|
||||
const containerLayout = visibleContainerSizeById.get(layout.sectionId);
|
||||
return (
|
||||
containerLayout !== undefined &&
|
||||
layout.xOffset < containerLayout.width &&
|
||||
layout.yOffset < containerLayout.height
|
||||
);
|
||||
};
|
||||
const itemPreview = previewLayout
|
||||
? previewItemLayouts
|
||||
.filter(
|
||||
(layout) =>
|
||||
layout.layoutId === previewLayout.id && (isInsideRootLane(layout) || isInsideVisibleContainer(layout)),
|
||||
)
|
||||
.map((layout) => {
|
||||
const appId = appIdByItemId.get(layout.itemId);
|
||||
return {
|
||||
id: layout.itemId,
|
||||
kind: layout.item.kind,
|
||||
iconUrl: appId ? appIconUrlById.get(appId) : undefined,
|
||||
layouts: [layout],
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
...board,
|
||||
isHome: currentUser?.homeBoardId === board.id,
|
||||
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
||||
preview: previewLayout
|
||||
? {
|
||||
layouts: [previewLayout],
|
||||
sections: [...previewRoots, ...containerPreview],
|
||||
items: itemPreview,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}),
|
||||
search: publicProcedure
|
||||
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -1846,14 +2064,6 @@ export const boardRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
importOldmarrConfig: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.input(importJsonFileSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const content = await input.file.text();
|
||||
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
|
||||
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
|
||||
}),
|
||||
addItem: protectedProcedure
|
||||
.meta({
|
||||
openapi: { method: "POST", path: "/api/boards/items", tags: ["boards"], protect: true },
|
||||
@@ -1877,25 +2087,7 @@ export const boardRouter = createTRPCRouter({
|
||||
await validateTimetableOptionsChangeAsync(input.options);
|
||||
}
|
||||
|
||||
const supportedIntegrations = widgetIntegrationSupport[input.kind];
|
||||
const integrationLimit = widgetIntegrationLimits[input.kind] ?? Infinity;
|
||||
if (input.integrationIds.length > integrationLimit) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${input.kind} supports at most ${integrationLimit} integration${integrationLimit === 1 ? "" : "s"}`,
|
||||
});
|
||||
}
|
||||
if (supportedIntegrations === undefined && input.integrationIds.length > 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} does not support integrations` });
|
||||
}
|
||||
if (
|
||||
supportedIntegrations !== undefined &&
|
||||
input.integrationIds.length === 0 &&
|
||||
!widgetKindsWithOptionalIntegrations.has(input.kind)
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} requires an integration` });
|
||||
}
|
||||
|
||||
let selectedIntegrationKinds: IntegrationKind[] = [];
|
||||
if (input.integrationIds.length > 0) {
|
||||
const existing = await ctx.db.query.integrations.findMany({
|
||||
columns: { id: true, kind: true },
|
||||
@@ -1907,19 +2099,21 @@ export const boardRouter = createTRPCRouter({
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
|
||||
}
|
||||
|
||||
selectedIntegrationKinds = existing.map((integration) => integration.kind);
|
||||
|
||||
await Promise.all(
|
||||
input.integrationIds.map((integrationId) =>
|
||||
throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const incompatible = existing.filter((integration) => !supportedIntegrations?.includes(integration.kind));
|
||||
if (incompatible.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${input.kind} does not support integration kind${incompatible.length === 1 ? "" : "s"}: ${incompatible.map((integration) => integration.kind).join(", ")}`,
|
||||
});
|
||||
}
|
||||
const integrationIssue = getWidgetIntegrationIssue(input.kind, selectedIntegrationKinds);
|
||||
if (integrationIssue) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: getWidgetIntegrationIssueMessage(input.kind, integrationIssue),
|
||||
});
|
||||
}
|
||||
|
||||
return await serializeBoardItemPlacementAsync(input.boardId, async () => {
|
||||
@@ -2372,6 +2566,59 @@ const getElementsForLayout = (board: BoardForLayoutProjection, layoutId: string)
|
||||
|
||||
const protectedLayoutRepairPromises = new Map<string, Promise<void>>();
|
||||
|
||||
const getBoardAccessContextAsync = async (db: Database, userId: string | undefined) => {
|
||||
const [userPermissions, groupMemberships, currentUser] = await Promise.all([
|
||||
db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
}),
|
||||
db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.query.users.findFirst({
|
||||
where: eq(users.id, userId ?? ""),
|
||||
columns: {
|
||||
homeBoardId: true,
|
||||
mobileHomeBoardId: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const boardIds = userPermissions
|
||||
.map((permission) => permission.boardId)
|
||||
.concat(
|
||||
groupMemberships.flatMap((membership) =>
|
||||
membership.group.boardPermissions.map((permission) => permission.boardId),
|
||||
),
|
||||
);
|
||||
|
||||
return { boardIds, currentUser, groupMemberships };
|
||||
};
|
||||
|
||||
const getBoardGroupPermissionWhere = (
|
||||
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
|
||||
) =>
|
||||
groupMemberships.length > 0
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
groupMemberships.map((membership) => membership.groupId),
|
||||
)
|
||||
: eq(boardGroupPermissions.groupId, "");
|
||||
|
||||
const getAccessibleBoardsWhere = (canViewAll: boolean | undefined, userId: string | undefined, boardIds: string[]) =>
|
||||
canViewAll
|
||||
? undefined
|
||||
: or(
|
||||
eq(boards.isPublic, true),
|
||||
eq(boards.creatorId, userId ?? ""),
|
||||
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
|
||||
);
|
||||
|
||||
const getFullBoardWithWhereAsync = async (
|
||||
db: Database,
|
||||
where: SQL<unknown>,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MySqlRawQueryResult } from "drizzle-orm/mysql2";
|
||||
import type { QueryResult } from "pg";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -6,7 +8,7 @@ import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
|
||||
import { groupMembers, groupPermissions, groups, onboarding, users } from "@homarr/db/schema";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
@@ -19,7 +21,6 @@ import {
|
||||
} from "@homarr/validation/group";
|
||||
|
||||
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
@@ -185,18 +186,44 @@ export const groupRouter = createTRPCRouter({
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
const groupRow = {
|
||||
id: groupId,
|
||||
name: input.name,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
};
|
||||
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
const transitionResult = (await transaction
|
||||
.update(schema.onboarding)
|
||||
.set({ previousStep: "group", step: "setup" })
|
||||
.where(eq(schema.onboarding.step, "group"))) as MySqlRawQueryResult | QueryResult;
|
||||
const transitionedRows = Array.isArray(transitionResult)
|
||||
? transitionResult[0].affectedRows
|
||||
: (transitionResult.rowCount ?? 0);
|
||||
if (transitionedRows !== 1) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "The initial external group was already created." });
|
||||
}
|
||||
await transaction.insert(schema.groups).values(groupRow);
|
||||
await transaction.insert(schema.groupPermissions).values({ groupId, permission: "admin" });
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
const transitionResult = transaction
|
||||
.update(onboarding)
|
||||
.set({ previousStep: "group", step: "setup" })
|
||||
.where(eq(onboarding.step, "group"))
|
||||
.run();
|
||||
if (transitionResult.changes !== 1) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "The initial external group was already created." });
|
||||
}
|
||||
transaction.insert(groups).values(groupRow).run();
|
||||
transaction.insert(groupPermissions).values({ groupId, permission: "admin" }).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
createGroup: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
|
||||
@@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("board-modify-all")) {
|
||||
if (ctx.session?.user.permissions.includes("app-create")) {
|
||||
statistics.push({
|
||||
titleKey: "app",
|
||||
subtitleKey: "resources",
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
|
||||
import {
|
||||
ensureValidTokenOrThrow,
|
||||
importInitialOldmarrAsync,
|
||||
importInitialOldmarrInputSchema,
|
||||
} from "@homarr/old-import/import";
|
||||
|
||||
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
|
||||
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
|
||||
|
||||
export const importRouter = createTRPCRouter({
|
||||
analyseInitialOldmarrImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(analyseOldmarrImportInputSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await analyseOldmarrImportForRouterAsync(input);
|
||||
}),
|
||||
validateToken: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(
|
||||
z.object({
|
||||
checksum: z.string(),
|
||||
token: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ input }) => {
|
||||
try {
|
||||
ensureValidTokenOrThrow(input.checksum, input.token);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
importInitialOldmarrImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(importInitialOldmarrInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await importInitialOldmarrAsync(ctx.db, input);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { probeRuntimeCapabilitiesAsync } from "./capability-probes";
|
||||
|
||||
describe("probeRuntimeCapabilitiesAsync", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reports independently reachable runtime capabilities", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
|
||||
await expect(
|
||||
probeRuntimeCapabilitiesAsync({
|
||||
kubernetesEnabled: true,
|
||||
workshopApiUrl: "https://workshop.example.com/",
|
||||
fetchImpl,
|
||||
getKubernetesVersionAsync: async () => ({ gitVersion: "v1.34.0" }),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kubernetes: { status: "available", detail: "v1.34.0" },
|
||||
workshop: { status: "available" },
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://workshop.example.com/api/health",
|
||||
expect.objectContaining({ headers: { Accept: "application/json" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes disabled and unreachable capabilities", async () => {
|
||||
await expect(
|
||||
probeRuntimeCapabilitiesAsync({
|
||||
kubernetesEnabled: false,
|
||||
workshopApiUrl: "https://workshop.example.com",
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 })),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kubernetes: { status: "disabled" },
|
||||
workshop: { status: "unavailable" },
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts a never-resolving Kubernetes request when the probe times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
let kubernetesSignal: AbortSignal | undefined;
|
||||
const getKubernetesVersionAsync = vi.fn(
|
||||
async (signal: AbortSignal) =>
|
||||
await new Promise<never>((_, reject) => {
|
||||
kubernetesSignal = signal;
|
||||
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = probeRuntimeCapabilitiesAsync({
|
||||
kubernetesEnabled: true,
|
||||
workshopApiUrl: "https://workshop.example.com",
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
getKubernetesVersionAsync,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
kubernetes: { status: "unavailable" },
|
||||
workshop: { status: "available" },
|
||||
});
|
||||
expect(getKubernetesVersionAsync).toHaveBeenCalledOnce();
|
||||
expect(kubernetesSignal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { RequestContext, ResponseContext } from "@kubernetes/client-node";
|
||||
|
||||
import { KubernetesClient } from "../kubernetes/kubernetes-client";
|
||||
|
||||
export type RuntimeCapability =
|
||||
| { status: "available"; detail?: string }
|
||||
| { status: "disabled" }
|
||||
| { status: "unavailable" };
|
||||
|
||||
export interface RuntimeCapabilities {
|
||||
kubernetes: RuntimeCapability;
|
||||
workshop: RuntimeCapability;
|
||||
}
|
||||
|
||||
const capabilityProbeTimeoutMs = 5_000;
|
||||
|
||||
const withTimeoutAsync = async <T>(
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs = capabilityProbeTimeoutMs,
|
||||
) => {
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation(controller.signal),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
const error = new Error("Capability probe timed out");
|
||||
controller.abort(error);
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
export const probeRuntimeCapabilitiesAsync = async ({
|
||||
kubernetesEnabled,
|
||||
workshopApiUrl,
|
||||
fetchImpl = fetch,
|
||||
getKubernetesVersionAsync = async (signal) =>
|
||||
await KubernetesClient.getInstance().versionApi.getCode({
|
||||
middleware: [
|
||||
{
|
||||
pre: async (request: RequestContext) => {
|
||||
request.setSignal(signal);
|
||||
return request;
|
||||
},
|
||||
post: async (response: ResponseContext) => response,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}: {
|
||||
kubernetesEnabled: boolean;
|
||||
workshopApiUrl: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
getKubernetesVersionAsync?: (signal: AbortSignal) => Promise<{ gitVersion?: string }>;
|
||||
}): Promise<RuntimeCapabilities> => {
|
||||
const kubernetesPromise: Promise<RuntimeCapability> = kubernetesEnabled
|
||||
? withTimeoutAsync(getKubernetesVersionAsync)
|
||||
.then((version) => ({ status: "available" as const, detail: version.gitVersion }))
|
||||
.catch(() => ({ status: "unavailable" as const }))
|
||||
: Promise.resolve({ status: "disabled" as const });
|
||||
const workshopPromise: Promise<RuntimeCapability> = withTimeoutAsync(
|
||||
async (signal) =>
|
||||
await fetchImpl(`${workshopApiUrl.replace(/\/+$/u, "")}/api/health`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
}).then((response) => {
|
||||
if (!response.ok) throw new Error(`Workshop health returned ${response.status}`);
|
||||
}),
|
||||
)
|
||||
.then(() => ({ status: "available" as const }))
|
||||
.catch(() => ({ status: "unavailable" as const }));
|
||||
|
||||
const [kubernetes, workshop] = await Promise.all([kubernetesPromise, workshopPromise]);
|
||||
return { kubernetes, workshop };
|
||||
};
|
||||
@@ -1,84 +1,35 @@
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { groups, onboarding } from "@homarr/db/schema";
|
||||
import { onboarding } from "@homarr/db/schema";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
|
||||
export const nextOnboardingStepAsync = async (db: Database, preferredStep: OnboardingStep | undefined) => {
|
||||
export const nextOnboardingStepAsync = async (db: Database) => {
|
||||
const { current } = await getOnboardingOrFallbackAsync(db);
|
||||
const nextStepConfiguration = nextSteps[current];
|
||||
if (!nextStepConfiguration) return;
|
||||
if (current !== "start") return;
|
||||
const nextStep = await getNextOnboardingStepAsync(db);
|
||||
|
||||
for (const conditionalStep of objectEntries(nextStepConfiguration)) {
|
||||
if (!conditionalStep) continue;
|
||||
const [nextStep, condition] = conditionalStep;
|
||||
if (condition === "preferred" && nextStep !== preferredStep) continue;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (typeof condition === "boolean" && !condition) continue;
|
||||
if (typeof condition === "function" && !(await condition(db))) continue;
|
||||
|
||||
await db.update(onboarding).set({
|
||||
previousStep: current,
|
||||
step: nextStep,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await db.update(onboarding).set({ previousStep: current, step: nextStep });
|
||||
};
|
||||
|
||||
export const getOnboardingOrFallbackAsync = async (db: Database) => {
|
||||
const value = await db.query.onboarding.findFirst();
|
||||
if (!value) return { current: "start" as const, previous: null };
|
||||
|
||||
return { current: value.step, previous: value.previousStep };
|
||||
return {
|
||||
current: normalizeOnboardingStep(value.step),
|
||||
previous: value.previousStep ? normalizeOnboardingStep(value.previousStep) : null,
|
||||
};
|
||||
};
|
||||
|
||||
type NextStepCondition = true | "preferred" | ((db: Database) => MaybePromise<boolean>);
|
||||
|
||||
/**
|
||||
* The below object is a definition of which can be the next step of the current one.
|
||||
* If the value is `true`, it means the step can always be the next one.
|
||||
* If the value is `preferred`, it means that the step can only be reached if the input `preferredStep` is set to the step.
|
||||
* If the value is a function, it will be called with the database instance and should return a boolean.
|
||||
* If the value or result is `false`, the step has to be skipped and the next value or callback should be checked.
|
||||
*/
|
||||
const nextSteps: Partial<Record<OnboardingStep, Partial<Record<OnboardingStep, NextStepCondition>>>> = {
|
||||
start: {
|
||||
import: "preferred" as const,
|
||||
user: () => isProviderEnabled("credentials"),
|
||||
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
|
||||
settings: true,
|
||||
},
|
||||
import: {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
user: async (db: Database) => {
|
||||
if (!isProviderEnabled("credentials")) return false;
|
||||
|
||||
const adminGroup = await db.query.groups.findFirst({
|
||||
where: eq(groups.name, credentialsAdminGroup),
|
||||
with: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !adminGroup || adminGroup.members.length === 0;
|
||||
},
|
||||
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
|
||||
settings: true,
|
||||
},
|
||||
user: {
|
||||
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
|
||||
settings: true,
|
||||
},
|
||||
group: {
|
||||
settings: true,
|
||||
},
|
||||
settings: {
|
||||
integrations: true,
|
||||
},
|
||||
integrations: {
|
||||
finish: true,
|
||||
},
|
||||
const getNextOnboardingStepAsync = async (db: Database): Promise<OnboardingStep> => {
|
||||
const existingUser = await db.query.users.findFirst({ columns: { id: true } });
|
||||
if (isProviderEnabled("credentials") && !existingUser) return "user";
|
||||
if (isProviderEnabled("ldap") || isProviderEnabled("oidc")) return "group";
|
||||
return "setup";
|
||||
};
|
||||
|
||||
export const normalizeOnboardingStep = (step: string | null | undefined): OnboardingStep => {
|
||||
if (step === "user" || step === "group" || step === "setup" || step === "finish") return step;
|
||||
if (step === "settings" || step === "integrations") return "setup";
|
||||
return "start";
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user