Compare commits
283
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfd0bb7f80 | ||
|
|
2098f89584 | ||
|
|
d091362db2 | ||
|
|
653dadaeff | ||
|
|
af6fb75e6b | ||
|
|
b2fbd10965 | ||
|
|
63dca7d1b6 | ||
|
|
22d5770977 | ||
|
|
dcef36ffa7 | ||
|
|
f65969053a | ||
|
|
8eb6735387 | ||
|
|
7841359917 | ||
|
|
a45b077b7c | ||
|
|
111a72c283 | ||
|
|
b988e431e8 | ||
|
|
87f264a286 | ||
|
|
83f80eb9a7 | ||
|
|
3c6b2a5d49 | ||
|
|
1b658b631c | ||
|
|
384dc46f26 | ||
|
|
48abdb52af | ||
|
|
45d8bc9443 | ||
|
|
b8f360c04e | ||
|
|
fc5ba7d888 | ||
|
|
b7fdff9f7f | ||
|
|
dfafad15bf | ||
|
|
ceed0979a1 | ||
|
|
474d9968b5 | ||
|
|
295eb836bb | ||
|
|
7b11d6673b | ||
|
|
ce48cabb36 | ||
|
|
443fbe0c36 | ||
|
|
9c64f8772d | ||
|
|
c0596d1b5e | ||
|
|
517d9320dc | ||
|
|
bb6e8e5c5a | ||
|
|
f4f6773f26 | ||
|
|
aea2832370 | ||
|
|
59cb3a4d99 | ||
|
|
ebd63bea74 | ||
|
|
ef9c37d5ec | ||
|
|
a1c2559541 | ||
|
|
d80e883479 | ||
|
|
3cef3cbdbd | ||
|
|
ae68d599be | ||
|
|
e4bf2aa13d | ||
|
|
4ed1eb5d59 | ||
|
|
66043eb78d | ||
|
|
8a1b2a5cd6 | ||
|
|
0298256b62 | ||
|
|
a9b0c2ae47 | ||
|
|
f6f755d5c9 | ||
|
|
b3e75df7fa | ||
|
|
bcd3d901d2 | ||
|
|
30ca26cb1a | ||
|
|
bb5a9562d5 | ||
|
|
5501eb555e | ||
|
|
28a65d61f9 | ||
|
|
f98766873f | ||
|
|
7bf22639ba | ||
|
|
f991dc6334 | ||
|
|
1ad1439828 | ||
|
|
9918386257 | ||
|
|
97a399d0fa | ||
|
|
f357eb4da6 | ||
|
|
d6a259bdbe | ||
|
|
9588c3860d | ||
|
|
c0cdedc039 | ||
|
|
1f60b4b4be | ||
|
|
8d06201b41 | ||
|
|
edf3f6cd84 | ||
|
|
9f2b52dd7f | ||
|
|
87a42f09be | ||
|
|
d40e6852e6 | ||
|
|
37f5473054 | ||
|
|
3d7c95ddcc | ||
|
|
454a35d1a9 | ||
|
|
a979e8d78a | ||
|
|
5ceef06b4b | ||
|
|
33695753dd | ||
|
|
3faac7a96d | ||
|
|
38440a2e57 | ||
|
|
c5800785c9 | ||
|
|
b66c73141f | ||
|
|
0a88db1296 | ||
|
|
23e4fae275 | ||
|
|
1e058c6ed1 | ||
|
|
ebf6c0d97b | ||
|
|
5ede7ef4b9 | ||
|
|
165d148cfc | ||
|
|
8e0a52fcd0 | ||
|
|
f3420a4149 | ||
|
|
6dc9a565b0 | ||
|
|
07547a462c | ||
|
|
73e4b80a26 | ||
|
|
4b647ec2c9 | ||
|
|
03c448f1f8 | ||
|
|
e77174ff15 | ||
|
|
18ed16d07e | ||
|
|
9c57e0cff9 | ||
|
|
c45a9d047d | ||
|
|
efe477e5b5 | ||
|
|
41389b61e2 | ||
|
|
0c5fc1d331 | ||
|
|
6eb75ca559 | ||
|
|
1656890b77 | ||
|
|
3ed839334b | ||
|
|
b5162a9b95 | ||
|
|
40c7b1523b | ||
|
|
d37a2ba963 | ||
|
|
26a9752206 | ||
|
|
3e1257bafc | ||
|
|
63699b1610 | ||
|
|
27448868dd | ||
|
|
8a8e222da3 | ||
|
|
f3360e0d97 | ||
|
|
8d9d2fe802 | ||
|
|
96d2c78879 | ||
|
|
e9e3a523dc | ||
|
|
0ef0e32e18 | ||
|
|
cba2df72e2 | ||
|
|
c0445cdca2 | ||
|
|
5f3d676af0 | ||
|
|
e9b24f1242 | ||
|
|
5db95f2def | ||
|
|
10ef871777 | ||
|
|
854b59ede4 | ||
|
|
eb918b805a | ||
|
|
4a4b04d471 | ||
|
|
3943741838 | ||
|
|
e425e060d9 | ||
|
|
6754e53f56 | ||
|
|
357adffb30 | ||
|
|
d24eca12ef | ||
|
|
dfcd1dbb64 | ||
|
|
252ce998ae | ||
|
|
a940a8223d | ||
|
|
bbd1abd4f0 | ||
|
|
7adc18b1ce | ||
|
|
6a5162d615 | ||
|
|
02fe99d5aa | ||
|
|
7bbca3bd40 | ||
|
|
923a528038 | ||
|
|
bb599e7fae | ||
|
|
bb5df3a2e6 | ||
|
|
c805d5c631 | ||
|
|
747234c040 | ||
|
|
b828f59568 | ||
|
|
60ea03e72e | ||
|
|
e9853f1fb1 | ||
|
|
f8b364dce2 | ||
|
|
cd8773681f | ||
|
|
2f99f1f34b | ||
|
|
6fc851f054 | ||
|
|
7bedf4a13d | ||
|
|
a9c8b65c14 | ||
|
|
e336059079 | ||
|
|
708105ab3f | ||
|
|
e6bd1adfc2 | ||
|
|
b217d8e026 | ||
|
|
35b7c4fc2d | ||
|
|
251a4c8ae8 | ||
|
|
b4404113c4 | ||
|
|
c1efaabd36 | ||
|
|
b75483982f | ||
|
|
c1da19c9b2 | ||
|
|
46898cd0ea | ||
|
|
6f4cd7522f | ||
|
|
eaaeaaccdb | ||
|
|
d63653142d | ||
|
|
08588da0d1 | ||
|
|
464f30799a | ||
|
|
bd4bbcaca2 | ||
|
|
09b82ba171 | ||
|
|
f38eb034da | ||
|
|
a3a89db727 | ||
|
|
c099a8eef6 | ||
|
|
9437d211c8 | ||
|
|
096cf56ac9 | ||
|
|
851129e62e | ||
|
|
cc1b22f052 | ||
|
|
5df4358f0d | ||
|
|
821b5a555d | ||
|
|
a0f4a58386 | ||
|
|
7c840bcfa0 | ||
|
|
38d30acb87 | ||
|
|
ae515584cc | ||
|
|
9338d6d4ec | ||
|
|
d98b7797c1 | ||
|
|
3c739dc636 | ||
|
|
639cb8d35f | ||
|
|
f1ac482684 | ||
|
|
9cc7fd2051 | ||
|
|
d4d951b99c | ||
|
|
a4f115064e | ||
|
|
07e0642192 | ||
|
|
965ccb32d6 | ||
|
|
be08f70c8d | ||
|
|
4892c399ba | ||
|
|
687fb323f7 | ||
|
|
56cc021f98 | ||
|
|
836a4cd46b | ||
|
|
a607dff50a | ||
|
|
68751d85dc | ||
|
|
869dd45de5 | ||
|
|
352aa7dd64 | ||
|
|
801221d2da | ||
|
|
861538e9a0 | ||
|
|
cb7b49b3e7 | ||
|
|
f5cced4080 | ||
|
|
e429d7540a | ||
|
|
3bd80dcc58 | ||
|
|
0e01ac67df | ||
|
|
8259df58f6 | ||
|
|
3485ef0877 | ||
|
|
2214acd17e | ||
|
|
25b835dca9 | ||
|
|
2efa6bcd4c | ||
|
|
c67c893f8f | ||
|
|
e7e44375ef | ||
|
|
842e86012d | ||
|
|
a8a7545f40 | ||
|
|
ed7b30dc5f | ||
|
|
c09ed96978 | ||
|
|
44b55a401c | ||
|
|
f00f5b83b5 | ||
|
|
5553c4e0c5 | ||
|
|
6cdda54e10 | ||
|
|
0f9d038aaf | ||
|
|
a38e731209 | ||
|
|
b1b17ad69f | ||
|
|
add40370aa | ||
|
|
a2763d1f6f | ||
|
|
f3048f4804 | ||
|
|
6bfa9c33d0 | ||
|
|
16982aa178 | ||
|
|
a2400f8412 | ||
|
|
6e455760a3 | ||
|
|
0b9b77c632 | ||
|
|
346bff540f | ||
|
|
f8771c371f | ||
|
|
71cc3abbe9 | ||
|
|
a098c42013 | ||
|
|
4cf83443fd | ||
|
|
f5b4889663 | ||
|
|
06aa5caf0b | ||
|
|
d3534dfcf8 | ||
|
|
f7591dd196 | ||
|
|
0c5f7a45bb | ||
|
|
cdbba92f31 | ||
|
|
3b0633f6a8 | ||
|
|
d0880aa04c | ||
|
|
7cf6a5d5ab | ||
|
|
d5763737c3 | ||
|
|
00b0b215b0 | ||
|
|
5f08327c5c | ||
|
|
2645cb426a | ||
|
|
65e866064e | ||
|
|
392df12d8a | ||
|
|
6209980b27 | ||
|
|
3f04cf83a2 | ||
|
|
e5a8194c03 | ||
|
|
90cf0e1127 | ||
|
|
498481d539 | ||
|
|
65da74858b | ||
|
|
af2b7b3157 | ||
|
|
db40779492 | ||
|
|
91aeac8377 | ||
|
|
dfe2ddf34b | ||
|
|
34f2a0bd24 | ||
|
|
4227e392b5 | ||
|
|
6893205ee0 | ||
|
|
28ac23e66b | ||
|
|
cddae6719e | ||
|
|
0cb75982fc | ||
|
|
36de8131c8 | ||
|
|
2f28f45ff1 | ||
|
|
915af9ae96 | ||
|
|
db5d088330 | ||
|
|
d1a2ed8fd7 | ||
|
|
d6b114e423 | ||
|
|
e41d938a5f | ||
|
|
afa54755fd |
No files matched your search
@@ -66,6 +66,8 @@ 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,6 +263,15 @@ 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:
|
||||
@@ -601,12 +610,13 @@ 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 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
|
||||
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
|
||||
@@ -95,12 +95,6 @@ jobs:
|
||||
- name: Validate checked-in OpenAPI schema
|
||||
run: pnpm check:openapi
|
||||
|
||||
- name: Validate feature contracts
|
||||
run: pnpm check:feature-contracts
|
||||
|
||||
- name: Test feature platform
|
||||
run: pnpm test:feature-platform
|
||||
|
||||
- name: Validate Custom Widget bundle budget
|
||||
run: |
|
||||
pnpm --filter nextjs build
|
||||
|
||||
@@ -40,6 +40,8 @@ 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,6 +13,8 @@ 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,11 +10,12 @@ If you are still on a version before 0.15.10, please update to 0.15.10 where we
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
## Preserve your old installation
|
||||
## Export your data
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -27,15 +28,28 @@ 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
|
||||

|
||||
|
||||
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 can upload the zip file you downloaded in the first step.
|
||||
|
||||
:::
|
||||

|
||||
|
||||
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.
|
||||
@@ -60,70 +60,6 @@ default data; use `pnpm db:seed` to seed it explicitly later.
|
||||
Workshop contributors can set up PocketBase and Docusaurus with the dedicated
|
||||
[Workshop development guide](../../workshop/development).
|
||||
|
||||
### Scaffold a native feature
|
||||
|
||||
Run `pnpm generate:feature` and choose `native-integration`, `widget`, or `integration-widget`. The generator creates
|
||||
implementation, focused test, documentation, and English translation skeletons and updates the current explicit
|
||||
registries. It stops before writing if a target exists, a registry anchor is ambiguous, or a supported integration is
|
||||
unknown.
|
||||
|
||||
The generated code is a starting contract, not a finished feature. Replace the placeholder request, UI, tests, secrets,
|
||||
categories, configuration, error states, and documentation with behavior specific to the service. Then run:
|
||||
|
||||
```sh
|
||||
pnpm check:feature-contracts
|
||||
pnpm test:feature-platform
|
||||
```
|
||||
|
||||
The contract check reports the exact registry, docs path, translation, support mapping, or package export that needs
|
||||
repair. It intentionally follows today's explicit registries; a future canonical feature descriptor can replace those
|
||||
patches without changing the contributor-facing generator names.
|
||||
|
||||
Integration-capable widgets declare their server-safe relationship in `nativeFeatureCapabilities`. Homarr derives the
|
||||
widget-to-integration map, reverse integration-to-widget map, optional-connection set, and server selection limits from
|
||||
that descriptor. Widget definitions still mirror the supported list for client-side type inference; the focused widget
|
||||
manifest test and `check:feature-contracts` prevent drift until those definition types can consume the descriptor
|
||||
directly.
|
||||
|
||||
New integration skeletons also include response fixtures using `simulateResponseContractAsync`. Replace the generated
|
||||
minimal schema and fixtures with representative upstream payloads: at least one successful response, important optional
|
||||
or version-specific shapes, and a malformed response that must be rejected. The simulator uses the real parser without
|
||||
network access and reports every fixture mismatch, so parser contracts can be reviewed before credentials are available.
|
||||
|
||||
### Review native feature states
|
||||
|
||||
In a development server, administrators can open `/manage/tools/feature-workbench` to inspect the shared `CatalogItem`
|
||||
and integration response-contract fixtures. The workbench uses the application Mantine theme and the same segmented
|
||||
fixture controls as the custom-widget preview. It covers ready, setup-required, standalone, connection-error, selected,
|
||||
loading, disabled, parser-success, and parser-failure states without requiring a live service.
|
||||
|
||||
Use the workbench after changing shared catalog presentation or generated parser contracts, then run the focused test:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run "apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-workbench.spec.tsx"
|
||||
```
|
||||
|
||||
The workbench is deliberately development-only and is not linked from management navigation. It does not yet exercise
|
||||
real integration branding, long translated labels, right-to-left text, high-contrast screenshots, touch interaction,
|
||||
small viewport clipping, live network latency, retry transitions, or service-specific response schemas. Those remain
|
||||
manual review items until screenshot and browser-automation coverage is added.
|
||||
|
||||
### Check one native feature
|
||||
|
||||
Run `pnpm check:feature <kind>` before submitting an integration or widget change. The command resolves the kind through
|
||||
`integrationDefs`, `widgetKinds`, the widget manifest, creator registry, documentation slug maps, and capability
|
||||
descriptor. When an integration and widget share a kind, such as `wud`, both sides are checked together.
|
||||
|
||||
```sh
|
||||
pnpm check:feature wud --plan
|
||||
pnpm check:feature wud
|
||||
```
|
||||
|
||||
`--plan` (or `--dry-run`) prints the deterministic affected files and commands without executing them. The normal mode
|
||||
runs feature contracts, nearby fixture and unit tests, affected package typechecks, and lint and formatting for only the
|
||||
resolved files. Unknown kinds fail before any command runs. Live-service, container, and visual checks are printed as
|
||||
optional and are never executed implicitly because they may require credentials, images, or a running development app.
|
||||
|
||||
### Useful commands
|
||||
|
||||
- `pnpm dev` — Next.js on port 3000.
|
||||
@@ -140,8 +76,6 @@ optional and are never executed implicitly because they may require credentials,
|
||||
- `pnpm db:seed` — seed the default database data explicitly.
|
||||
- `pnpm db:studio` — inspect the database.
|
||||
- `pnpm format` / `pnpm format:fix` — check or apply oxfmt.
|
||||
- `pnpm generate:feature` — scaffold a native integration, widget, or paired feature.
|
||||
- `pnpm check:feature-contracts` — validate native feature registries, docs, translations, support maps, and exports.
|
||||
- `pnpm lint` / `pnpm lint:fix` — check or apply oxlint.
|
||||
- `pnpm typecheck` — typecheck all workspaces.
|
||||
- `pnpm test` — unit and contract suites.
|
||||
|
||||
@@ -58,12 +58,11 @@ A random secret can be generated by using the following command: `openssl rand -
|
||||
|
||||
## Docker
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_ENDPOINTS` | JSON array of named, capability-scoped Docker or Podman endpoints. Supports socket, explicitly insecure TCP, and TLS transports. See the [Docker and Podman integration guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
|
||||
| `DOCKER_HOSTNAMES` | Legacy comma-separated list of plaintext TCP hostnames. Ignored when `DOCKER_ENDPOINTS` is set | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Legacy comma-separated list of plaintext TCP ports. Ignored when `DOCKER_ENDPOINTS` is set | For example `2375,2376` | - |
|
||||
| `DOCKER_SOCKET_PATHS` | Legacy comma-separated list of mounted Docker-compatible socket paths. Ignored when `DOCKER_ENDPOINTS` is set | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
|
||||
| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
@@ -15,6 +15,20 @@ Homarr has the following keyboard shortcuts:
|
||||
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
|
||||
| `Esc` | Closes an open advanced view or board menu |
|
||||
|
||||
## Board editing
|
||||
|
||||
When a tile is focused in edit mode:
|
||||
|
||||
| Shortcut | Description |
|
||||
| -------------------- | --------------------------------------------------------------------------- |
|
||||
| `Enter` or `Space` | Starts or stops keyboard editing |
|
||||
| Arrow keys | Moves the focused tile within its current canvas, rail, or Container |
|
||||
| `Shift` + Arrow keys | Resizes from the tile's lower-right corner: Left/Up shrink; Right/Down grow |
|
||||
| `Esc` | Stops keyboard editing without undoing its changes |
|
||||
|
||||
Keyboard moves and resizes apply immediately. To move an item between a canvas, rail, or Container, drag it there or use **Move / resize item** for precise numeric placement.
|
||||
Commands at a grid boundary or at the tile's minimum size are ignored and announced instead of moving or shrinking the tile.
|
||||
|
||||
:::tip
|
||||
|
||||
Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS.
|
||||
|
||||
@@ -6,10 +6,18 @@ 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
|
||||
|
||||
@@ -21,50 +29,117 @@ Congratulations on installing Homarr. Now it's time to get yourself familiar wit
|
||||
|
||||
## Onboarding
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
:::note
|
||||
|
||||
If you see this screen repeatedly after restarting the Homarr container, your Docker mounting paths may be missing,
|
||||
If you see this screen repeatitly after you restarted 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.
|
||||
|
||||
:::
|
||||
|
||||
### Restore or start fresh
|
||||
You can either import a previous dashboard using the ZIP archive or start from scratch:
|
||||
|
||||
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.
|
||||

|
||||
|
||||
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.
|
||||
You may refresh anytime and continue from a different device - the onboarding is saved immediately on the server for your convenience.
|
||||
|
||||
### Setup studio
|
||||
### Starting from scratch
|
||||
|
||||
The studio keeps related choices together:
|
||||
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.
|
||||
|
||||
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.
|
||||
<img src={imageStartFromScratchStepAdminUser} alt={"Screenshot form to create admin user"} width={300} />
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
<img
|
||||
src={imageStartFromScratchStepExternalAdminGroup}
|
||||
alt={"Screenshot form to create external admin group"}
|
||||
width={300}
|
||||
/>
|
||||
|
||||
## Creating another board
|
||||
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
|
||||
|
||||
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.
|
||||
Onboarding already created the first board. To create another one, navigate to `Manage` > `Boards` > `New board`:
|
||||
Let's create your first board by navigating here: `Manage` > `Boards` > `New board`:
|
||||

|
||||
|
||||
A board must always have a name and can either be public or private.
|
||||
@@ -158,7 +233,8 @@ The tile snaps to the grid. Equally-sized tiles swap when one is dropped into th
|
||||
Every `1 × 1` tile has a logical content size of `200 × 200` pixels.
|
||||
Larger tiles use multiples of that fixed unit, with a consistent gap between cells.
|
||||
Homarr scales the complete board uniformly, like zooming a page.
|
||||
It stops shrinking at 75% and lets you scroll horizontally instead of making text and controls too small.
|
||||
The complete canvas always fits the available width without horizontal board scrolling.
|
||||
Below 100%, Homarr preserves the widget's readable typography and control density instead of shrinking each widget independently.
|
||||
It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -122,77 +122,9 @@ services:
|
||||
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
|
||||
```
|
||||
|
||||
For named endpoints, transport security, or least-privilege actions, prefer `DOCKER_ENDPOINTS`. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./docker-ca.pem:/run/secrets/docker-ca.pem:ro
|
||||
environment:
|
||||
DOCKER_ENDPOINTS: >-
|
||||
[
|
||||
{
|
||||
"id": "local",
|
||||
"name": "Local Docker",
|
||||
"kind": "docker",
|
||||
"transport": { "type": "socket", "path": "/var/run/docker.sock" },
|
||||
"capabilities": ["inventory", "logs", "lifecycle", "remove"]
|
||||
},
|
||||
{
|
||||
"id": "remote-read-only",
|
||||
"name": "Production inventory",
|
||||
"kind": "docker",
|
||||
"transport": {
|
||||
"type": "tls",
|
||||
"host": "docker.example.com",
|
||||
"port": 2376,
|
||||
"caPath": "/run/secrets/docker-ca.pem"
|
||||
},
|
||||
"capabilities": ["inventory", "logs"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The stable `id` keeps containers from different endpoints distinct. `name` is shown in the interface, and `kind` can be `docker` or `podman`. Every endpoint must include the `inventory` capability; optionally add `logs`, `lifecycle`, and `remove`. Homarr hides or disables actions the endpoint does not allow and also enforces the same capabilities on the server.
|
||||
|
||||
Supported transports are:
|
||||
|
||||
- `socket`: an absolute in-container `path`
|
||||
- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS
|
||||
- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true`
|
||||
|
||||
:::warning[Plaintext TCP]
|
||||
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy, or TLS. The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`.
|
||||
:::
|
||||
|
||||
When `DOCKER_ENDPOINTS` is set, it takes precedence over the legacy `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS` variables.
|
||||
|
||||
### Managing your Docker containers within Homarr
|
||||
|
||||
Homarr allows you to interact with Docker containers running on your system.
|
||||
The **Service setup** section compares the visible containers with your existing Homarr apps and integrations. It keeps every service distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
|
||||
|
||||
Homarr can show a service as:
|
||||
|
||||
- **Ready to connect** when its image or name matches a supported integration
|
||||
- **New service** when it can be added as an app but no integration is recognized
|
||||
- **Already represented** or **Linked** when an app or integration already matches
|
||||
- **Address changed** when a service appears to match an integration but its reachable address has changed
|
||||
|
||||
Suggested addresses are ranked and explain where they came from. A published address or Docker endpoint host is normally usable from a browser; a container-name address is server-only and requires Homarr to share the container network. Wildcard addresses such as `0.0.0.0` are never offered as browser destinations. You can edit every suggestion before continuing.
|
||||
|
||||
:::info[Review before changing]
|
||||
Service discovery is read-only. Homarr never creates or changes an app or integration automatically; use the action on an individual suggestion to review and confirm its setup.
|
||||
:::
|
||||
|
||||
Use the inbox filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same panel. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
|
||||
|
||||
The health badges are an evidence-based projection across Docker, integration configuration, app representation, and persisted widget links. Authentication, API request success, and widget query execution are labeled **Not observed** because Homarr does not currently retain trustworthy last-run results for these layers. Use the suggested integration test or board action to diagnose them live; **Not observed** is never presented as healthy.
|
||||
|
||||
If one Docker endpoint is unavailable, Homarr reports that endpoint while continuing to show services from healthy endpoints. An unavailable endpoint is not treated as an empty Docker installation.
|
||||
|
||||
Each container row has an action menu with the following options:
|
||||
|
||||
- **View logs** - opens a dedicated logs page with a live terminal
|
||||
@@ -228,13 +160,11 @@ They are then available in the app list and can be used as normally added apps.
|
||||
|
||||

|
||||
|
||||
### Container labels
|
||||
### Configuration
|
||||
|
||||
`homarr.hide` excludes a container from the Docker Containers widget, tools page, and onboarding discovery whenever the
|
||||
label is present.
|
||||
Currently Homarr supports one label for configuration of the containers:
|
||||
|
||||
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).
|
||||
- **homarr.hide:** If set to any value, the container will be hidden from the Docker Containers widget and tools page.
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ import { kubernetesIntegration } from ".";
|
||||
|
||||
Homarr provides a user-friendly Kubernetes dashboard that offers an overview of your cluster’s resources in a clear and structured interface. It allows users to view key Kubernetes objects, including `Pods`, `Services`, `Ingresses`, `Nodes`, `ConfigMaps`, `Secrets`, `Namespaces`, and `Volumes`, as well as basic metrics when the Kubernetes Metrics Server is enabled.
|
||||
|
||||
Docker service discovery does not convert Kubernetes resources into Homarr apps or integrations. Kubernetes remains a separate, read-only operational view, so a cluster resource is never changed or linked automatically.
|
||||
|
||||
:::warning[Read only]
|
||||
This integration is read-only, meaning Homarr does not modify or manage cluster resources but serves as an informational tool to enhance visibility into your Kubernetes environment.
|
||||
:::
|
||||
@@ -27,7 +25,7 @@ This integration is read-only, meaning Homarr does not modify or manage cluster
|
||||
Before deploying Homarr in Kubernetes, ensure the following prerequisites are met:
|
||||
|
||||
- Kubernetes (Version >= 1.24.0)
|
||||
- Metrics Server ([install from here](https://github.com/kubernetes-sigs/metrics-server)) is optional. Without it, Homarr still shows cluster inventory, node status, and reserved capacity, and identifies live CPU and memory usage as unavailable.
|
||||
- Metrics Server ([Install from here](https://github.com/kubernetes-sigs/metrics-server))
|
||||
- RBAC Enabled (Required for Homarr Helm chart)
|
||||
|
||||
### Configuring RBAC in Helm Chart
|
||||
@@ -47,25 +45,6 @@ Enabling RBAC in the Helm chart creates the necessary Kubernetes Role-Based Acce
|
||||
- ClusterRole & ClusterRoleBinding: Extends the same permissions cluster-wide for additional flexibility.
|
||||
:::
|
||||
|
||||
### Multiple Kubernetes contexts
|
||||
|
||||
When Homarr runs outside a cluster, set `KUBECONFIG` explicitly so Homarr loads the contexts from the referenced kubeconfig file or files. Mount the kubeconfig read-only and set the standard `KUBECONFIG` environment variable to its path inside the Homarr container:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
volumes:
|
||||
- ./kubeconfig:/app/config/kubeconfig:ro
|
||||
environment:
|
||||
KUBECONFIG: /app/config/kubeconfig
|
||||
```
|
||||
|
||||
The Kubernetes management pages include a context selector. The selected context is stored in the page URL, so each React Query cache entry and every API request remains context-specific. Resources with the same namespace and name in two contexts therefore remain separate.
|
||||
|
||||
Context status is checked independently. If one context is unavailable, the selector still lists healthy contexts. A context is shown as **Metrics unavailable** when its Kubernetes API is reachable but Metrics Server is not; inventory remains available without live CPU and memory usage.
|
||||
|
||||
When Homarr runs inside Kubernetes without `KUBECONFIG`, it keeps using the in-cluster service account as the single default context.
|
||||
|
||||
### Homarr Dashboard Overview
|
||||
|
||||
Once deployed, Homarr provides a dashboard displaying Kubernetes metrics and resource information. The main page includes:
|
||||
|
||||
@@ -86,30 +86,6 @@ Board layout responses identify each layout with a `mobile`, `base`, or `custom`
|
||||
|
||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||
|
||||
## Docker action payload migration
|
||||
|
||||
Docker lifecycle and removal actions now identify each container with both its Docker endpoint and container ID. This is a
|
||||
breaking change for tRPC and MCP clients that previously sent an `ids` array to the start, stop, restart, or remove action.
|
||||
|
||||
Replace the old payload:
|
||||
|
||||
```json
|
||||
{ "ids": ["container-id"] }
|
||||
```
|
||||
|
||||
with endpoint-qualified targets:
|
||||
|
||||
```json
|
||||
{
|
||||
"targets": [{ "endpointId": "local", "id": "container-id" }]
|
||||
}
|
||||
```
|
||||
|
||||
Read `endpointId` and `id` from `docker.getContainers` (MCP tool `docker_getContainers`) immediately before the action.
|
||||
Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.restartAll`, or `docker.removeAll`; their MCP tool
|
||||
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different
|
||||
Docker or Podman endpoints from targeting the wrong service.
|
||||
|
||||
## Partial integration failures
|
||||
|
||||
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
|
||||
|
||||
@@ -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. 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.
|
||||
On the apps management page you find all apps that exist in your Homarr instance. You can edit and delete them.
|
||||
|
||||
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) 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.
|
||||
[Homarr provider](../workshop/homarr-provider) needs no administrator API key; users sign in to the Community Workshop
|
||||
and receive a daily allowance.
|
||||
|
||||
:::info Local model servers
|
||||
|
||||
|
||||
@@ -53,9 +53,6 @@ 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,8 +20,6 @@ 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.
|
||||
|
||||
@@ -159,15 +157,6 @@ The visible corner controls provide a 44-pixel resize target, and edge controls
|
||||
Resize handles stop at the tile's minimum allowed size while the pointer is still moving.
|
||||
Exit edit mode to save the resulting positions.
|
||||
|
||||
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
|
||||
Spotlight. It can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
|
||||
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
|
||||
destinations before the widget is created, so the common flow does not require a second move step.
|
||||
|
||||
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
|
||||
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
|
||||
saved integration changes apply everywhere that connection is used.
|
||||
|
||||
Pointer users can drag anywhere on a widget tile. Containers use the grab button at their top-left corner.
|
||||
Resize tiles from any edge or corner handle.
|
||||
On a touch screen, press and hold the tile before moving it; resize handles support direct touch input.
|
||||
|
||||
@@ -13,9 +13,9 @@ Check [the available integrations](/docs/category/integrations) for further info
|
||||
|
||||
## Create an integration
|
||||
|
||||
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.
|
||||
To create an integration, you can choose the supported application from the dropdown at the top right:
|
||||
|
||||
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:
|
||||
|
||||
@@ -59,22 +59,6 @@ It is not allowed to create an integration without passing the test.
|
||||
This is to ensure that you minimize the amount of broken applications on your Homarr instance.
|
||||
The test and create button will perform a network request to the application and authenticate if required.
|
||||
|
||||
When you connect a service from a board, Homarr keeps you in the same setup flow. After the connection succeeds, it
|
||||
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
|
||||
widget returns to its settings with the new integration already selected.
|
||||
|
||||
Users with full access can also test and update a selected integration from **Edit widget → Edit integration** on a
|
||||
board. Failed tests keep the editor open and show the same connection diagnostics as the management page. Successful
|
||||
changes apply to every app and widget that uses the integration.
|
||||
|
||||
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
|
||||
create one.
|
||||
|
||||
When the connection unlocks useful widgets that are not on the current board yet, the confirmation also shows up to
|
||||
three explainable recommendations. A recommendation is based only on the services you can use and the widget kinds
|
||||
already present on that board. You can open the existing widget picker prefiltered to the recommendation, or dismiss
|
||||
recommendations for that service on that board.
|
||||
|
||||
## Managing permissions
|
||||
|
||||
On the edit page of an integration, you can manage which users / groups can
|
||||
|
||||
@@ -8,7 +8,8 @@ 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.
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ hide_title: true
|
||||
---
|
||||
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { assistantWidget } from ".";
|
||||
|
||||
@@ -28,15 +27,8 @@ touch devices. When a board layout does not provide enough height for conversati
|
||||
a compact **Open assistant** action instead of squeezing an unusable composer into the card. Increase its height
|
||||
in that layout to keep the complete conversation inline.
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={assistantWidget.configuration} />
|
||||
|
||||
**Current conversation** follows whichever conversation is active in the user-menu panel, `Shift + A`, or
|
||||
the command menu. This is the default and keeps every assistant entry point synchronized.
|
||||
|
||||
**Pinned conversation** reveals a searchable conversation selector. A pinned widget never changes the
|
||||
active conversation silently: select **Open conversation** inside the widget before continuing that thread.
|
||||
The widget follows whichever conversation is active in the user-menu panel, `Shift + A`, or the command menu. This
|
||||
keeps every assistant entry point synchronized without storing a user's private conversation details in board settings.
|
||||
|
||||
### Conversation features
|
||||
|
||||
|
||||
@@ -43,12 +43,12 @@ import contextMenuImg from "./img/context-menu.png";
|
||||
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
|
||||
- **Sortable columns** — click any column header to sort ascending/descending
|
||||
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
|
||||
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
|
||||
- **Right-click context menu** — copy download names, or pause, resume, and delete when your account has Interact access and the client supports those actions
|
||||
- **Row expansion** — click any row to see full details inline without opening a separate modal
|
||||
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category; open tooltips close when switching between compact and advanced mode
|
||||
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
|
||||
- **Client filtering** — filter downloads by integration or status using the footer filter panel
|
||||
- **Global controls** — pause/resume all downloads across all clients from the footer bar
|
||||
- **Global controls** — pause/resume downloads from supported clients when your account has Interact access; read-only clients such as slskd do not expose these controls
|
||||
- **Status indicators** — color-coded badges show download state at a glance
|
||||
- **Speed overview** — total download and upload speeds displayed in the footer
|
||||
|
||||
@@ -89,13 +89,13 @@ import contextMenuImg from "./img/context-menu.png";
|
||||
|
||||
### Interactions
|
||||
|
||||
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
|
||||
- **Right-click** any download to copy its name; pause/resume and delete require Interact access and client support
|
||||
- **Click** any row to expand it and see full download details inline
|
||||
- **Hover** a download name for a quick stats tooltip
|
||||
- **Stats toggle** — show or hide the global stats bar from the footer
|
||||
- **Pause / Resume** all downloads using the footer controls
|
||||
- **Pause / Resume** downloads from supported clients using the footer controls when you have Interact access; slskd remains read-only
|
||||
- **Filter** by integration or status using the filter button in the footer
|
||||
- **Sort** by clicking column headers (with persistent sort direction)
|
||||
- **Sort** the current view by clicking column headers to switch direction
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export const downloadsWidget: WidgetDefinition = {
|
||||
{
|
||||
name: "Hide completed torrent under this threshold (in kiB/s)",
|
||||
description:
|
||||
"This will hide completed torrent entries that have a download speed below the specified threshold.",
|
||||
"This will hide completed torrent entries that have an upload speed below the specified threshold.",
|
||||
values: "Any number above 0, 0 to disable",
|
||||
defaultValue: "0",
|
||||
},
|
||||
|
||||
+1
-7
@@ -1266,15 +1266,9 @@
|
||||
"properties": {
|
||||
"boardId": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"layoutId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["boardId", "name", "layoutId"],
|
||||
"required": ["boardId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/onboarding": "workspace:^0.1.0",
|
||||
"@homarr/old-import": "workspace:^0.1.0",
|
||||
"@homarr/old-schema": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
@@ -68,7 +69,6 @@
|
||||
"@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,12 +4,10 @@ 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 { BoardEmptyState } from "~/components/board/board-empty-state";
|
||||
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
@@ -66,8 +64,6 @@ export const ClientBoard = () => {
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardEmptyState />
|
||||
<BoardSetupChecklist />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
|
||||
@@ -44,17 +44,15 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||
const resolvedParams = await params;
|
||||
const queryClient = getQueryClient();
|
||||
const session = await auth();
|
||||
const sessionPromise = auth();
|
||||
const boardPromise = getInitialBoard(resolvedParams);
|
||||
const session = await sessionPromise;
|
||||
|
||||
const board = await getInitialBoard(resolvedParams).catch((error) => {
|
||||
const board = await boardPromise.catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
const requestedBoardName =
|
||||
typeof resolvedParams.name === "string" ? `/boards/${encodeURIComponent(resolvedParams.name)}` : null;
|
||||
redirect(
|
||||
requestedBoardName ? `/auth/login?callbackUrl=${encodeURIComponent(requestedBoardName)}` : "/auth/login",
|
||||
);
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
notFound();
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useBoardAddActions } from "~/components/board/use-board-add-actions";
|
||||
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useContainerActions } from "~/components/board/sections/container/container-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export default function BoardEditActions() {
|
||||
@@ -17,41 +20,60 @@ export default function BoardEditActions() {
|
||||
}
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addContainer } = useContainerActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const handledInitialAdd = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
|
||||
handledInitialAdd.current = true;
|
||||
addWidget();
|
||||
const handleSelectItem = () => {
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
};
|
||||
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||
nextSearchParams.delete("add");
|
||||
const query = nextSearchParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}, [addWidget, board.id, pathname, router, searchParams]);
|
||||
const handleSelectApp = () => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddIntegration = () => {
|
||||
openIntegrationSelectModal({});
|
||||
};
|
||||
|
||||
return (
|
||||
<HeaderButton
|
||||
aria-label={t("board.action.addContent")}
|
||||
onClick={() =>
|
||||
openModal({
|
||||
boardActions: {
|
||||
widget: addWidget,
|
||||
app: addApp,
|
||||
integration: canConnectService ? connectService : undefined,
|
||||
container: addContainer,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconPlus stroke={1.5} />
|
||||
</HeaderButton>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
|
||||
{t("section.container.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import dynamic from "next/dynamic";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconDeviceFloppy,
|
||||
IconLayoutBoard,
|
||||
@@ -99,9 +98,6 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
|
||||
latestBoardRef.current = board;
|
||||
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
|
||||
onMutate(boardToSave) {
|
||||
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
|
||||
},
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
|
||||
@@ -21,7 +21,6 @@ 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";
|
||||
@@ -45,11 +44,21 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<BoardColorInput label={t("board.field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
|
||||
<ColorInput
|
||||
label={t("board.field.primaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<BoardColorInput label={t("board.field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
|
||||
<ColorInput
|
||||
label={t("board.field.secondaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...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: BoardPreviewLayout;
|
||||
layouts: BoardPreviewLayout[];
|
||||
sourceLayout: BoardPreviewLayout;
|
||||
layout: BoardLayout;
|
||||
layouts: BoardLayout[];
|
||||
sourceLayout: BoardLayout;
|
||||
apps: RouterOutputs["app"]["byIds"];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
+8
-64
@@ -1,66 +1,14 @@
|
||||
import { generateResponsiveGridFor } from "@homarr/common";
|
||||
import type { GridAlgorithmItem } from "@homarr/common";
|
||||
import type { BoardLane, LayoutRole, WidgetKind } from "@homarr/definitions";
|
||||
import type { BoardLane } from "@homarr/definitions";
|
||||
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
export interface BoardPreviewLayout {
|
||||
id: string;
|
||||
columnCount: number;
|
||||
leftGutterColumnCount: number;
|
||||
rightGutterColumnCount: number;
|
||||
breakpoint: number;
|
||||
role: LayoutRole;
|
||||
}
|
||||
import type { Board } from "./_types";
|
||||
|
||||
interface BoardPreviewItemLayout {
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
export type BoardLayout = Board["layouts"][number];
|
||||
type LayoutGeometry = Pick<BoardLayout, "id" | "columnCount" | "leftGutterColumnCount" | "rightGutterColumnCount">;
|
||||
|
||||
interface BoardPreviewSectionLayout {
|
||||
layoutId: string;
|
||||
parentSectionId: string | null;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BoardPreviewData {
|
||||
layouts: readonly BoardPreviewLayout[];
|
||||
sections: readonly BoardPreviewSection[];
|
||||
items: readonly {
|
||||
id: string;
|
||||
kind?: WidgetKind;
|
||||
iconUrl?: string | null;
|
||||
layouts: readonly BoardPreviewItemLayout[];
|
||||
}[];
|
||||
}
|
||||
|
||||
type BoardPreviewSection =
|
||||
| {
|
||||
id: string;
|
||||
kind: "empty";
|
||||
xOffset: number | null;
|
||||
layouts?: readonly BoardPreviewSectionLayout[];
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "container";
|
||||
xOffset?: number | null;
|
||||
layouts: readonly BoardPreviewSectionLayout[];
|
||||
};
|
||||
|
||||
type LayoutGeometry = Pick<
|
||||
BoardPreviewLayout,
|
||||
"id" | "columnCount" | "leftGutterColumnCount" | "rightGutterColumnCount"
|
||||
>;
|
||||
|
||||
export const getRepresentativeLayoutWidth = (layout: BoardPreviewLayout, allLayouts: readonly BoardPreviewLayout[]) => {
|
||||
export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: BoardLayout[]) => {
|
||||
if (layout.role === "mobile") return 390;
|
||||
if (layout.role === "base") return Math.max(1280, layout.breakpoint);
|
||||
|
||||
@@ -72,11 +20,7 @@ export const getRepresentativeLayoutWidth = (layout: BoardPreviewLayout, allLayo
|
||||
return nextLayout ? Math.round((layout.breakpoint + nextLayout.breakpoint) / 2) : Math.max(1280, layout.breakpoint);
|
||||
};
|
||||
|
||||
export const projectBoardLayout = (
|
||||
board: BoardPreviewData,
|
||||
sourceLayout: LayoutGeometry,
|
||||
targetLayout: LayoutGeometry,
|
||||
) => {
|
||||
export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, targetLayout: LayoutGeometry) => {
|
||||
const elements = getElementsForLayout(board, sourceLayout.id);
|
||||
|
||||
if (
|
||||
@@ -90,7 +34,7 @@ export const projectBoardLayout = (
|
||||
|
||||
const emptyRoots = board.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
||||
.toSorted((first, second) => first.xOffset - second.xOffset || first.id.localeCompare(second.id));
|
||||
const rootByLane = new Map<BoardLane, (typeof emptyRoots)[number]>();
|
||||
for (const root of emptyRoots) {
|
||||
const lane = getRootSectionLane(root.xOffset);
|
||||
@@ -141,7 +85,7 @@ export const projectBoardLayout = (
|
||||
});
|
||||
};
|
||||
|
||||
const getElementsForLayout = (board: BoardPreviewData, layoutId: string): GridAlgorithmItem[] => [
|
||||
const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem[] => [
|
||||
...board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createElement, act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { BoardProviders } from "./_providers";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
searchParams: new URLSearchParams(),
|
||||
layoutOverrideId: undefined as string | null | undefined,
|
||||
initialOpen: undefined as boolean | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => mocks.searchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
return {
|
||||
BoardProvider: ({ children, layoutOverrideId }: { children: React.ReactNode; layoutOverrideId: string | null }) => {
|
||||
mocks.layoutOverrideId = layoutOverrideId;
|
||||
return React.createElement(React.Fragment, null, children);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/boards/edit-mode", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
return {
|
||||
EditModeProvider: ({ children, initialOpen }: { children: React.ReactNode; initialOpen: boolean }) => {
|
||||
mocks.initialOpen = initialOpen;
|
||||
return React.createElement(React.Fragment, null, children);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("board providers", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
mocks.layoutOverrideId = undefined;
|
||||
mocks.initialOpen = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["base", "base"],
|
||||
["custom", "custom"],
|
||||
])("uses the requested %s layout for an explicit edit request", (_name, requestedLayoutId) => {
|
||||
mocks.searchParams = new URLSearchParams(`layout=${requestedLayoutId}&edit=true&returnTo=settings`);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(
|
||||
BoardProviders,
|
||||
{
|
||||
initialBoard: { layouts: [{ id: "mobile" }, { id: "custom" }, { id: "base" }] } as never,
|
||||
initialLayoutId: "mobile",
|
||||
initialViewportWidth: 390,
|
||||
canModify: true,
|
||||
},
|
||||
createElement("span", null, "board"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.layoutOverrideId).toBe(requestedLayoutId);
|
||||
expect(mocks.initialOpen).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["without edit mode", "layout=base", true],
|
||||
["for an unknown layout", "layout=unknown&edit=true", true],
|
||||
["without modify access", "layout=base&edit=true", false],
|
||||
])("keeps viewport layout selection %s", (_name, query, canModify) => {
|
||||
mocks.searchParams = new URLSearchParams(query);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(
|
||||
BoardProviders,
|
||||
{
|
||||
initialBoard: { layouts: [{ id: "mobile" }, { id: "base" }] } as never,
|
||||
initialLayoutId: "mobile",
|
||||
initialViewportWidth: 390,
|
||||
canModify,
|
||||
},
|
||||
createElement("span", null, "board"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.layoutOverrideId).toBeNull();
|
||||
expect(mocks.initialOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
@@ -24,11 +23,10 @@ export const BoardProviders = ({
|
||||
canModify,
|
||||
}: PropsWithChildren<Props>) => {
|
||||
const searchParams = useSearchParams();
|
||||
const isDesktop = useMediaQuery("(min-width: 64em)");
|
||||
const requestedLayoutId = searchParams.get("layout");
|
||||
const isRequestedLayoutValid = initialBoard.layouts.some((layout) => layout.id === requestedLayoutId);
|
||||
const isLayoutEditRequest = canModify && searchParams.get("edit") === "true" && isRequestedLayoutValid;
|
||||
const layoutOverrideId = isLayoutEditRequest && isDesktop ? requestedLayoutId : null;
|
||||
const layoutOverrideId = isLayoutEditRequest ? requestedLayoutId : null;
|
||||
|
||||
return (
|
||||
<BoardProvider
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Card } from "@mantine/core";
|
||||
|
||||
interface InitStepCardProps {
|
||||
withBorder?: boolean;
|
||||
}
|
||||
|
||||
export const InitStepCard = ({ children, withBorder = true }: PropsWithChildren<InitStepCardProps>) => (
|
||||
<Card w="100%" withBorder={withBorder}>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export const onboardingContentWidth = 64 * 16;
|
||||
export const onboardingMaxWidth = "90vw";
|
||||
@@ -0,0 +1,23 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Card,
|
||||
Collapse,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconBrandDocker, IconCheck, IconChevronDown } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
export interface DiscoveredIntegration {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
kind: IntegrationKind;
|
||||
suggestedUrl: string;
|
||||
publishedPort: number | null;
|
||||
iconUrl: string | null;
|
||||
}
|
||||
|
||||
export interface DiscoveredApp {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
suggestedUrl: string;
|
||||
publishedPort: number | null;
|
||||
iconUrl: string | null;
|
||||
}
|
||||
|
||||
interface DockerDiscoveryIndicatorProps {
|
||||
integrations: DiscoveredIntegration[];
|
||||
apps: DiscoveredApp[];
|
||||
selectedAppIds: Set<string>;
|
||||
onToggleApp: (containerId: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const APP_CARD_HEIGHT = 80;
|
||||
|
||||
export const DockerDiscoveryIndicator = ({
|
||||
integrations,
|
||||
apps,
|
||||
selectedAppIds,
|
||||
onToggleApp,
|
||||
isLoading,
|
||||
}: DockerDiscoveryIndicatorProps) => {
|
||||
const hasApps = apps.length > 0;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const expandedOnce = useRef(false);
|
||||
useEffect(() => {
|
||||
if ((hasApps || integrations.length > 0) && !expandedOnce.current) {
|
||||
expandedOnce.current = true;
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [hasApps, integrations.length]);
|
||||
|
||||
const t = useScopedI18n("init.step.integrations.docker");
|
||||
const totalCount = integrations.length + apps.length;
|
||||
|
||||
if (!isLoading && totalCount === 0) return null;
|
||||
|
||||
const selectedAppCount = apps.filter((app) => selectedAppIds.has(app.containerId)).length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
p="sm"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-blue-4)",
|
||||
background: "var(--mantine-color-blue-light)",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
||||
<IconBrandDocker size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("scanning")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("scanningDescription")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Loader size="sm" color="blue" type="dots" />
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
p="sm"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-blue-4)",
|
||||
background: "var(--mantine-color-blue-light)",
|
||||
}}
|
||||
>
|
||||
<UnstyledButton onClick={() => setExpanded((prev) => !prev)} w="100%">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
||||
<IconBrandDocker size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("detected", { count: String(totalCount) })}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("detectedBreakdown", {
|
||||
integrations: String(integrations.length),
|
||||
apps: String(apps.length),
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<IconChevronDown
|
||||
size={16}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={{
|
||||
transform: expanded ? "rotate(180deg)" : undefined,
|
||||
transition: "transform 200ms ease",
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Collapse expanded={expanded}>
|
||||
<Stack gap="xs" mt="sm">
|
||||
{integrations.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
{integrations.map((integration) => (
|
||||
<Group key={integration.containerId} gap="xs" wrap="nowrap" py={2}>
|
||||
<IntegrationAvatar kind={integration.kind} size="xs" />
|
||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{integration.containerName}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{getIntegrationName(integration.kind)}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{hasApps && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500}>
|
||||
{t("appSectionLabel", { count: String(selectedAppCount) })}
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah="30vh">
|
||||
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
|
||||
{apps.map((app) => {
|
||||
const isSelected = selectedAppIds.has(app.containerId);
|
||||
return (
|
||||
<Card
|
||||
key={app.containerId}
|
||||
h={APP_CARD_HEIGHT}
|
||||
p="xs"
|
||||
withBorder
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: isSelected ? 2 : undefined,
|
||||
opacity: isSelected ? 1 : 0.6,
|
||||
}}
|
||||
onClick={() => onToggleApp(app.containerId)}
|
||||
>
|
||||
<Stack justify="space-between" h="100%" gap={4} align="center">
|
||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{app.containerName}
|
||||
</Text>
|
||||
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
||||
</Group>
|
||||
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
||||
{app.containerName.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,483 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Collapse,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
IconBrandDocker,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions";
|
||||
import { buildAppUrl, buildIntegrationUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
||||
import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid";
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
type Phase = "select" | "configure" | "done";
|
||||
|
||||
interface IntegrationDraft {
|
||||
kind: IntegrationKind;
|
||||
initialUrl: string;
|
||||
initialName?: string;
|
||||
dockerPort?: number | null;
|
||||
source: "manual" | "docker";
|
||||
}
|
||||
|
||||
export const InitIntegrations = () => {
|
||||
const [phase, setPhase] = useState<Phase>("select");
|
||||
const [drafts, setDrafts] = useState<Map<IntegrationKind, IntegrationDraft>>(new Map());
|
||||
const [selectedKinds, setSelectedKinds] = useState<IntegrationKind[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [baseHost, setBaseHost] = useState("");
|
||||
const [urlMode, setUrlMode] = useState<UrlTemplateMode>("subdomain");
|
||||
const t = useScopedI18n("init.step.integrations");
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
|
||||
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
|
||||
|
||||
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
|
||||
undefined,
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : [];
|
||||
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
|
||||
const totalDetected = dockerIntegrations.length + dockerApps.length;
|
||||
|
||||
const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]);
|
||||
|
||||
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
|
||||
const [appsExpanded, setAppsExpanded] = useState(false);
|
||||
|
||||
const dockerApplied = useRef(false);
|
||||
useEffect(() => {
|
||||
if (dockerData?.status !== "success" || dockerApplied.current) return;
|
||||
dockerApplied.current = true;
|
||||
|
||||
const newDrafts = new Map(drafts);
|
||||
const newKinds = new Set(selectedKinds);
|
||||
|
||||
for (const item of dockerData.integrations) {
|
||||
if (!newDrafts.has(item.kind)) {
|
||||
newDrafts.set(item.kind, {
|
||||
kind: item.kind,
|
||||
initialUrl: item.suggestedUrl,
|
||||
initialName: item.containerName,
|
||||
dockerPort: item.publishedPort,
|
||||
source: "docker",
|
||||
});
|
||||
}
|
||||
newKinds.add(item.kind);
|
||||
}
|
||||
|
||||
setDrafts(newDrafts);
|
||||
setSelectedKinds(Array.from(newKinds));
|
||||
setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId)));
|
||||
if (dockerData.apps.length > 0) {
|
||||
setAppsExpanded(true);
|
||||
}
|
||||
}, [dockerData]);
|
||||
|
||||
const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => {
|
||||
setSelectedKinds(kinds);
|
||||
}, []);
|
||||
|
||||
const handleToggleApp = useCallback((containerId: string) => {
|
||||
setSelectedAppIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(containerId)) {
|
||||
next.delete(containerId);
|
||||
} else {
|
||||
next.add(containerId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleContinueToConfig = () => {
|
||||
const newDrafts = new Map(drafts);
|
||||
for (const kind of selectedKinds) {
|
||||
const existing = newDrafts.get(kind);
|
||||
if (baseHost) {
|
||||
newDrafts.set(kind, {
|
||||
kind,
|
||||
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
|
||||
initialName: existing?.initialName,
|
||||
dockerPort: existing?.dockerPort,
|
||||
source: existing?.source ?? "manual",
|
||||
});
|
||||
} else if (!existing) {
|
||||
newDrafts.set(kind, { kind, initialUrl: "", source: "manual" });
|
||||
}
|
||||
}
|
||||
setDrafts(newDrafts);
|
||||
setCurrentIndex(0);
|
||||
setPhase("configure");
|
||||
};
|
||||
|
||||
const finishSetupAsync = async () => {
|
||||
setPhase("done");
|
||||
try {
|
||||
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
|
||||
if (appsToCreate.length > 0) {
|
||||
await createApps(
|
||||
appsToCreate.map((app) => {
|
||||
const href = baseHost
|
||||
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
|
||||
: app.suggestedUrl;
|
||||
return {
|
||||
name: app.containerName,
|
||||
href: href || null,
|
||||
iconUrl: app.iconUrl,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
await setupIntegrations();
|
||||
router.refresh();
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("configure.error.title"),
|
||||
message: t("configure.error.message"),
|
||||
});
|
||||
setPhase("select");
|
||||
}
|
||||
};
|
||||
|
||||
const advanceOrFinish = () => {
|
||||
if (currentIndex + 1 >= selectedKinds.length) {
|
||||
void finishSetupAsync();
|
||||
} else {
|
||||
setCurrentIndex((i) => i + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const backToSelect = () => {
|
||||
setPhase("select");
|
||||
setCurrentIndex(0);
|
||||
};
|
||||
|
||||
if (phase === "done") {
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader size="lg" />
|
||||
<Text>{t("configure.finishing")}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</InitStepCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "configure") {
|
||||
return (
|
||||
<ConfigurePhase
|
||||
selectedKinds={selectedKinds}
|
||||
currentIndex={currentIndex}
|
||||
drafts={drafts}
|
||||
baseHost={baseHost}
|
||||
setBaseHost={setBaseHost}
|
||||
urlMode={urlMode}
|
||||
setUrlMode={setUrlMode}
|
||||
onSuccess={advanceOrFinish}
|
||||
onCancel={backToSelect}
|
||||
onSkip={advanceOrFinish}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedAppCount = dockerApps.filter((app) => selectedAppIds.has(app.containerId)).length;
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Stack>
|
||||
<Text>{t("description")}</Text>
|
||||
|
||||
<Alert variant="outline" color="orange" icon={<IconAlertTriangle />} title={t("urlTemplate.label")}>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("urlTemplate.warning")}
|
||||
</Text>
|
||||
<Group gap="sm" grow>
|
||||
<TextInput
|
||||
placeholder={t("urlTemplate.placeholder")}
|
||||
value={baseHost}
|
||||
onChange={(e) => setBaseHost(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={urlMode}
|
||||
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
|
||||
data={[
|
||||
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
|
||||
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
|
||||
]}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
/>
|
||||
</Group>
|
||||
{baseHost && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
{isDockerLoading && (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
||||
<IconBrandDocker size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("docker.scanning")}
|
||||
</Text>
|
||||
<Loader size="sm" color="blue" type="dots" />
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{!isDockerLoading && totalDetected > 0 && (
|
||||
<Paper withBorder p="sm" radius="md" style={{ borderColor: "var(--mantine-color-teal-4)" }}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="teal" size="lg" radius="md">
|
||||
<IconBrandDocker size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm">
|
||||
{t("docker.summary", {
|
||||
apps: String(totalDetected),
|
||||
integrations: String(dockerIntegrations.length),
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<IntegrationMultiSelectGrid
|
||||
selectedKinds={selectedKinds}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
detectedKinds={detectedKinds}
|
||||
onboarding
|
||||
/>
|
||||
|
||||
{dockerApps.length > 0 && (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="xs">
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => setAppsExpanded((prev) => !prev)}
|
||||
>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("docker.appSectionLabel", { count: String(selectedAppCount) })}
|
||||
</Text>
|
||||
<IconChevronDown
|
||||
size={16}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={{
|
||||
transform: appsExpanded ? "rotate(180deg)" : undefined,
|
||||
transition: "transform 200ms ease",
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Collapse expanded={appsExpanded}>
|
||||
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
|
||||
{dockerApps.map((app) => {
|
||||
const isSelected = selectedAppIds.has(app.containerId);
|
||||
return (
|
||||
<Card
|
||||
key={app.containerId}
|
||||
h={80}
|
||||
p="xs"
|
||||
withBorder
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: isSelected ? 2 : undefined,
|
||||
opacity: isSelected ? 1 : 0.6,
|
||||
}}
|
||||
onClick={() => handleToggleApp(app.containerId)}
|
||||
>
|
||||
<Stack justify="space-between" h="100%" gap={4} align="center">
|
||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{app.containerName}
|
||||
</Text>
|
||||
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
||||
</Group>
|
||||
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
||||
{app.containerName.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
|
||||
{t("action.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleContinueToConfig}
|
||||
disabled={selectedKinds.length === 0 || undefined}
|
||||
rightSection={<IconArrowRight size={16} stroke={1.5} />}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{t("action.continue")} {selectedKinds.length > 0 && `(${selectedKinds.length})`}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
|
||||
interface ConfigurePhaseProps {
|
||||
selectedKinds: IntegrationKind[];
|
||||
currentIndex: number;
|
||||
drafts: Map<IntegrationKind, IntegrationDraft>;
|
||||
baseHost: string;
|
||||
setBaseHost: (value: string) => void;
|
||||
urlMode: UrlTemplateMode;
|
||||
setUrlMode: (value: UrlTemplateMode) => void;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
const ConfigurePhase = ({
|
||||
selectedKinds,
|
||||
currentIndex,
|
||||
drafts,
|
||||
baseHost,
|
||||
setBaseHost,
|
||||
urlMode,
|
||||
setUrlMode,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
onSkip,
|
||||
}: ConfigurePhaseProps) => {
|
||||
const t = useScopedI18n("init.step.integrations");
|
||||
const [helpersOpen, { toggle: toggleHelpers }] = useDisclosure(false);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds
|
||||
const currentKind = selectedKinds[currentIndex]!;
|
||||
const draft = drafts.get(currentKind);
|
||||
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
|
||||
const computedUrl = baseHost
|
||||
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
|
||||
: draft?.initialUrl;
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="xs">
|
||||
<IntegrationAvatar kind={currentKind} size="sm" />
|
||||
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
|
||||
</Group>
|
||||
<Tooltip label={t("configure.helpers.tooltip")}>
|
||||
<ActionIcon variant={helpersOpen ? "light" : "subtle"} color="gray" onClick={toggleHelpers}>
|
||||
<IconSettings size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Progress value={progress} size="sm" />
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Collapse expanded={helpersOpen}>
|
||||
<Stack
|
||||
gap="xs"
|
||||
p="xs"
|
||||
style={{ background: "var(--mantine-color-default-hover)", borderRadius: "var(--mantine-radius-sm)" }}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("configure.helpers.description")}
|
||||
</Text>
|
||||
<Group gap="sm" grow>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("urlTemplate.placeholder")}
|
||||
value={baseHost}
|
||||
onChange={(e) => setBaseHost(e.currentTarget.value)}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={urlMode}
|
||||
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
|
||||
data={[
|
||||
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
|
||||
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
|
||||
]}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
/>
|
||||
</Group>
|
||||
{baseHost && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("urlTemplate.preview", {
|
||||
example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
|
||||
<NewIntegrationForm
|
||||
key={currentKind}
|
||||
kind={currentKind}
|
||||
initialUrl={computedUrl}
|
||||
initialName={draft?.initialName}
|
||||
onSuccess={onSuccess}
|
||||
onCancel={onCancel}
|
||||
onSkip={onSkip}
|
||||
isOnboarding
|
||||
/>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
"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>;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
import { InitUserForm } from "./init-user-form";
|
||||
|
||||
export const InitUser = () => {
|
||||
return (
|
||||
<InitStepCard>
|
||||
<InitUserForm />
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
@@ -1,78 +1,66 @@
|
||||
import { headers } from "next/headers";
|
||||
import type { JSX } from "react";
|
||||
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
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 type { MaybePromise } from "@homarr/common/types";
|
||||
import { db } from "@homarr/db";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
||||
import { env } from "~/env";
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import { AssistantConfiguration } from "../manage/assistant/_components/assistant-configuration";
|
||||
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 };
|
||||
};
|
||||
|
||||
export default async function InitPage() {
|
||||
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);
|
||||
const t = await getScopedI18n("init.step");
|
||||
const currentStep = await getCurrentOnboardingStepAsync();
|
||||
|
||||
const CurrentComponent = stepComponents[currentStep.current];
|
||||
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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";
|
||||
@@ -174,12 +173,8 @@ export default async function Layout(props: {
|
||||
>
|
||||
<head>
|
||||
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
|
||||
{session ? (
|
||||
<>
|
||||
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
|
||||
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
|
||||
</>
|
||||
) : null}
|
||||
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
|
||||
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
|
||||
<SearchEngineOptimization />
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
|
||||
@@ -21,4 +21,26 @@
|
||||
|
||||
.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,10 +1,20 @@
|
||||
import { Box, Group, Image, Stack, Title } from "@mantine/core";
|
||||
import { Box, Grid, GridCol, 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");
|
||||
|
||||
@@ -20,7 +30,41 @@ export const HeroBanner = async () => {
|
||||
</Group>
|
||||
</Stack>
|
||||
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
||||
<IntegrationMarquee />
|
||||
<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>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ 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();
|
||||
|
||||
@@ -51,14 +50,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
}, [app, mutate, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={onClick}
|
||||
aria-label={tList("delete", { name: app.name })}
|
||||
>
|
||||
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,10 @@ export default async function AppEditPage(props: AppEditPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("app-modify-all")) {
|
||||
if (
|
||||
!session?.user.permissions.includes("board-modify-all") ||
|
||||
!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("app-create")) {
|
||||
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,26 @@
|
||||
import { Fragment } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, 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 { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
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(/^[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),
|
||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
interface AppsPageProps {
|
||||
@@ -40,38 +29,24 @@ interface AppsPageProps {
|
||||
|
||||
export default async function AppsPage(props: AppsPageProps) {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/auth/login");
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
if (!session.user.permissions.includes("board-modify-all")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
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
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={t("page.list.title")}
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={apps.length}
|
||||
emptyState={emptyState}
|
||||
primaryAction={
|
||||
canCreate ? (
|
||||
<TourTarget id="manage-apps-create">
|
||||
@@ -81,100 +56,101 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
totalCount > searchParams.pageSize ? (
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
) : undefined
|
||||
}
|
||||
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{apps.map((app) => (
|
||||
<AppItem
|
||||
key={app.id}
|
||||
app={app}
|
||||
canModify={canModify}
|
||||
canDelete={canDelete}
|
||||
editLabel={t("page.list.action.edit", { name: app.name })}
|
||||
/>
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
{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>
|
||||
);
|
||||
|
||||
return apps.length > 0 ? <TourTarget id="manage-apps-list">{page}</TourTarget> : page;
|
||||
}
|
||||
|
||||
interface AppItemProps {
|
||||
app: RouterOutputs["app"]["getPaginated"]["items"][number];
|
||||
canModify: boolean;
|
||||
canDelete: boolean;
|
||||
editLabel: string;
|
||||
interface AppCardProps {
|
||||
app: RouterOutputs["app"]["all"][number];
|
||||
}
|
||||
|
||||
const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
|
||||
const descriptionLines = app.description?.split("\n");
|
||||
const safeHref = getSafeAppHref(app.href);
|
||||
const AppCard = async ({ app }: AppCardProps) => {
|
||||
const t = await getScopedI18n("app");
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<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}
|
||||
<Card>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
|
||||
<Avatar
|
||||
size="sm"
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{app.href}
|
||||
</Anchor>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canModify || canDelete ? (
|
||||
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>
|
||||
<ActionIconGroup>
|
||||
{canModify && (
|
||||
{session?.user.permissions.includes("app-modify-all") && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={editLabel}
|
||||
aria-label={t("page.edit.title")}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{canDelete && <AppDeleteButton app={app} />}
|
||||
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
) : undefined
|
||||
}
|
||||
</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"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+7
-9
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
@@ -140,10 +141,11 @@ const ConfigurationSkeleton = ({ label }: { label: string }) => (
|
||||
|
||||
export const AssistantConfiguration = () => {
|
||||
const t = useScopedI18n("management.page.settings.section.assistant");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
|
||||
const [provider, setProvider] = useState<AssistantProvider>("homarr");
|
||||
const [baseUrl, setBaseUrl] = useState<string>(assistantProviderPresets.homarr.baseUrl);
|
||||
const [provider, setProvider] = useState<AssistantProvider>("openrouter");
|
||||
const [baseUrl, setBaseUrl] = useState<string>(assistantProviderPresets.openrouter.baseUrl);
|
||||
const [modelDiscoveryPath, setModelDiscoveryPath] = useState<string>("/models");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [credentialFlow, setCredentialFlow] = useState<CredentialFlow>("idle");
|
||||
@@ -155,7 +157,7 @@ export const AssistantConfiguration = () => {
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const configuredProvider = configuration?.provider ?? "homarr";
|
||||
const configuredProvider = configuration?.provider ?? "openrouter";
|
||||
setProvider(configuredProvider);
|
||||
setBaseUrl(
|
||||
configuredProvider === "homarr"
|
||||
@@ -201,7 +203,7 @@ export const AssistantConfiguration = () => {
|
||||
);
|
||||
const providerOptions = useMemo(
|
||||
() =>
|
||||
(["free", "hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => {
|
||||
(["hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => {
|
||||
const items = assistantProviderIds
|
||||
.filter((providerId) => assistantProviderPresets[providerId].category === category)
|
||||
.map((providerId) => ({
|
||||
@@ -268,6 +270,7 @@ export const AssistantConfiguration = () => {
|
||||
utils.assistant.getAvailability.invalidate(),
|
||||
utils.assistant.getRuntimeOptions.invalidate(),
|
||||
]);
|
||||
router.refresh();
|
||||
showSuccessNotification({
|
||||
title: t("notification.saved.title"),
|
||||
message: t("notification.saved.message"),
|
||||
@@ -438,11 +441,6 @@ 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"]["getManageOverview"][number],
|
||||
RouterOutputs["board"]["getAllBoards"][number],
|
||||
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconCategoryPlus } from "@tabler/icons-react";
|
||||
import { Button, Menu } from "@mantine/core";
|
||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal } from "@homarr/modals-collection";
|
||||
import { AddBoardModal, ImportBoardModal } 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 } = useModalAction(AddBoardModal);
|
||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||
|
||||
return (
|
||||
<ManageMobilePrimaryAction>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
</Button>
|
||||
<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>
|
||||
</ManageMobilePrimaryAction>
|
||||
);
|
||||
};
|
||||
@@ -24,11 +24,10 @@ 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 { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
@@ -38,7 +37,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.getManageOverview();
|
||||
const boards = await api.board.getAllBoards();
|
||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||
|
||||
return (
|
||||
@@ -53,37 +52,13 @@ export default async function ManageBoardsPage() {
|
||||
}
|
||||
floatingPrimaryAction={canCreateBoards}
|
||||
>
|
||||
{boards.length === 0 && (
|
||||
<NoResults
|
||||
icon={IconLayoutDashboard}
|
||||
title={t("noResults.title")}
|
||||
description={t("noResults.description")}
|
||||
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
|
||||
/>
|
||||
)}
|
||||
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
||||
{boards.length > 0 && (
|
||||
<TourTarget id="manage-boards-list">
|
||||
<Grid>
|
||||
{boards.map((board) => (
|
||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||
<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"),
|
||||
}}
|
||||
/>
|
||||
<BoardCard board={board} />
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
@@ -94,61 +69,48 @@ export default async function ManageBoardsPage() {
|
||||
}
|
||||
|
||||
interface BoardCardProps {
|
||||
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;
|
||||
};
|
||||
board: RouterOutputs["board"]["getAllBoards"][number];
|
||||
}
|
||||
|
||||
const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
|
||||
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 VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||
|
||||
return (
|
||||
<Card padding={0} withBorder>
|
||||
<CardSection withBorder>
|
||||
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
|
||||
</CardSection>
|
||||
<Card>
|
||||
<CardSection p="sm" withBorder>
|
||||
<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}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
<Tooltip label={t(`visibility.${visibility}`)}>
|
||||
<VisibilityIcon size={20} stroke={1.5} />
|
||||
</Tooltip>
|
||||
<Text fw={700} truncate>
|
||||
{board.name}
|
||||
</Text>
|
||||
<Text fw={700}>{board.name}</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
|
||||
<Group>
|
||||
{board.isHome && (
|
||||
<Tooltip label={labels.homeTooltip}>
|
||||
<Tooltip label={t("action.setHomeBoard.badge.tooltip")}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
|
||||
{labels.home}
|
||||
{t("action.setHomeBoard.badge.label")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.isMobileHome && (
|
||||
<Tooltip label={labels.mobileHomeTooltip}>
|
||||
<Tooltip label={t("action.setMobileHomeBoard.badge.tooltip")}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
|
||||
{labels.mobileHome}
|
||||
{t("action.setMobileHomeBoard.badge.label")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.creator && (
|
||||
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
|
||||
<Group gap="xs">
|
||||
<UserAvatar user={board.creator} size="sm" />
|
||||
</Tooltip>
|
||||
<Text>{board.creator.name}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -161,10 +123,9 @@ const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
variant="default"
|
||||
h={44}
|
||||
fullWidth
|
||||
>
|
||||
{labels.open}
|
||||
{t("action.open.label")}
|
||||
</Button>
|
||||
{isMenuVisible && (
|
||||
<Menu position="bottom-end">
|
||||
@@ -172,8 +133,8 @@ const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
|
||||
<ActionIcon
|
||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||
variant="default"
|
||||
size={44}
|
||||
aria-label={labels.settings}
|
||||
size="lg"
|
||||
aria-label={t("action.settings.label")}
|
||||
>
|
||||
<IconDotsVertical size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -144,6 +144,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
key={id}
|
||||
component="a"
|
||||
href={`#${id}`}
|
||||
onClick={() => setMobilePane(id === "preview" ? "preview" : "configure")}
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color={invalidSections.has(id) ? "red" : undefined}
|
||||
|
||||
+8
-1
@@ -22,6 +22,7 @@ vi.mock("@homarr/api/client", () => ({
|
||||
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
|
||||
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
|
||||
const rendererAttempt = vi.hoisted(() => vi.fn());
|
||||
const codeEditorProps = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
|
||||
default: ({ data }: { data: Record<string, unknown> }) => {
|
||||
@@ -32,7 +33,12 @@ vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
|
||||
return <div>widget-renderer</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
|
||||
vi.mock("./_code-editor", () => ({
|
||||
CodeEditor: (props: { label: string; readOnly?: boolean }) => {
|
||||
codeEditorProps(props);
|
||||
return <div>{props.label}</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
|
||||
|
||||
let root: Root;
|
||||
@@ -109,6 +115,7 @@ describe("Custom Widget preview panel", () => {
|
||||
);
|
||||
await act(async () => dataTab?.click());
|
||||
expect(host.textContent).toContain("requestData");
|
||||
expect(codeEditorProps).toHaveBeenCalledWith(expect.objectContaining({ label: "requestData", readOnly: true }));
|
||||
expect(host.textContent).not.toContain("invalid");
|
||||
|
||||
const optionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
|
||||
@@ -172,6 +172,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
label={t("requestData")}
|
||||
language="json"
|
||||
value={JSON.stringify({ data: props.preview.data, status: props.preview.status }, null, 2)}
|
||||
readOnly
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
@@ -251,6 +252,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
label={t("journal")}
|
||||
language="json"
|
||||
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
|
||||
readOnly
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
+3
-1
@@ -87,7 +87,9 @@ export function CustomWidgetSourcesEditor({
|
||||
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
|
||||
};
|
||||
const addSource = () => {
|
||||
const id = sources.length === 0 ? "default" : `source-${sources.length + 1}`;
|
||||
let suffix = sources.length + 1;
|
||||
while (sources.some(({ id }) => id === `source-${suffix}`)) suffix += 1;
|
||||
const id = sources.length === 0 ? "default" : `source-${suffix}`;
|
||||
form.setFieldValue(
|
||||
"sources",
|
||||
JSON.stringify(
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { JsonPreviewEditor } from "./_json-preview-editor";
|
||||
|
||||
const editorProps = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
|
||||
vi.mock("./_code-editor", () => ({
|
||||
CodeEditor: (props: { error?: string; onChange(value: string): void }) => {
|
||||
editorProps(props);
|
||||
return <button onClick={() => props.onChange("{invalid")}>edit</button>;
|
||||
},
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe("preview JSON editor", () => {
|
||||
it("surfaces invalid JSON and clears the previously rendered options", async () => {
|
||||
const onChange = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(<JsonPreviewEditor id="options" label="Options" value={{ stale: true }} onChange={onChange} />);
|
||||
});
|
||||
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({});
|
||||
expect(editorProps).toHaveBeenLastCalledWith(expect.objectContaining({ error: "invalidJson" }));
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
|
||||
@@ -16,19 +18,26 @@ export function JsonPreviewEditor({
|
||||
value: Record<string, unknown>;
|
||||
onChange(value: Record<string, unknown>): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const serialized = JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
useEffect(() => setDraft(serialized), [serialized]);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!invalid) setDraft(serialized);
|
||||
}, [invalid, serialized]);
|
||||
return (
|
||||
<CodeEditor
|
||||
id={id}
|
||||
label={label}
|
||||
language="json"
|
||||
value={draft}
|
||||
error={invalid ? t("invalidJson") : undefined}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
const parsed = parseJson(next);
|
||||
if (isRecord(parsed)) onChange(parsed);
|
||||
const valid = isRecord(parsed);
|
||||
setInvalid(!valid);
|
||||
onChange(valid ? parsed : {});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -48,4 +48,17 @@ describe("custom widget unsaved changes guard", () => {
|
||||
expect(listLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(true);
|
||||
expect(confirmNavigation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the current page and confirms browser history navigation", () => {
|
||||
window.history.replaceState(null, "", "/en/manage/custom-widgets");
|
||||
window.history.pushState(null, "", "/en/manage/custom-widgets/edit/widget-1");
|
||||
const confirmNavigation = vi.fn();
|
||||
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => true, confirmNavigation }));
|
||||
|
||||
window.history.replaceState(null, "", "/en/manage/custom-widgets");
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
|
||||
expect(window.location.pathname).toBe("/en/manage/custom-widgets/edit/widget-1");
|
||||
expect(confirmNavigation).toHaveBeenCalledWith("/en/manage/custom-widgets");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ interface UnsavedChangesGuardOptions {
|
||||
}
|
||||
|
||||
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
|
||||
let currentHref = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
@@ -32,11 +33,22 @@ export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: Unsa
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
};
|
||||
const handlePopState = () => {
|
||||
const destination = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
if (!isDirty()) {
|
||||
currentHref = destination;
|
||||
return;
|
||||
}
|
||||
window.history.pushState(window.history.state, "", currentHref);
|
||||
confirmNavigation(destination);
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleClick, true);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
};
|
||||
}
|
||||
+3
-11
@@ -42,11 +42,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
<Text size="md">
|
||||
{t.rich("integration.testConnection.error.statusCode.otherDescription", {
|
||||
statusCode: error.data.statusCode.toString(),
|
||||
url: () => (
|
||||
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
|
||||
{error.data.url}
|
||||
</Anchor>
|
||||
),
|
||||
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -54,11 +50,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
{t.rich("integration.testConnection.error.statusCode.description", {
|
||||
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`),
|
||||
statusCode: error.data.statusCode.toString(),
|
||||
url: () => (
|
||||
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
|
||||
{error.data.url}
|
||||
</Anchor>
|
||||
),
|
||||
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
@@ -74,7 +66,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
{t("integration.testConnection.error.common.cause.title")}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
|
||||
<pre style={{ whiteSpace: "pre-wrap" }}>
|
||||
{error.name}: {error.message}
|
||||
{"\n"}
|
||||
{causeArray
|
||||
|
||||
+1
-3
@@ -1,8 +1,6 @@
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
type IntegrationCreateErrorResult = Extract<RouterOutputs["integration"]["create"], { error: unknown }>;
|
||||
|
||||
export type AnyMappedTestConnectionError = IntegrationCreateErrorResult["error"];
|
||||
export type AnyMappedTestConnectionError = Exclude<RouterOutputs["integration"]["create"], undefined>["error"];
|
||||
export type MappedTestConnectionCertificateError = Extract<AnyMappedTestConnectionError, { type: "certificate" }>;
|
||||
export type MappedCertificate = MappedTestConnectionCertificateError["data"]["certificate"];
|
||||
export type MappedError = Exclude<AnyMappedTestConnectionError["cause"], undefined>;
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
@@ -10,12 +11,13 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface DeleteIntegrationActionButtonProps {
|
||||
count: number;
|
||||
integration: { id: string; name: string };
|
||||
}
|
||||
|
||||
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
|
||||
export const DeleteIntegrationActionButton = ({ count, integration }: DeleteIntegrationActionButtonProps) => {
|
||||
const t = useScopedI18n("integration.page.delete");
|
||||
const tList = useScopedI18n("integration.page.list.action");
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
|
||||
@@ -25,7 +27,6 @@ export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegration
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={() => {
|
||||
openConfirmModal({
|
||||
title: t("title"),
|
||||
@@ -39,6 +40,9 @@ export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegration
|
||||
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();
|
||||
},
|
||||
@@ -53,7 +57,7 @@ export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegration
|
||||
},
|
||||
});
|
||||
}}
|
||||
aria-label={tList("delete", { name: integration.name })}
|
||||
aria-label={t("title")}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
+39
-49
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
|
||||
@@ -25,10 +25,8 @@ import { SecretKindsSegmentedControl } from "../../_components/secrets/integrati
|
||||
import { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error";
|
||||
import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types";
|
||||
|
||||
interface EditIntegrationFormProps {
|
||||
interface EditIntegrationForm {
|
||||
integration: RouterOutputs["integration"]["byId"];
|
||||
embedded?: boolean;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||
@@ -44,7 +42,7 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||
}),
|
||||
);
|
||||
|
||||
export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }: EditIntegrationFormProps) => {
|
||||
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const allSecretKinds = getAllSecretKindOptions(integration.kind);
|
||||
@@ -71,17 +69,15 @@ export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
|
||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||
const errorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||
|
||||
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
|
||||
setError(null);
|
||||
try {
|
||||
const url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
const data = await mutateAsync({
|
||||
const url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
await mutateAsync(
|
||||
{
|
||||
id: integration.id,
|
||||
...values,
|
||||
url,
|
||||
@@ -90,34 +86,34 @@ export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }
|
||||
value: secret.value === "" ? null : secret.value,
|
||||
})),
|
||||
appId: app?.id ?? null,
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
// We do it this way as we are unable to send a typesafe error through onError
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// We do it this way as we are unable to send a typesafe error through onError
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
requestAnimationFrame(() => errorRef.current?.focus());
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
});
|
||||
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
|
||||
onSuccess?.();
|
||||
if (!embedded) {
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
}
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
}
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
});
|
||||
void utils.integration.invalidate();
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const isInitialSecretKinds =
|
||||
@@ -194,18 +190,12 @@ export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }
|
||||
|
||||
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
||||
|
||||
{error !== null && (
|
||||
<div ref={errorRef} role="alert" tabIndex={-1}>
|
||||
<IntegrationTestConnectionError error={error} url={form.values.url} />
|
||||
</div>
|
||||
)}
|
||||
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||
|
||||
<Group justify="end" align="center">
|
||||
{!embedded && (
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("integration.testConnection.action.edit")}
|
||||
</Button>
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
@@ -50,12 +49,12 @@ interface NewIntegrationFormProps {
|
||||
kind: IntegrationKind;
|
||||
initialUrl?: string;
|
||||
initialName?: string;
|
||||
onSuccess: (result?: CreatedIntegrationResult) => void;
|
||||
onSuccess: () => void;
|
||||
onCancel?: () => void;
|
||||
onSkip?: () => void;
|
||||
isOnboarding?: boolean;
|
||||
}
|
||||
|
||||
export type CreatedIntegrationResult = Extract<RouterOutputs["integration"]["create"], { integration: unknown }>;
|
||||
|
||||
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||
z.object({
|
||||
hasApp: z.boolean(),
|
||||
@@ -64,24 +63,26 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||
}),
|
||||
);
|
||||
|
||||
export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, onCancel }: NewIntegrationFormProps) => {
|
||||
export const NewIntegrationForm = ({
|
||||
kind,
|
||||
initialUrl,
|
||||
initialName,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
onSkip,
|
||||
isOnboarding = false,
|
||||
}: NewIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const secretKinds = getAllSecretKindOptions(kind);
|
||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||
const { data: session } = useSession();
|
||||
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
||||
const validationSchema = canCreateApps
|
||||
? formSchema
|
||||
: formSchema.superRefine((values, context) => {
|
||||
if (!values.hasApp || values.appId !== null) return;
|
||||
context.addIssue({ code: "custom", message: t("common.zod.errors.required"), path: ["appId"] });
|
||||
});
|
||||
const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false);
|
||||
|
||||
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
|
||||
if (hasUrlSecret) {
|
||||
url = "http://localhost";
|
||||
}
|
||||
const form = useZodForm(validationSchema, {
|
||||
const form = useZodForm(formSchema, {
|
||||
initialValues: {
|
||||
name: initialName ?? getIntegrationName(kind),
|
||||
url,
|
||||
@@ -89,9 +90,9 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
kind,
|
||||
value: "",
|
||||
})),
|
||||
attemptSearchEngineCreation: true,
|
||||
hasApp: canCreateApps,
|
||||
appHref: url,
|
||||
attemptSearchEngineCreation: !isOnboarding,
|
||||
hasApp: !isOnboarding,
|
||||
appHref: isOnboarding ? null : url,
|
||||
appId: null,
|
||||
},
|
||||
});
|
||||
@@ -103,6 +104,13 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
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) => {
|
||||
@@ -110,9 +118,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
|
||||
const onMutationSuccess = (
|
||||
data: CreatedIntegrationResult | { error?: AnyMappedTestConnectionError } | undefined | void,
|
||||
) => {
|
||||
const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => {
|
||||
if (data && "error" in data && data.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
@@ -127,7 +133,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
message: t("integration.page.create.notification.success.message"),
|
||||
});
|
||||
|
||||
onSuccess(data && "integration" in data ? data : undefined);
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
const onMutationError = () => {
|
||||
@@ -137,20 +143,26 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
? appId !== null
|
||||
? { id: appId }
|
||||
: canCreateApps
|
||||
? {
|
||||
name: values.name,
|
||||
href: hasCustomHref ? appHref : url,
|
||||
iconUrl: getIconUrl(kind),
|
||||
description: null,
|
||||
pingUrl: url,
|
||||
}
|
||||
: undefined
|
||||
: {
|
||||
name: values.name,
|
||||
href: hasCustomHref ? appHref : url,
|
||||
iconUrl: getIconUrl(kind),
|
||||
description: null,
|
||||
pingUrl: url,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await createIntegrationAsync(
|
||||
@@ -194,7 +206,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
|
||||
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||
|
||||
{supportsSearchEngine && (
|
||||
{!isOnboarding && supportsSearchEngine && (
|
||||
<Checkbox
|
||||
label={t("integration.field.attemptSearchEngineCreation.label")}
|
||||
description={t("integration.field.attemptSearchEngineCreation.description", {
|
||||
@@ -204,7 +216,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
/>
|
||||
)}
|
||||
|
||||
<AppForm form={form} canCreateApps={canCreateApps} />
|
||||
{!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />}
|
||||
|
||||
<Group justify="end" align="center">
|
||||
{onCancel ? (
|
||||
@@ -216,7 +228,12 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isCreatePending}>
|
||||
{onSkip && (
|
||||
<Button variant="default" onClick={onSkip}>
|
||||
{t("common.action.skip")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("integration.testConnection.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -242,10 +259,8 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
|
||||
checkboxInputProps.onChange(event);
|
||||
});
|
||||
}}
|
||||
label={t(canCreateApps ? "integration.field.createApp.label" : "integration.field.linkApp.label")}
|
||||
description={t(
|
||||
canCreateApps ? "integration.field.createApp.description" : "integration.field.linkApp.description",
|
||||
)}
|
||||
label={t("integration.field.createApp.label")}
|
||||
description={t("integration.field.createApp.description")}
|
||||
/>
|
||||
|
||||
<Collapse expanded={form.values.hasApp}>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
.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,80 +1,66 @@
|
||||
import { Fragment } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Badge, Text } from "@mantine/core";
|
||||
import {
|
||||
AccordionControl,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Anchor,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
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 { getSafeApplicationUrl } from "@homarr/common";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { IntegrationAvatar, Link, SearchInput } from "@homarr/ui";
|
||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
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";
|
||||
|
||||
const searchParamsSchema = z.object({ search: z.string().optional() });
|
||||
import classes from "./page.module.css";
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
searchParams: Promise<z.infer<typeof searchParamsSchema>>;
|
||||
searchParams: Promise<{
|
||||
tab?: IntegrationKind;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
if (!session) redirect("/auth/login");
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
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 page = (
|
||||
<ManageCollectionPage
|
||||
const canCreateIntegrations = session.user.permissions.includes("integration-create");
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
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={
|
||||
canCreate ? (
|
||||
canCreateIntegrations ? (
|
||||
<TourTarget id="manage-integrations-create">
|
||||
<MobileAffixButton component={Link} href="/manage/integrations/new">
|
||||
{t("action.create")}
|
||||
@@ -82,83 +68,128 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={
|
||||
<SearchInput
|
||||
placeholder={`${t("page.list.search")}...`}
|
||||
ariaLabel={t("page.list.search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
}
|
||||
floatingPrimaryAction={canCreate}
|
||||
floatingPrimaryAction={canCreateIntegrations}
|
||||
>
|
||||
{filteredIntegrations.map((integration) => (
|
||||
<IntegrationItem
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
canManage={hasGlobalFullAccess || integration.permissions.hasFullAccess}
|
||||
/>
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
<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] = [];
|
||||
}
|
||||
|
||||
acc[integration.kind].push(integration);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
|
||||
);
|
||||
|
||||
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);
|
||||
const entries = objectEntries(groupedIntegrations);
|
||||
|
||||
return (
|
||||
<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
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -71,11 +71,7 @@ export default async function MediaListPage(props: MediaListPageProps) {
|
||||
}
|
||||
toolbar={
|
||||
<Group>
|
||||
<SearchInput
|
||||
placeholder={`${t("media.search")}...`}
|
||||
ariaLabel={t("media.search")}
|
||||
defaultValue={searchParams.search}
|
||||
/>
|
||||
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||
{session.user.permissions.includes("media-view-all") && (
|
||||
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,6 @@ 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();
|
||||
|
||||
@@ -51,14 +50,7 @@ export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteBut
|
||||
}, [searchEngine, mutate, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={onClick}
|
||||
aria-label={tList("delete", { name: searchEngine.name })}
|
||||
>
|
||||
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Badge, Text } from "@mantine/core";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, 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 { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
||||
@@ -28,145 +27,123 @@ 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 t = await getScopedI18n("search.engine");
|
||||
|
||||
const tEngine = 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 (
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<ManagePageLayout
|
||||
title={tEngine("page.list.title")}
|
||||
primaryAction={
|
||||
canCreate ? (
|
||||
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||
{t("page.create.title")}
|
||||
{tEngine("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
totalCount > searchParams.pageSize ? (
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
) : undefined
|
||||
}
|
||||
toolbar={<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{searchEngines.map((searchEngine) => (
|
||||
<SearchEngineItem
|
||||
key={searchEngine.id}
|
||||
searchEngine={searchEngine}
|
||||
canModify={canModify}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
||||
{searchEngines.length > 0 && (
|
||||
<Stack gap="sm">
|
||||
{searchEngines.map((searchEngine) => (
|
||||
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchEngineItemProps {
|
||||
interface SearchEngineCardProps {
|
||||
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
|
||||
canModify: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
const SearchEngineItem = async ({ searchEngine, canModify, canDelete }: SearchEngineItemProps) => {
|
||||
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
||||
const t = await getScopedI18n("search.engine");
|
||||
const isIntegration = searchEngine.type === "fromIntegration";
|
||||
const previewUrl =
|
||||
searchEngine.type === "generic" && searchEngine.urlTemplate !== null
|
||||
? getSafeApplicationUrl(searchEngine.urlTemplate.replace("%s", "test"))
|
||||
: undefined;
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<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}
|
||||
<Card>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Avatar
|
||||
size="sm"
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{searchEngine.urlTemplate}
|
||||
</Anchor>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canModify || canDelete ? (
|
||||
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>
|
||||
<ActionIconGroup>
|
||||
{canModify && (
|
||||
{session?.user.permissions.includes("search-engine-modify-all") && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/search-engines/edit/${searchEngine.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: searchEngine.name })}
|
||||
aria-label={t("page.edit.title")}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{canDelete && <SearchEngineDeleteButton searchEngine={searchEngine} />}
|
||||
{session?.user.permissions.includes("search-engine-full-all") && (
|
||||
<SearchEngineDeleteButton searchEngine={searchEngine} />
|
||||
)}
|
||||
</ActionIconGroup>
|
||||
) : undefined
|
||||
}
|
||||
</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"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
dismissDockerReconciliationCandidate,
|
||||
filterDockerReconciliationInbox,
|
||||
getValidDockerServiceUrl,
|
||||
} from "./docker-reconciliation-inbox";
|
||||
|
||||
const candidates = [
|
||||
{ candidateKey: "home:new", state: "newRecognized" as const },
|
||||
{ candidateKey: "home:new-app", state: "newApp" as const },
|
||||
{ candidateKey: "remote:moved", state: "moved" as const },
|
||||
{ candidateKey: "home:linked", state: "linked" as const },
|
||||
{ candidateKey: "home:represented", state: "represented" as const },
|
||||
];
|
||||
|
||||
describe("Docker reconciliation inbox", () => {
|
||||
test("separates attention items from represented services", () => {
|
||||
expect(
|
||||
filterDockerReconciliationInbox(candidates, "attention", []).map(({ candidateKey }) => candidateKey),
|
||||
).toEqual(["home:new", "home:new-app", "remote:moved"]);
|
||||
expect(
|
||||
filterDockerReconciliationInbox(candidates, "represented", []).map(({ candidateKey }) => candidateKey),
|
||||
).toEqual(["home:linked", "home:represented"]);
|
||||
});
|
||||
|
||||
test("dismisses only the endpoint-qualified candidate key and remains idempotent", () => {
|
||||
const dismissed = dismissDockerReconciliationCandidate([], "home:new");
|
||||
|
||||
expect(
|
||||
filterDockerReconciliationInbox(candidates, "all", dismissed).map(({ candidateKey }) => candidateKey),
|
||||
).not.toContain("home:new");
|
||||
expect(filterDockerReconciliationInbox(candidates, "all", dismissed)).toContainEqual({
|
||||
candidateKey: "remote:moved",
|
||||
state: "moved",
|
||||
});
|
||||
expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed);
|
||||
});
|
||||
|
||||
test("accepts only HTTP service URLs", () => {
|
||||
expect(getValidDockerServiceUrl("https://service.example.com")).toBe("https://service.example.com/");
|
||||
expect(getValidDockerServiceUrl("ftp://service.example.com")).toBeNull();
|
||||
expect(getValidDockerServiceUrl("service.example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
export type DockerReconciliationInboxFilter = "attention" | "represented" | "all";
|
||||
|
||||
interface DockerReconciliationInboxCandidate {
|
||||
candidateKey: string;
|
||||
state: "newRecognized" | "newApp" | "represented" | "linked" | "moved";
|
||||
}
|
||||
|
||||
const attentionStates = new Set<DockerReconciliationInboxCandidate["state"]>(["newRecognized", "newApp", "moved"]);
|
||||
|
||||
export const filterDockerReconciliationInbox = <Candidate extends DockerReconciliationInboxCandidate>(
|
||||
candidates: Candidate[],
|
||||
filter: DockerReconciliationInboxFilter,
|
||||
dismissedCandidateKeys: string[],
|
||||
) => {
|
||||
const dismissed = new Set(dismissedCandidateKeys);
|
||||
return candidates.filter((candidate) => {
|
||||
if (dismissed.has(candidate.candidateKey)) return false;
|
||||
if (filter === "attention") return attentionStates.has(candidate.state);
|
||||
if (filter === "represented") return candidate.state === "represented" || candidate.state === "linked";
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const dismissDockerReconciliationCandidate = (dismissedCandidateKeys: string[], candidateKey: string) =>
|
||||
dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey];
|
||||
|
||||
export const getValidDockerServiceUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,344 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconArrowRight, IconEyeOff, IconPlugConnected, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox";
|
||||
import {
|
||||
dismissDockerReconciliationCandidate,
|
||||
filterDockerReconciliationInbox,
|
||||
getValidDockerServiceUrl,
|
||||
} from "./docker-reconciliation-inbox";
|
||||
|
||||
type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number];
|
||||
type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number];
|
||||
|
||||
export const DockerReconciliation = () => {
|
||||
const t = useScopedI18n("docker.reconciliation");
|
||||
const utils = clientApi.useUtils();
|
||||
const reconciliation = clientApi.docker.reconcileServices.useQuery();
|
||||
const health = clientApi.docker.getServiceHealth.useQuery();
|
||||
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
|
||||
async onSuccess() {
|
||||
await Promise.all([
|
||||
utils.docker.getContainers.invalidate(),
|
||||
utils.docker.reconcileServices.invalidate(),
|
||||
utils.docker.getServiceHealth.invalidate(),
|
||||
]);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("refreshError.title"),
|
||||
message: t("refreshError.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const [filter, setFilter] = useState<DockerReconciliationInboxFilter>("attention");
|
||||
const [dismissedCandidateKeys, setDismissedCandidateKeys] = useLocalStorage<string[]>({
|
||||
key: "homarr-docker-reconciliation-dismissed",
|
||||
defaultValue: [],
|
||||
});
|
||||
|
||||
if (reconciliation.isError) {
|
||||
return (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("loadError.title")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("loadError.message")}</Text>
|
||||
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void reconciliation.refetch()}>
|
||||
{t("action.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!reconciliation.data) return null;
|
||||
|
||||
const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable");
|
||||
const candidates = filterDockerReconciliationInbox(reconciliation.data.candidates, filter, dismissedCandidateKeys);
|
||||
const attentionCount = health.data
|
||||
? health.data.services.filter(
|
||||
({ key, status }) =>
|
||||
["newRecognized", "newApp", "moved"].includes(status) && !dismissedCandidateKeys.includes(key),
|
||||
).length
|
||||
: 0;
|
||||
const isRefreshing = refreshInventory.isPending || reconciliation.isFetching || health.isFetching;
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Group justify="space-between" align="start">
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<IconPlugConnected size={20} />
|
||||
<Text fw={600}>{t("title")}</Text>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("description")}
|
||||
</Text>
|
||||
</div>
|
||||
{health.data && <Badge variant="light">{t("attention", { count: String(attentionCount) })}</Badge>}
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as DockerReconciliationInboxFilter)}
|
||||
data={[
|
||||
{ value: "attention", label: t("filter.attention") },
|
||||
{ value: "represented", label: t("filter.represented") },
|
||||
{ value: "all", label: t("filter.all") },
|
||||
]}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
{dismissedCandidateKeys.length > 0 && (
|
||||
<Button variant="subtle" size="xs" onClick={() => setDismissedCandidateKeys([])}>
|
||||
{t("action.restoreDismissed", { count: String(dismissedCandidateKeys.length) })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
loading={isRefreshing}
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
>
|
||||
{t("action.refresh")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{unavailableEndpoints.length > 0 && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("endpointUnavailable", { names: unavailableEndpoints.map(({ name }) => name).join(", ") })}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{candidates.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{reconciliation.data.candidates.length === 0 ? t("empty") : t("emptyFilter")}
|
||||
</Text>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }}>
|
||||
{candidates.map((candidate) => (
|
||||
<DockerReconciliationCandidate
|
||||
key={candidate.candidateKey}
|
||||
candidate={candidate}
|
||||
health={health.data?.services.find(({ key }) => key === candidate.candidateKey)}
|
||||
onDismiss={() =>
|
||||
setDismissedCandidateKeys((current) =>
|
||||
dismissDockerReconciliationCandidate(current, candidate.candidateKey),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const DockerReconciliationCandidate = ({
|
||||
candidate,
|
||||
health,
|
||||
onDismiss,
|
||||
}: {
|
||||
candidate: ReconciliationCandidate;
|
||||
health: ServiceHealth | undefined;
|
||||
onDismiss: () => void;
|
||||
}) => {
|
||||
const t = useScopedI18n("docker.reconciliation");
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const initialUrlCandidate =
|
||||
candidate.urlCandidates.find(
|
||||
({ url, scopes }) => url.length > 0 && (candidate.match ? true : scopes.includes("browser")),
|
||||
) ?? candidate.urlCandidates.find(({ source }) => source === "manual");
|
||||
const [selectedCandidateId, setSelectedCandidateId] = useState(initialUrlCandidate?.id ?? null);
|
||||
const [url, setUrl] = useState(initialUrlCandidate?.url ?? "");
|
||||
const selectedCandidate = candidate.urlCandidates.find(({ id }) => id === selectedCandidateId);
|
||||
const validatedUrl = getValidDockerServiceUrl(url);
|
||||
const isInvalidUrl = url.length > 0 && validatedUrl === null;
|
||||
const target = getCandidateTarget(candidate, validatedUrl ?? "");
|
||||
const actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration";
|
||||
const isActionDisabled = actionNeedsUrl && validatedUrl === null;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCandidateId(initialUrlCandidate?.id ?? null);
|
||||
setUrl(initialUrlCandidate?.url ?? "");
|
||||
}, [initialUrlCandidate?.id, initialUrlCandidate?.url]);
|
||||
|
||||
return (
|
||||
<Card withBorder padding="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="start" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar src={candidate.container.iconUrl} radius="sm">
|
||||
{candidate.container.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text fw={600} lineClamp={1}>
|
||||
{candidate.container.name}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{candidate.endpointName}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={stateColor(candidate.state)} variant="light">
|
||||
{t(`state.${candidate.state}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{candidate.match && (
|
||||
<Group gap="xs">
|
||||
<Badge variant="outline">{candidate.match.kind}</Badge>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("match", { confidence: t(`confidence.${candidate.match.confidence}`) })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{candidate.representation.signals.ambiguous && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("ambiguous")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{health && (
|
||||
<div>
|
||||
<Text c="dimmed" size="xs" mb={4}>
|
||||
{t("health.title")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{health.layers.map((layer) => (
|
||||
<Badge key={layer.layer} color={healthStatusColor(layer.status)} variant="dot" size="sm">
|
||||
{t(`health.layer.${layer.layer}`)}: {t(`health.status.${layer.status}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label={t("url.label")}
|
||||
description={selectedCandidate ? t(`reason.${selectedCandidate.reason}`) : t("reason.manualHostRequired")}
|
||||
value={selectedCandidateId}
|
||||
data={candidate.urlCandidates.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.url || t("url.manual"),
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setSelectedCandidateId(value);
|
||||
setUrl(candidate.urlCandidates.find(({ id }) => id === value)?.url ?? "");
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("url.inputLabel")}
|
||||
aria-label={t("url.inputLabel")}
|
||||
value={url}
|
||||
placeholder="https://service.example.com"
|
||||
error={isInvalidUrl ? t("url.invalid") : undefined}
|
||||
onChange={(event) => setUrl(event.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" color="gray" leftSection={<IconEyeOff size={16} />} onClick={onDismiss}>
|
||||
{t("action.dismiss")}
|
||||
</Button>
|
||||
{target.kind === "createApp" ? (
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
onClick={() =>
|
||||
openModal({
|
||||
selectedContainers: [candidate.container],
|
||||
initialUrls: validatedUrl ? [validatedUrl] : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("action.createApp")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
component={Link}
|
||||
href={target.href}
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
onClick={(event) => {
|
||||
if (isActionDisabled) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t(`action.${target.kind}`)}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const getCandidateTarget = (candidate: ReconciliationCandidate, url: string) => {
|
||||
if (candidate.representation.signals.ambiguous) {
|
||||
if (candidate.nextAction === "reviewIntegration" && candidate.match) {
|
||||
const params = new URLSearchParams({ search: getIntegrationName(candidate.match.kind) });
|
||||
return { kind: "reviewIntegration" as const, href: `/manage/integrations?${params.toString()}` };
|
||||
}
|
||||
return { kind: "viewRepresentation" as const, href: "/manage/apps" };
|
||||
}
|
||||
if (candidate.state === "newRecognized" && candidate.match) {
|
||||
const params = new URLSearchParams({ kind: candidate.match.kind, name: candidate.container.name });
|
||||
if (url) params.set("url", url);
|
||||
return { kind: "setupIntegration" as const, href: `/manage/integrations/new?${params.toString()}` };
|
||||
}
|
||||
|
||||
if (candidate.state === "newApp") return { kind: "createApp" as const };
|
||||
if (candidate.representation.integration) {
|
||||
return {
|
||||
kind: candidate.state === "moved" ? ("reviewIntegration" as const) : ("viewRepresentation" as const),
|
||||
href: `/manage/integrations/edit/${candidate.representation.integration.id}`,
|
||||
};
|
||||
}
|
||||
if (candidate.representation.app) {
|
||||
return { kind: "viewRepresentation" as const, href: `/manage/apps/edit/${candidate.representation.app.id}` };
|
||||
}
|
||||
|
||||
return { kind: "createApp" as const };
|
||||
};
|
||||
|
||||
const stateColor = (state: ReconciliationCandidate["state"]) => {
|
||||
if (state === "linked" || state === "represented") return "green";
|
||||
if (state === "moved") return "yellow";
|
||||
return "blue";
|
||||
};
|
||||
|
||||
const healthStatusColor = (status: ServiceHealth["layers"][number]["status"]) => {
|
||||
if (["available", "configured", "linked"].includes(status)) return "green";
|
||||
if (["missing", "changed", "unused"].includes(status)) return "yellow";
|
||||
return "gray";
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { ActionIcon, Alert, Avatar, Badge, Box, Button, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCategoryPlus,
|
||||
IconDots,
|
||||
IconFileText,
|
||||
@@ -20,10 +20,10 @@ import { MantineReactTable } from "mantine-react-table";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatBytes, useTimeAgo } from "@homarr/common";
|
||||
import type { ContainerState, DockerEndpointCapability } from "@homarr/docker";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr, useDockerContainerRemovalConfirmation } from "@homarr/modals-collection";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -32,41 +32,9 @@ import { OverflowBadge } from "@homarr/ui";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
||||
type DockerEndpoint = RouterOutputs["docker"]["getContainers"]["endpoints"][number];
|
||||
|
||||
const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" | "id" | "name">) =>
|
||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}&endpointId=${encodeURIComponent(container.endpointId)}`;
|
||||
|
||||
const getContainerTarget = ({ endpointId, id }: DockerContainer) => ({ endpointId, id });
|
||||
|
||||
const showContainerActionResult = (
|
||||
action: "start" | "stop" | "restart" | "remove",
|
||||
containers: DockerContainer[],
|
||||
results: RouterOutputs["docker"]["startAll"],
|
||||
t: TranslationFunction,
|
||||
) => {
|
||||
const failures = results.filter((result) => !result.success);
|
||||
if (failures.length === 0) {
|
||||
showSuccessNotification({
|
||||
title: t(`docker.action.${action}.notification.success.title`),
|
||||
message: t(`docker.action.${action}.notification.success.message`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const failedNames = failures.map(
|
||||
({ target }) =>
|
||||
containers.find(({ endpointId, id }) => endpointId === target.endpointId && id === target.id)?.name ?? target.id,
|
||||
);
|
||||
showErrorNotification({
|
||||
title: t("docker.action.result.failure.title"),
|
||||
message: t("docker.action.result.failure.message", {
|
||||
failed: String(failures.length),
|
||||
total: String(results.length),
|
||||
names: failedNames.join(", "),
|
||||
}),
|
||||
});
|
||||
};
|
||||
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
|
||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
|
||||
|
||||
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
|
||||
{
|
||||
@@ -189,39 +157,32 @@ interface DockerTableProps {
|
||||
export function DockerTable({ initialData }: DockerTableProps) {
|
||||
const t = useI18n();
|
||||
const tDocker = useScopedI18n("docker");
|
||||
const utils = clientApi.useUtils();
|
||||
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
const { data, isFetching, refetch } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
|
||||
async onSuccess() {
|
||||
await Promise.all([
|
||||
utils.docker.getContainers.invalidate(),
|
||||
utils.docker.reconcileServices.invalidate(),
|
||||
utils.docker.getServiceHealth.invalidate(),
|
||||
]);
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
||||
const mutate = useCallback(() => {
|
||||
void refetch().then((result) => {
|
||||
if (result.isError) {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
} else {
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [refetch, tDocker]);
|
||||
|
||||
const containers = data?.containers ?? [];
|
||||
const unavailableEndpoints = data?.endpoints.filter(({ status }) => status === "unavailable") ?? [];
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data: containers,
|
||||
getRowId: (row) => row.resourceId,
|
||||
state: { isLoading: isFetching || refreshInventory.isPending },
|
||||
state: { isLoading: isFetching },
|
||||
enableDensityToggle: false,
|
||||
enableColumnActions: false,
|
||||
enableColumnFilters: false,
|
||||
@@ -246,18 +207,13 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
||||
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
|
||||
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
|
||||
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
|
||||
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => (
|
||||
<ContainerRowMenu
|
||||
container={row.original}
|
||||
endpoint={data?.endpoints.find(({ id }) => id === row.original.endpointId)}
|
||||
/>
|
||||
),
|
||||
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button
|
||||
variant="default"
|
||||
rightSection={<IconRefresh size="1rem" />}
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
loading={isFetching || refreshInventory.isPending}
|
||||
onClick={() => mutate()}
|
||||
loading={isFetching}
|
||||
>
|
||||
{tDocker("action.refresh.label")}
|
||||
</Button>
|
||||
@@ -273,7 +229,7 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
||||
totalCount: String(table.getRowCount()),
|
||||
})}
|
||||
</Text>
|
||||
<ContainerActionBar selectedContainers={dockerContainers} endpoints={data?.endpoints ?? []} />
|
||||
<ContainerActionBar selectedContainers={dockerContainers} />
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -282,18 +238,10 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{unavailableEndpoints.length > 0 && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{tDocker("error.endpointUnavailable", {
|
||||
count: String(unavailableEndpoints.length),
|
||||
names: unavailableEndpoints.map(({ name }) => name).join(", "),
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
<>
|
||||
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
|
||||
<MantineReactTable table={table} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,21 +252,22 @@ const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon:
|
||||
{ action: "remove", icon: IconTrash, color: "red" },
|
||||
];
|
||||
|
||||
const ContainerRowMenu = ({ container, endpoint }: { container: DockerContainer; endpoint?: DockerEndpoint }) => {
|
||||
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const tAll = useI18n();
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const confirmRemoval = useDockerContainerRemovalConfirmation();
|
||||
|
||||
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
|
||||
clientApi.docker[`${action}All`].useMutation({
|
||||
async onSettled() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
},
|
||||
onSuccess(results) {
|
||||
showContainerActionResult(action, [container], results, tAll);
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t(`${action}.notification.success.title`),
|
||||
message: t(`${action}.notification.success.message`),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
@@ -345,7 +294,6 @@ const ContainerRowMenu = ({ container, endpoint }: { container: DockerContainer;
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileText size="1rem" />}
|
||||
disabled={!endpointHasCapability(endpoint, "logs")}
|
||||
onClick={() => router.push(createContainerLogsPath(container))}
|
||||
>
|
||||
{t("logs.label")}
|
||||
@@ -356,12 +304,7 @@ const ContainerRowMenu = ({ container, endpoint }: { container: DockerContainer;
|
||||
key={action}
|
||||
leftSection={<Icon size="1rem" />}
|
||||
color={color}
|
||||
disabled={!endpointHasCapability(endpoint, action === "remove" ? "remove" : "lifecycle")}
|
||||
onClick={() => {
|
||||
const runAction = () => mutations[action].mutate({ targets: [getContainerTarget(container)] });
|
||||
if (action === "remove") confirmRemoval([container], runAction);
|
||||
else runAction();
|
||||
}}
|
||||
onClick={() => mutations[action].mutate({ ids: [container.id] })}
|
||||
>
|
||||
{t(`${action}.label`)}
|
||||
</Menu.Item>
|
||||
@@ -381,24 +324,18 @@ const ContainerRowMenu = ({ container, endpoint }: { container: DockerContainer;
|
||||
|
||||
interface ContainerActionBarProps {
|
||||
selectedContainers: DockerContainer[];
|
||||
endpoints: DockerEndpoint[];
|
||||
}
|
||||
|
||||
const ContainerActionBar = ({ selectedContainers, endpoints }: ContainerActionBarProps) => {
|
||||
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
|
||||
const selectedIds = selectedContainers.map((container) => container.id);
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{containerActions.map(({ action, icon, color }) => (
|
||||
<ContainerActionBarButton
|
||||
key={action}
|
||||
icon={icon}
|
||||
color={color}
|
||||
action={action}
|
||||
selectedContainers={selectedContainers}
|
||||
endpoints={endpoints}
|
||||
/>
|
||||
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
|
||||
))}
|
||||
<Button
|
||||
leftSection={<IconCategoryPlus />}
|
||||
@@ -416,22 +353,12 @@ interface ContainerActionBarButtonProps {
|
||||
icon: TablerIcon;
|
||||
color: MantineColor;
|
||||
action: "start" | "stop" | "restart" | "remove";
|
||||
selectedContainers: DockerContainer[];
|
||||
endpoints: DockerEndpoint[];
|
||||
selectedIds: string[];
|
||||
}
|
||||
|
||||
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const tAll = useI18n();
|
||||
const utils = clientApi.useUtils();
|
||||
const confirmRemoval = useDockerContainerRemovalConfirmation();
|
||||
const requiredCapability = props.action === "remove" ? "remove" : "lifecycle";
|
||||
const isAllowed = props.selectedContainers.every((container) =>
|
||||
endpointHasCapability(
|
||||
props.endpoints.find(({ id }) => id === container.endpointId),
|
||||
requiredCapability,
|
||||
),
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation({
|
||||
async onSettled() {
|
||||
@@ -440,28 +367,23 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
});
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
const runActionAsync = async () => {
|
||||
await mutateAsync(
|
||||
{ targets: props.selectedContainers.map(getContainerTarget) },
|
||||
{
|
||||
onSuccess(results) {
|
||||
showContainerActionResult(props.action, props.selectedContainers, results, tAll);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t(`${props.action}.notification.error.title`),
|
||||
message: t(`${props.action}.notification.error.message`),
|
||||
});
|
||||
},
|
||||
await mutateAsync(
|
||||
{ ids: props.selectedIds },
|
||||
{
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t(`${props.action}.notification.success.title`),
|
||||
message: t(`${props.action}.notification.success.message`),
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (props.action === "remove") {
|
||||
confirmRemoval(props.selectedContainers, runActionAsync);
|
||||
return;
|
||||
}
|
||||
await runActionAsync();
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t(`${props.action}.notification.error.title`),
|
||||
message: t(`${props.action}.notification.error.message`),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -470,7 +392,6 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
color={props.color}
|
||||
onClick={handleClickAsync}
|
||||
loading={isPending}
|
||||
disabled={!isAllowed}
|
||||
variant="light"
|
||||
>
|
||||
{t(`${props.action}.label`)}
|
||||
@@ -478,9 +399,6 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const endpointHasCapability = (endpoint: DockerEndpoint | undefined, capability: DockerEndpointCapability) =>
|
||||
endpoint && "capabilities" in endpoint ? endpoint.capabilities.includes(capability) : true;
|
||||
|
||||
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
||||
const t = useScopedI18n("docker.field.state.option");
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ClientSideDockerLogsTerminal } from "./client";
|
||||
|
||||
interface DockerContainerLogsPageProps {
|
||||
params: Promise<{ containerId: string }>;
|
||||
searchParams: Promise<{ endpointId?: string; name?: string }>;
|
||||
searchParams: Promise<{ name?: string }>;
|
||||
}
|
||||
|
||||
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
|
||||
@@ -23,12 +23,10 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
|
||||
}
|
||||
|
||||
const { containerId } = await params;
|
||||
const { endpointId, name } = await searchParams;
|
||||
const resolvedEndpointId = endpointId ?? (await getOnlyDockerEndpointIdAsync());
|
||||
if (!resolvedEndpointId) notFound();
|
||||
const { name } = await searchParams;
|
||||
|
||||
try {
|
||||
await api.docker.logs({ endpointId: resolvedEndpointId, id: containerId, tail: 1 });
|
||||
await api.docker.logs({ id: containerId, tail: 1 });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
@@ -42,17 +40,8 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
|
||||
/>
|
||||
</Group>
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideDockerLogsTerminal endpointId={resolvedEndpointId} containerId={containerId} />
|
||||
<ClientSideDockerLogsTerminal containerId={containerId} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getOnlyDockerEndpointIdAsync = async () => {
|
||||
try {
|
||||
const { endpoints } = await api.docker.getContainers();
|
||||
return endpoints.length === 1 ? endpoints[0]?.id : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -11,11 +11,10 @@ import { clientApi } from "@homarr/api/client";
|
||||
import classes from "./terminal.module.css";
|
||||
|
||||
interface DockerLogsTerminalProps {
|
||||
endpointId: string;
|
||||
containerId: string;
|
||||
}
|
||||
|
||||
export const DockerLogsTerminal = ({ endpointId, containerId }: DockerLogsTerminalProps) => {
|
||||
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const pendingLogsRef = useRef<string[]>([]);
|
||||
@@ -32,7 +31,7 @@ export const DockerLogsTerminal = ({ endpointId, containerId }: DockerLogsTermin
|
||||
}, []);
|
||||
|
||||
clientApi.docker.subscribeLogs.useSubscription(
|
||||
{ endpointId, id: containerId, tail: 200 },
|
||||
{ id: containerId, tail: 200 },
|
||||
{
|
||||
onData: writeToTerminal,
|
||||
onError(err) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { env } from "@homarr/docker/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { DockerReconciliation } from "./docker-reconciliation";
|
||||
import { DockerTable } from "./docker-table";
|
||||
|
||||
export default async function DockerPage() {
|
||||
@@ -16,7 +15,7 @@ export default async function DockerPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { containers, endpoints, timestamp } = await api.docker.getContainers();
|
||||
const { containers, timestamp } = await api.docker.getContainers();
|
||||
const tDocker = await getScopedI18n("docker");
|
||||
|
||||
return (
|
||||
@@ -24,8 +23,7 @@ export default async function DockerPage() {
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tDocker("title")}</Title>
|
||||
<DockerReconciliation />
|
||||
<DockerTable initialData={{ containers, endpoints, timestamp }} />
|
||||
<DockerTable initialData={{ containers, timestamp }} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
export type CatalogFixtureId = "ready" | "needsSetup" | "noConnection" | "error";
|
||||
export type CatalogInteractionId = "default" | "selected" | "loading" | "disabled";
|
||||
export type ResponseFixtureId = "loading" | "success" | "failure";
|
||||
|
||||
export interface CatalogStateFixture {
|
||||
id: CatalogFixtureId;
|
||||
color: "green" | "yellow" | "gray" | "red";
|
||||
}
|
||||
|
||||
export const catalogStateFixtures: readonly CatalogStateFixture[] = [
|
||||
{ id: "ready", color: "green" },
|
||||
{ id: "needsSetup", color: "yellow" },
|
||||
{ id: "noConnection", color: "gray" },
|
||||
{ id: "error", color: "red" },
|
||||
];
|
||||
|
||||
export const catalogInteractionFixtures: readonly CatalogInteractionId[] = [
|
||||
"default",
|
||||
"selected",
|
||||
"loading",
|
||||
"disabled",
|
||||
];
|
||||
|
||||
export const responseStateFixtures = [
|
||||
{ id: "loading" },
|
||||
{
|
||||
id: "success",
|
||||
payload: { status: "ok", service: "fixture-service", version: "2.0" },
|
||||
},
|
||||
{
|
||||
id: "failure",
|
||||
payload: { status: 503, message: "Service unavailable" },
|
||||
},
|
||||
] as const;
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { FeatureStateWorkbench } from "./_feature-state-workbench";
|
||||
import { catalogStateFixtures, responseStateFixtures } from "./_feature-state-fixtures";
|
||||
import { getResponseContractFixtureResultsAsync } from "./_response-contract-fixtures";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) =>
|
||||
({
|
||||
"catalog.state.needsSetup.label": "Service needing setup",
|
||||
"catalog.state.needsSetup.status": "Needs setup",
|
||||
"catalog.interaction.loading": "Loading",
|
||||
"catalog.interaction.disabled": "Disabled",
|
||||
"response.state.failure.label": "Failure",
|
||||
"response.state.failure.title": "Response rejected",
|
||||
"response.contractPassed": "Fixture contract passed",
|
||||
"catalog.loadingAriaLabel": "Loading catalog item",
|
||||
"catalog.loading": "Loading",
|
||||
})[key] ?? key,
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root.unmount());
|
||||
host.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const clickControl = async (label: string) => {
|
||||
const control = [...host.querySelectorAll<HTMLElement>("label")].find((element) =>
|
||||
element.textContent?.includes(label),
|
||||
);
|
||||
await act(async () => control?.click());
|
||||
};
|
||||
|
||||
describe("FeatureStateWorkbench", () => {
|
||||
test("keeps stable catalog and response fixtures covered by the parser contract", async () => {
|
||||
expect(catalogStateFixtures.map((fixture) => fixture.id)).toEqual(["ready", "needsSetup", "noConnection", "error"]);
|
||||
expect(responseStateFixtures.map((fixture) => fixture.id)).toEqual(["loading", "success", "failure"]);
|
||||
expect(await getResponseContractFixtureResultsAsync()).toEqual([
|
||||
{ name: "success", passed: true, message: undefined },
|
||||
{ name: "failure", passed: true, message: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test("renders setup, busy, disabled, and rejected states with native semantics", async () => {
|
||||
const responseResults = await getResponseContractFixtureResultsAsync();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<FeatureStateWorkbench responseResults={responseResults} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
await clickControl("Needs setup");
|
||||
let catalogButton = host.querySelector<HTMLButtonElement>('button[aria-label^="Service needing setup"]');
|
||||
expect(catalogButton?.getAttribute("aria-label")).toBe("Service needing setup, Needs setup");
|
||||
|
||||
await clickControl("Loading");
|
||||
catalogButton = host.querySelector<HTMLButtonElement>('button[aria-label^="Service needing setup"]');
|
||||
expect(catalogButton?.disabled).toBe(true);
|
||||
expect(catalogButton?.getAttribute("aria-busy")).toBe("true");
|
||||
expect(host.querySelector('[aria-label="Loading catalog item"]')).not.toBeNull();
|
||||
|
||||
await clickControl("Disabled");
|
||||
expect(host.querySelector<HTMLButtonElement>('button[aria-label^="Service needing setup"]')?.disabled).toBe(true);
|
||||
|
||||
await clickControl("Failure");
|
||||
expect(host.querySelector('[role="alert"]')?.textContent).toContain("Response rejected");
|
||||
expect(host.querySelector('[role="alert"]')?.textContent).toContain("Fixture contract passed");
|
||||
});
|
||||
});
|
||||
-196
@@ -1,196 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Code,
|
||||
Group,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertCircle, IconCircleCheck, IconPlugConnected, IconPlugConnectedX } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { CatalogItem } from "@homarr/ui";
|
||||
|
||||
import { catalogInteractionFixtures, catalogStateFixtures, responseStateFixtures } from "./_feature-state-fixtures";
|
||||
import type { CatalogFixtureId, CatalogInteractionId, ResponseFixtureId } from "./_feature-state-fixtures";
|
||||
import type { ResponseContractFixtureResult } from "./_response-contract-fixtures";
|
||||
|
||||
interface FeatureStateWorkbenchProps {
|
||||
responseResults: readonly ResponseContractFixtureResult[];
|
||||
}
|
||||
|
||||
export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbenchProps) {
|
||||
const t = useScopedI18n("featureWorkbench");
|
||||
const [catalogFixtureId, setCatalogFixtureId] = useState<CatalogFixtureId>("ready");
|
||||
const [interactionId, setInteractionId] = useState<CatalogInteractionId>("default");
|
||||
const [responseFixtureId, setResponseFixtureId] = useState<ResponseFixtureId>("loading");
|
||||
const catalogFixture = catalogStateFixtures.find((fixture) => fixture.id === catalogFixtureId);
|
||||
const responseFixture = responseStateFixtures.find((fixture) => fixture.id === responseFixtureId);
|
||||
if (!catalogFixture || !responseFixture) return null;
|
||||
const responseResult = responseResults.find((result) => result.name === responseFixtureId);
|
||||
const busy = interactionId === "loading";
|
||||
const disabled = interactionId === "disabled";
|
||||
const selected = interactionId === "selected";
|
||||
const catalogLabel = t(`catalog.state.${catalogFixture.id}.label`);
|
||||
const catalogStatus = busy ? t("catalog.loading") : t(`catalog.state.${catalogFixture.id}.status`);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" maw={960}>
|
||||
<Paper component="section" withBorder p="lg" aria-labelledby="catalog-workbench-title">
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title id="catalog-workbench-title" order={2} size="h3">
|
||||
{t("catalog.title")}
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm" maw={680}>
|
||||
{t("catalog.description")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600} id="catalog-fixture-label">
|
||||
{t("catalog.serviceState")}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
aria-labelledby="catalog-fixture-label"
|
||||
value={catalogFixtureId}
|
||||
onChange={(value) => setCatalogFixtureId(value as CatalogFixtureId)}
|
||||
data={catalogStateFixtures.map((fixture) => ({
|
||||
value: fixture.id,
|
||||
label: t(`catalog.state.${fixture.id}.status`),
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600} id="catalog-interaction-label">
|
||||
{t("catalog.interactionState")}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
aria-labelledby="catalog-interaction-label"
|
||||
value={interactionId}
|
||||
onChange={(value) => setInteractionId(value as CatalogInteractionId)}
|
||||
data={catalogInteractionFixtures.map((id) => ({
|
||||
value: id,
|
||||
label: t(`catalog.interaction.${id}`),
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
<CatalogItem
|
||||
height={140}
|
||||
label={catalogLabel}
|
||||
status={catalogStatus}
|
||||
selected={selected}
|
||||
busy={busy}
|
||||
disabled={disabled}
|
||||
onSelect={() => setInteractionId(selected ? "default" : "selected")}
|
||||
>
|
||||
{busy ? (
|
||||
<Stack gap="sm" aria-label={t("catalog.loadingAriaLabel")}>
|
||||
<Skeleton height={24} width="65%" />
|
||||
<Skeleton height={16} />
|
||||
<Skeleton height={20} width="40%" />
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack justify="space-between" h="100%" gap="sm">
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon variant="light" color={catalogFixture.color} aria-hidden>
|
||||
{catalogFixture.id === "noConnection" ? (
|
||||
<IconPlugConnectedX size={18} />
|
||||
) : (
|
||||
<IconPlugConnected size={18} />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">
|
||||
{catalogLabel}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(`catalog.state.${catalogFixture.id}.description`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Badge color={catalogFixture.color} variant="light" size="sm" w="fit-content">
|
||||
{t(`catalog.state.${catalogFixture.id}.status`)}
|
||||
</Badge>
|
||||
</Stack>
|
||||
)}
|
||||
</CatalogItem>
|
||||
|
||||
<Stack gap="xs" justify="center">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("catalog.semanticOutput")}
|
||||
</Text>
|
||||
<Code
|
||||
block
|
||||
>{`aria-label="${catalogLabel}, ${catalogStatus}"\naria-pressed=${selected}\ndisabled=${busy || disabled}\naria-busy=${busy}`}</Code>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("catalog.instructions")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper component="section" withBorder p="lg" aria-labelledby="response-workbench-title">
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title id="response-workbench-title" order={2} size="h3">
|
||||
{t("response.title")}
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm" maw={680}>
|
||||
{t("response.description")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<SegmentedControl
|
||||
aria-label={t("response.fixtureLabel")}
|
||||
value={responseFixtureId}
|
||||
onChange={(value) => setResponseFixtureId(value as ResponseFixtureId)}
|
||||
data={responseStateFixtures.map((fixture) => ({
|
||||
value: fixture.id,
|
||||
label: t(`response.state.${fixture.id}.label`),
|
||||
}))}
|
||||
/>
|
||||
|
||||
{responseFixture.id === "loading" ? (
|
||||
<Stack component="output" gap="sm" aria-label={t("response.checking")}>
|
||||
<Group justify="space-between">
|
||||
<Skeleton height={22} width={180} />
|
||||
<Skeleton height={22} width={72} />
|
||||
</Group>
|
||||
<Skeleton height={72} />
|
||||
</Stack>
|
||||
) : (
|
||||
<Alert
|
||||
color={responseFixture.id === "success" ? "green" : "red"}
|
||||
icon={responseFixture.id === "success" ? <IconCircleCheck /> : <IconAlertCircle />}
|
||||
title={t(`response.state.${responseFixture.id}.title`)}
|
||||
role={responseFixture.id === "failure" ? "alert" : "status"}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t(`response.state.${responseFixture.id}.description`)}</Text>
|
||||
<Code block>{JSON.stringify(responseFixture.payload, null, 2)}</Code>
|
||||
<Badge color={responseResult?.passed ? "green" : "red"} variant="light" w="fit-content">
|
||||
{responseResult?.passed ? t("response.contractPassed") : t("response.contractFailed")}
|
||||
</Badge>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { simulateResponseContractAsync } from "@homarr/integrations/response-contract";
|
||||
|
||||
import { responseStateFixtures } from "./_feature-state-fixtures";
|
||||
|
||||
const fixtureResponseSchema = z.object({
|
||||
status: z.literal("ok"),
|
||||
service: z.string(),
|
||||
version: z.string(),
|
||||
});
|
||||
|
||||
export const parseWorkbenchResponseAsync = async (response: { json: () => Promise<unknown> }) =>
|
||||
fixtureResponseSchema.parse(await response.json());
|
||||
|
||||
export const getResponseContractFixtureResultsAsync = () =>
|
||||
simulateResponseContractAsync(parseWorkbenchResponseAsync, [
|
||||
{
|
||||
name: "success",
|
||||
payload: responseStateFixtures.find((fixture) => fixture.id === "success")?.payload,
|
||||
expected: { status: "ok", service: "fixture-service", version: "2.0" },
|
||||
},
|
||||
{
|
||||
name: "failure",
|
||||
payload: responseStateFixtures.find((fixture) => fixture.id === "failure")?.payload,
|
||||
rejects: true,
|
||||
},
|
||||
]);
|
||||
|
||||
export type ResponseContractFixtureResult = Awaited<ReturnType<typeof getResponseContractFixtureResultsAsync>>[number];
|
||||
@@ -1,28 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { FeatureStateWorkbench } from "./_feature-state-workbench";
|
||||
import { getResponseContractFixtureResultsAsync } from "./_response-contract-fixtures";
|
||||
|
||||
export default async function FeatureWorkbenchPage() {
|
||||
if (process.env.NODE_ENV === "production") notFound();
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) notFound();
|
||||
const t = await getScopedI18n("featureWorkbench.page");
|
||||
const responseResults = await getResponseContractFixtureResultsAsync();
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title>{t("title")}</Title>
|
||||
<Text c="dimmed" maw={720}>
|
||||
{t("description")}
|
||||
</Text>
|
||||
</div>
|
||||
<FeatureStateWorkbench responseResults={responseResults} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+4
-18
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, SimpleGrid, Skeleton, Stack, Title } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { SimpleGrid, Skeleton, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
@@ -12,26 +11,21 @@ import KubernetesErrorPage from "~/app/[locale]/manage/tools/kubernetes/cluster-
|
||||
import { HeaderCard } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card";
|
||||
import { ResourceGauge } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge";
|
||||
import { ResourceTile } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile";
|
||||
import { useSelectedKubernetesContextId } from "../kubernetes-context-selector";
|
||||
|
||||
export function ClusterDashboard() {
|
||||
const t = useI18n();
|
||||
const contextId = useSelectedKubernetesContextId();
|
||||
|
||||
const {
|
||||
data: clusterData,
|
||||
isLoading: isClusterLoading,
|
||||
isError: isClusterError,
|
||||
} = clientApi.kubernetes.cluster.getCluster.useQuery({ contextId: contextId ?? "" }, { enabled: Boolean(contextId) });
|
||||
} = clientApi.kubernetes.cluster.getCluster.useQuery();
|
||||
|
||||
const {
|
||||
data: resourceCountsData,
|
||||
isLoading: isResourceCountsLoading,
|
||||
isError: isResourceCountsError,
|
||||
} = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery(
|
||||
{ contextId: contextId ?? "" },
|
||||
{ enabled: Boolean(contextId) },
|
||||
);
|
||||
} = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery();
|
||||
|
||||
return (
|
||||
<Stack bg="var(--mantine-color-body)">
|
||||
@@ -52,12 +46,6 @@ export function ClusterDashboard() {
|
||||
|
||||
<Title>{t("kubernetes.cluster.capacity.title")}</Title>
|
||||
|
||||
{clusterData?.metricsAvailable === false && (
|
||||
<Alert color="blue" variant="light" icon={<IconInfoCircle size={18} />}>
|
||||
{t("kubernetes.cluster.capacity.metricsUnavailable")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
{isClusterError
|
||||
? Array.from({ length: 3 }).map(() => <KubernetesErrorPage key={createId()} />)
|
||||
@@ -75,11 +63,9 @@ export function ClusterDashboard() {
|
||||
? Array.from({ length: 8 }).map(() => <KubernetesErrorPage key={createId()} />)
|
||||
: isResourceCountsLoading
|
||||
? Array.from({ length: 8 }).map(() => <Skeleton key={createId()} height={100} />)
|
||||
: contextId &&
|
||||
resourceCountsData?.map((clusterResourceCount) => (
|
||||
: resourceCountsData?.map((clusterResourceCount) => (
|
||||
<ResourceTile
|
||||
count={clusterResourceCount.count}
|
||||
contextId={contextId}
|
||||
label={clusterResourceCount.label as KubernetesLabelResourceType}
|
||||
key={clusterResourceCount.label}
|
||||
/>
|
||||
|
||||
+1
-6
@@ -11,18 +11,13 @@ import classes from "./resource-tile.module.css";
|
||||
|
||||
interface ResourceTileProps {
|
||||
count: number;
|
||||
contextId: string;
|
||||
label: KubernetesLabelResourceType;
|
||||
}
|
||||
|
||||
export function ResourceTile(props: ResourceTileProps) {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Card
|
||||
component={Link}
|
||||
href={`/manage/tools/kubernetes/${props.label}?context=${encodeURIComponent(props.contextId)}`}
|
||||
className={classes.cardContainer}
|
||||
>
|
||||
<Card component={Link} href={`/manage/tools/kubernetes/${props.label}`} className={classes.cardContainer}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
|
||||
<Group gap="xs">
|
||||
|
||||
+7
-12
@@ -16,7 +16,6 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface ConfigMapsTableComponentProps {
|
||||
contextId: string;
|
||||
initialConfigMaps: RouterOutputs["kubernetes"]["configMaps"]["getConfigMaps"];
|
||||
}
|
||||
|
||||
@@ -40,19 +39,15 @@ const createColumns = (
|
||||
},
|
||||
];
|
||||
|
||||
export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTableComponentProps) {
|
||||
export function ConfigmapsTable(initialData: ConfigMapsTableComponentProps) {
|
||||
const tConfigMaps = useScopedI18n("kubernetes.configmaps");
|
||||
|
||||
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialConfigMaps,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(undefined, {
|
||||
initialData: initialData.initialConfigMaps,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -8,24 +8,14 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ConfigmapsTable } from "~/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function ConfigMapsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<KubernetesContextSearchParams>;
|
||||
}) {
|
||||
export default async function ConfigMapsPage() {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const configMaps =
|
||||
context.status === "unavailable"
|
||||
? []
|
||||
: await api.kubernetes.configMaps.getConfigMaps({ contextId: context.contextId });
|
||||
const configMaps = await api.kubernetes.configMaps.getConfigMaps();
|
||||
const tConfigMaps = await getScopedI18n("kubernetes.configmaps");
|
||||
|
||||
return (
|
||||
@@ -33,7 +23,7 @@ export default async function ConfigMapsPage({
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tConfigMaps("label")}</Title>
|
||||
<ConfigmapsTable contextId={context.contextId} initialConfigMaps={configMaps} />
|
||||
<ConfigmapsTable initialConfigMaps={configMaps} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,6 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface IngressesTableComponentProps {
|
||||
contextId: string;
|
||||
initialIngresses: RouterOutputs["kubernetes"]["ingresses"]["getIngresses"];
|
||||
}
|
||||
|
||||
@@ -73,19 +72,15 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.ingresses">): MR
|
||||
},
|
||||
];
|
||||
|
||||
export function IngressesTable({ contextId, initialIngresses }: IngressesTableComponentProps) {
|
||||
export function IngressesTable(initialData: IngressesTableComponentProps) {
|
||||
const tIngresses = useScopedI18n("kubernetes.ingresses");
|
||||
|
||||
const { data } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialIngresses,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
const { data } = clientApi.kubernetes.ingresses.getIngresses.useQuery(undefined, {
|
||||
initialData: initialData.initialIngresses,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -8,31 +8,21 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { IngressesTable } from "~/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function NamespacesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<KubernetesContextSearchParams>;
|
||||
}) {
|
||||
export default async function NamespacesPage() {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const ingresses =
|
||||
context.status === "unavailable"
|
||||
? []
|
||||
: await api.kubernetes.ingresses.getIngresses({ contextId: context.contextId });
|
||||
const ingresses = await api.kubernetes.ingresses.getIngresses();
|
||||
const tIngresses = await getScopedI18n("kubernetes.ingresses");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tIngresses("label")}</Title>
|
||||
<IngressesTable contextId={context.contextId} initialIngresses={ingresses} />
|
||||
<IngressesTable initialIngresses={ingresses} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
Loaded 100 of 447 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user