Compare commits
318
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bafe1ce75 | ||
|
|
ca95112466 | ||
|
|
32732680e5 | ||
|
|
4905ea6738 | ||
|
|
ca856f9f54 | ||
|
|
ef2ed6c602 | ||
|
|
9b4970583e | ||
|
|
4641839fa4 | ||
|
|
00e9dbe268 | ||
|
|
34a17932ae | ||
|
|
c224e62f30 | ||
|
|
25c2fbab51 | ||
|
|
5b58be347e | ||
|
|
7fcc36adff | ||
|
|
d11f34a6f2 | ||
|
|
ff8ef7a403 | ||
|
|
76758ff7bc | ||
|
|
45c1507bfa | ||
|
|
1c34d95976 | ||
|
|
eb33ff1372 | ||
|
|
c3a090e880 | ||
|
|
def3a3a6fa | ||
|
|
ad0bb41fe4 | ||
|
|
6e76e8c0dd | ||
|
|
e06d95ee7a | ||
|
|
865e96b5b1 | ||
|
|
66313055df | ||
|
|
2283ec7113 | ||
|
|
e5d6b6be87 | ||
|
|
e3e5730a39 | ||
|
|
d776078ecf | ||
|
|
4ca02a9133 | ||
|
|
f4891447d3 | ||
|
|
2067d851cf | ||
|
|
3aedd312b7 | ||
|
|
e8ca63d9c0 | ||
|
|
d89bf25e21 | ||
|
|
07770e862c | ||
|
|
576cc0b418 | ||
|
|
81d5070a8b | ||
|
|
44df54ecb8 | ||
|
|
07867cc3db | ||
|
|
818e9bb0c5 | ||
|
|
3b73f0bc4c | ||
|
|
c92ca2af5f | ||
|
|
124a5526b4 | ||
|
|
b52b2b5f3c | ||
|
|
1f3e7afd5e | ||
|
|
2c8134ee02 | ||
|
|
1088f058e3 | ||
|
|
47861c0194 | ||
|
|
0e7d7e0a2f | ||
|
|
5ca5bd3bda | ||
|
|
bcb732a26c | ||
|
|
8c9e2c8bfa | ||
|
|
14c7e58e17 | ||
|
|
2eea916013 | ||
|
|
5a7607ddf7 | ||
|
|
e466208c09 | ||
|
|
cfce8ada4e | ||
|
|
36661e810f | ||
|
|
308e478733 | ||
|
|
59ce3c7158 | ||
|
|
6ea7a6ff09 | ||
|
|
1611a9033b | ||
|
|
20f4f87361 | ||
|
|
4130b62f24 | ||
|
|
a3b7b0835e | ||
|
|
e225424ab1 | ||
|
|
237b6cb119 | ||
|
|
557b3f8d5f | ||
|
|
8f0efd508e | ||
|
|
876f312a2e | ||
|
|
e662a175c0 | ||
|
|
52c261c034 | ||
|
|
d8108f3375 | ||
|
|
0ae2203aaa | ||
|
|
6b30ba3707 | ||
|
|
2e945805b2 | ||
|
|
780e5928cd | ||
|
|
f61e07ae58 | ||
|
|
7a8b37c906 | ||
|
|
70b0df45b3 | ||
|
|
fa946e1b33 | ||
|
|
f01969b9fc | ||
|
|
11b941285b | ||
|
|
3148ecb823 | ||
|
|
71811dda55 | ||
|
|
e4cb8da5e2 | ||
|
|
aab12bbe64 | ||
|
|
12c436cd5c | ||
|
|
14d426764b | ||
|
|
b3b5a58116 | ||
|
|
5f7429063b | ||
|
|
2ffb05e746 | ||
|
|
921aac0560 | ||
|
|
24bd63d216 | ||
|
|
8b87967a12 | ||
|
|
d44db87d43 | ||
|
|
73b7df44d7 | ||
|
|
c6cf75e561 | ||
|
|
3c3232e85a | ||
|
|
c1aaa1a955 | ||
|
|
5e6ebef961 | ||
|
|
c3436d43e6 | ||
|
|
d825cb7051 | ||
|
|
de682abadc | ||
|
|
6afa4d2f4a | ||
|
|
95473d1b7f | ||
|
|
fd58f9a358 | ||
|
|
a89877b461 | ||
|
|
b362ed1e68 | ||
|
|
83355510c0 | ||
|
|
6cf34af845 | ||
|
|
0f2f9e6e59 | ||
|
|
2f5f89426a | ||
|
|
427db07a95 | ||
|
|
fc07e911d1 | ||
|
|
94ec244fad | ||
|
|
4184721b9a | ||
|
|
8dc5a0540f | ||
|
|
71d1cef139 | ||
|
|
a97605e0f7 | ||
|
|
0876992a9a | ||
|
|
5a65ff47bf | ||
|
|
4060f9299f | ||
|
|
ca8163c061 | ||
|
|
cb1a73a6ac | ||
|
|
7899d8677c | ||
|
|
16e3f54512 | ||
|
|
39a14a18f9 | ||
|
|
ed705c9e34 | ||
|
|
b0ec746081 | ||
|
|
00fa1192d7 | ||
|
|
c7b0c1bcf0 | ||
|
|
9da3ff3e9d | ||
|
|
93113234a7 | ||
|
|
9ef4bf803d | ||
|
|
4920063318 | ||
|
|
68d236fd63 | ||
|
|
ebce24ba67 | ||
|
|
039bfb9b93 | ||
|
|
faf01e82c8 | ||
|
|
54d7821e24 | ||
|
|
f29e604bc1 | ||
|
|
50deb638fb | ||
|
|
b1f25f779d | ||
|
|
df874dd2db | ||
|
|
26ede97407 | ||
|
|
b5fbf0085f | ||
|
|
a65dc2841f | ||
|
|
09a85e69c1 | ||
|
|
e53f63b373 | ||
|
|
ea1e07c74f | ||
|
|
4635e3ce4e | ||
|
|
345d9e6fa4 | ||
|
|
f86120016c | ||
|
|
68b70ac01a | ||
|
|
c65e6cdbd4 | ||
|
|
073d9ce127 | ||
|
|
6da471d24a | ||
|
|
c157001455 | ||
|
|
00e4cc8100 | ||
|
|
d3205d886a | ||
|
|
97d26bde5e | ||
|
|
12f950ce4a | ||
|
|
381a257dbc | ||
|
|
16c86079e6 | ||
|
|
fd69cd28f0 | ||
|
|
3b6de1f91f | ||
|
|
f0747fd28b | ||
|
|
5c96c52e64 | ||
|
|
bc2aff17f3 | ||
|
|
90e30ce577 | ||
|
|
485a54d6d8 | ||
|
|
45954837ed | ||
|
|
aef0a6d340 | ||
|
|
e74d5e2aae | ||
|
|
7453e6c559 | ||
|
|
b3ec1381e9 | ||
|
|
30c00ea30d | ||
|
|
4df62221c7 | ||
|
|
dec04da806 | ||
|
|
866b3ab78b | ||
|
|
92c6e4668d | ||
|
|
df70d06606 | ||
|
|
4d0c2f8eec | ||
|
|
9887c018d2 | ||
|
|
e93f14acdf | ||
|
|
85db534fa7 | ||
|
|
8ac35083e6 | ||
|
|
f481007b9a | ||
|
|
f88fbb5a19 | ||
|
|
d4cecde0f0 | ||
|
|
0356eb427c | ||
|
|
b3e6d35b79 | ||
|
|
be9c69f325 | ||
|
|
e93293f8a9 | ||
|
|
967ed8dd73 | ||
|
|
5454bcc8ac | ||
|
|
ec54ddcfa6 | ||
|
|
25a4aca8c0 | ||
|
|
d1f76b24c0 | ||
|
|
6a2e7d0610 | ||
|
|
438f0d80eb | ||
|
|
673b1a74d6 | ||
|
|
84db2f9d49 | ||
|
|
2e3dca3e87 | ||
|
|
7d5ec15017 | ||
|
|
f6baaa0c3b | ||
|
|
9d3c0acb95 | ||
|
|
3079eb1767 | ||
|
|
5059a16181 | ||
|
|
444ae82352 | ||
|
|
65a7e84bea | ||
|
|
55226842f9 | ||
|
|
e84b05e91c | ||
|
|
d6359d595c | ||
|
|
f876b2fd06 | ||
|
|
c256bd644c | ||
|
|
c9fe69c5ec | ||
|
|
64a57bac82 | ||
|
|
39e408c53f | ||
|
|
f404f77002 | ||
|
|
21a38b3004 | ||
|
|
3b76381262 | ||
|
|
bb97ce5a01 | ||
|
|
2e323677f4 | ||
|
|
f2dd7c10a2 | ||
|
|
ccabc2dab6 | ||
|
|
5633bc4775 | ||
|
|
d20bb18b08 | ||
|
|
747ccdfa9e | ||
|
|
bbcfc505bc | ||
|
|
2b1053e243 | ||
|
|
154753ddda | ||
|
|
74599b0b65 | ||
|
|
7bfd10210e | ||
|
|
19befee8a5 | ||
|
|
77cbd9f723 | ||
|
|
1fa345edd1 | ||
|
|
6331c88911 | ||
|
|
4c69c4ec45 | ||
|
|
ff31208c37 | ||
|
|
177ea49469 | ||
|
|
f03bf6a917 | ||
|
|
80d1416ad6 | ||
|
|
d2a935b6c1 | ||
|
|
758c300d9e | ||
|
|
53185701ce | ||
|
|
21cc0b19b3 | ||
|
|
a882a227f1 | ||
|
|
e535ff393e | ||
|
|
349dfa6d41 | ||
|
|
6c8dbc182f | ||
|
|
d880c4a281 | ||
|
|
d7083fc14c | ||
|
|
c7431ef1f5 | ||
|
|
419c8434f9 | ||
|
|
49ef7748ad | ||
|
|
a9f1293077 | ||
|
|
2344896ba9 | ||
|
|
0aa3b98675 | ||
|
|
f003d9ddc9 | ||
|
|
56346b2aa8 | ||
|
|
e321ee27a2 | ||
|
|
afd73565c9 | ||
|
|
36d7ef0562 | ||
|
|
99a871344c | ||
|
|
010ba45468 | ||
|
|
777bbd89a1 | ||
|
|
6f2cd7fc2b | ||
|
|
1a5e083fd0 | ||
|
|
9607bf65cd | ||
|
|
6caa1c8224 | ||
|
|
556179deb5 | ||
|
|
ab012b4a32 | ||
|
|
d635e4d80f | ||
|
|
6afd21fe98 | ||
|
|
cf7ec0a31f | ||
|
|
5be2226c39 | ||
|
|
082f2271c5 | ||
|
|
5a03640a1b | ||
|
|
80132ae71f | ||
|
|
9b8b6c7cfe | ||
|
|
282997e016 | ||
|
|
c973f54c8e | ||
|
|
8dd984fcb3 | ||
|
|
2d17e9f58a | ||
|
|
3fe3d1714a | ||
|
|
add61ce9a3 | ||
|
|
1332f5626b | ||
|
|
203711e570 | ||
|
|
2cb69c4ea3 | ||
|
|
7368262149 | ||
|
|
b9858b5ba4 | ||
|
|
b84502ef9b | ||
|
|
b39bff43a9 | ||
|
|
d5dbf6fc17 | ||
|
|
c6c96c4870 | ||
|
|
f438ea8aa9 | ||
|
|
279aeb3269 | ||
|
|
f5ac662aeb | ||
|
|
0b1c2f856f | ||
|
|
dd11fd8381 | ||
|
|
07e6598f75 | ||
|
|
b7eb099977 | ||
|
|
5ab3ee7d02 | ||
|
|
59106053f2 | ||
|
|
078c41b6ec | ||
|
|
1f5311e93b | ||
|
|
5bde4d3cc5 | ||
|
|
94074c368d | ||
|
|
bf91f92d56 | ||
|
|
b7693c4238 | ||
|
|
fb18ac8fc5 | ||
|
|
336ba76fef | ||
|
|
0110a2ef63 |
No files matched your search
@@ -95,6 +95,12 @@ 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
|
||||
|
||||
@@ -60,6 +60,70 @@ 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.
|
||||
@@ -76,6 +140,8 @@ Workshop contributors can set up PocketBase and Docusaurus with the dedicated
|
||||
- `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,11 +58,12 @@ A random secret can be generated by using the following command: `openssl rand -
|
||||
|
||||
## Docker
|
||||
|
||||
| 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` | - |
|
||||
| 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` | - |
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
@@ -15,20 +15,6 @@ 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.
|
||||
|
||||
@@ -158,8 +158,7 @@ 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.
|
||||
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 stops shrinking at 75% and lets you scroll horizontally instead of making text and controls too small.
|
||||
It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -122,9 +122,77 @@ 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
|
||||
|
||||
@@ -14,6 +14,8 @@ 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.
|
||||
:::
|
||||
@@ -25,7 +27,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))
|
||||
- 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.
|
||||
- RBAC Enabled (Required for Homarr Helm chart)
|
||||
|
||||
### Configuring RBAC in Helm Chart
|
||||
@@ -45,6 +47,25 @@ 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,6 +86,30 @@ 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
|
||||
|
||||
@@ -159,6 +159,15 @@ 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.
|
||||
|
||||
@@ -59,6 +59,22 @@ 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
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 ".";
|
||||
|
||||
@@ -27,8 +28,15 @@ 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.
|
||||
|
||||
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.
|
||||
### 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.
|
||||
|
||||
### 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** — copy download names, or pause, resume, and delete when your account has Interact access and the client supports those actions
|
||||
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
|
||||
- **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 downloads from supported clients when your account has Interact access; read-only clients such as slskd do not expose these controls
|
||||
- **Global controls** — pause/resume all downloads across all clients from the footer bar
|
||||
- **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 copy its name; pause/resume and delete require Interact access and client support
|
||||
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
|
||||
- **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** downloads from supported clients using the footer controls when you have Interact access; slskd remains read-only
|
||||
- **Pause / Resume** all downloads using the footer controls
|
||||
- **Filter** by integration or status using the filter button in the footer
|
||||
- **Sort** the current view by clicking column headers to switch direction
|
||||
- **Sort** by clicking column headers (with persistent sort 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 an upload speed below the specified threshold.",
|
||||
"This will hide completed torrent entries that have a download speed below the specified threshold.",
|
||||
values: "Any number above 0, 0 to disable",
|
||||
defaultValue: "0",
|
||||
},
|
||||
|
||||
+7
-1
@@ -1266,9 +1266,15 @@
|
||||
"properties": {
|
||||
"boardId": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"layoutId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["boardId"],
|
||||
"required": ["boardId", "name", "layoutId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
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,
|
||||
@@ -64,6 +66,8 @@ export const ClientBoard = () => {
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardEmptyState />
|
||||
<BoardSetupChecklist />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
|
||||
@@ -44,11 +44,9 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||
const resolvedParams = await params;
|
||||
const queryClient = getQueryClient();
|
||||
const sessionPromise = auth();
|
||||
const boardPromise = getInitialBoard(resolvedParams);
|
||||
const session = await sessionPromise;
|
||||
const session = await auth();
|
||||
|
||||
const board = await boardPromise.catch((error) => {
|
||||
const board = await getInitialBoard(resolvedParams).catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { IconPlus } 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 { 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 { useBoardAddActions } from "~/components/board/use-board-add-actions";
|
||||
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export default function BoardEditActions() {
|
||||
@@ -20,60 +17,41 @@ export default function BoardEditActions() {
|
||||
}
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addContainer } = useContainerActions();
|
||||
const { createItem } = useItemActions();
|
||||
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const t = useI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const handledInitialAdd = useRef(false);
|
||||
|
||||
const handleSelectItem = () => {
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
};
|
||||
useEffect(() => {
|
||||
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
|
||||
handledInitialAdd.current = true;
|
||||
addWidget();
|
||||
|
||||
const handleSelectApp = () => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddIntegration = () => {
|
||||
openIntegrationSelectModal({});
|
||||
};
|
||||
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]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ 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,
|
||||
@@ -98,6 +99,9 @@ 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"),
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
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,6 +2,7 @@
|
||||
|
||||
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";
|
||||
@@ -23,10 +24,11 @@ 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 ? requestedLayoutId : null;
|
||||
const layoutOverrideId = isLayoutEditRequest && isDesktop ? requestedLayoutId : null;
|
||||
|
||||
return (
|
||||
<BoardProvider
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
@@ -141,7 +140,6 @@ 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");
|
||||
@@ -270,7 +268,6 @@ export const AssistantConfiguration = () => {
|
||||
utils.assistant.getAvailability.invalidate(),
|
||||
utils.assistant.getRuntimeOptions.invalidate(),
|
||||
]);
|
||||
router.refresh();
|
||||
showSuccessNotification({
|
||||
title: t("notification.saved.title"),
|
||||
message: t("notification.saved.message"),
|
||||
|
||||
@@ -54,7 +54,12 @@ export default async function ManageBoardsPage() {
|
||||
floatingPrimaryAction={canCreateBoards}
|
||||
>
|
||||
{boards.length === 0 && (
|
||||
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
|
||||
<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 && (
|
||||
<TourTarget id="manage-boards-list">
|
||||
|
||||
@@ -144,7 +144,6 @@ 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}
|
||||
|
||||
+1
-8
@@ -22,7 +22,6 @@ 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> }) => {
|
||||
@@ -33,12 +32,7 @@ vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
|
||||
return <div>widget-renderer</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock("./_code-editor", () => ({
|
||||
CodeEditor: (props: { label: string; readOnly?: boolean }) => {
|
||||
codeEditorProps(props);
|
||||
return <div>{props.label}</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
|
||||
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
|
||||
|
||||
let root: Root;
|
||||
@@ -115,7 +109,6 @@ 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,7 +172,6 @@ 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>
|
||||
@@ -252,7 +251,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
label={t("journal")}
|
||||
language="json"
|
||||
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
|
||||
readOnly
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
+1
-3
@@ -87,9 +87,7 @@ export function CustomWidgetSourcesEditor({
|
||||
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
|
||||
};
|
||||
const addSource = () => {
|
||||
let suffix = sources.length + 1;
|
||||
while (sources.some(({ id }) => id === `source-${suffix}`)) suffix += 1;
|
||||
const id = sources.length === 0 ? "default" : `source-${suffix}`;
|
||||
const id = sources.length === 0 ? "default" : `source-${sources.length + 1}`;
|
||||
form.setFieldValue(
|
||||
"sources",
|
||||
JSON.stringify(
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// @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,8 +2,6 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
|
||||
@@ -18,26 +16,19 @@ 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);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!invalid) setDraft(serialized);
|
||||
}, [invalid, serialized]);
|
||||
useEffect(() => setDraft(serialized), [serialized]);
|
||||
return (
|
||||
<CodeEditor
|
||||
id={id}
|
||||
label={label}
|
||||
language="json"
|
||||
value={draft}
|
||||
error={invalid ? t("invalidJson") : undefined}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
const parsed = parseJson(next);
|
||||
const valid = isRecord(parsed);
|
||||
setInvalid(!valid);
|
||||
onChange(valid ? parsed : {});
|
||||
if (isRecord(parsed)) onChange(parsed);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -48,17 +48,4 @@ 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,7 +4,6 @@ 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;
|
||||
@@ -33,22 +32,11 @@ 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);
|
||||
};
|
||||
}
|
||||
+11
-3
@@ -42,7 +42,11 @@ 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}>{error.data.url}</Anchor>,
|
||||
url: () => (
|
||||
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
|
||||
{error.data.url}
|
||||
</Anchor>
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -50,7 +54,11 @@ 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}>{error.data.url}</Anchor>,
|
||||
url: () => (
|
||||
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
|
||||
{error.data.url}
|
||||
</Anchor>
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
@@ -66,7 +74,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
{t("integration.testConnection.error.common.cause.title")}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<pre style={{ whiteSpace: "pre-wrap" }}>
|
||||
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
|
||||
{error.name}: {error.message}
|
||||
{"\n"}
|
||||
{causeArray
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
export type AnyMappedTestConnectionError = Exclude<RouterOutputs["integration"]["create"], undefined>["error"];
|
||||
type IntegrationCreateErrorResult = Extract<RouterOutputs["integration"]["create"], { error: unknown }>;
|
||||
|
||||
export type AnyMappedTestConnectionError = IntegrationCreateErrorResult["error"];
|
||||
export type MappedTestConnectionCertificateError = Extract<AnyMappedTestConnectionError, { type: "certificate" }>;
|
||||
export type MappedCertificate = MappedTestConnectionCertificateError["data"]["certificate"];
|
||||
export type MappedError = Exclude<AnyMappedTestConnectionError["cause"], undefined>;
|
||||
+49
-39
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, 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,8 +25,10 @@ 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 EditIntegrationForm {
|
||||
interface EditIntegrationFormProps {
|
||||
integration: RouterOutputs["integration"]["byId"];
|
||||
embedded?: boolean;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||
@@ -42,7 +44,7 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||
}),
|
||||
);
|
||||
|
||||
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }: EditIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const allSecretKinds = getAllSecretKindOptions(integration.kind);
|
||||
@@ -69,15 +71,17 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
});
|
||||
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) => {
|
||||
const url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
await mutateAsync(
|
||||
{
|
||||
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({
|
||||
id: integration.id,
|
||||
...values,
|
||||
url,
|
||||
@@ -86,34 +90,34 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
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"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
// 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"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isInitialSecretKinds =
|
||||
@@ -190,12 +194,18 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
|
||||
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
||||
|
||||
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||
{error !== null && (
|
||||
<div ref={errorRef} role="alert" tabIndex={-1}>
|
||||
<IntegrationTestConnectionError error={error} url={form.values.url} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
{!embedded && (
|
||||
<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,6 +19,7 @@ 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";
|
||||
@@ -49,10 +50,12 @@ interface NewIntegrationFormProps {
|
||||
kind: IntegrationKind;
|
||||
initialUrl?: string;
|
||||
initialName?: string;
|
||||
onSuccess: () => void;
|
||||
onSuccess: (result?: CreatedIntegrationResult) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export type CreatedIntegrationResult = Extract<RouterOutputs["integration"]["create"], { integration: unknown }>;
|
||||
|
||||
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||
z.object({
|
||||
hasApp: z.boolean(),
|
||||
@@ -67,12 +70,18 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
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"] });
|
||||
});
|
||||
|
||||
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
|
||||
if (hasUrlSecret) {
|
||||
url = "http://localhost";
|
||||
}
|
||||
const form = useZodForm(formSchema, {
|
||||
const form = useZodForm(validationSchema, {
|
||||
initialValues: {
|
||||
name: initialName ?? getIntegrationName(kind),
|
||||
url,
|
||||
@@ -81,7 +90,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
value: "",
|
||||
})),
|
||||
attemptSearchEngineCreation: true,
|
||||
hasApp: true,
|
||||
hasApp: canCreateApps,
|
||||
appHref: url,
|
||||
appId: null,
|
||||
},
|
||||
@@ -101,7 +110,9 @@ 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: { error?: AnyMappedTestConnectionError } | undefined | void) => {
|
||||
const onMutationSuccess = (
|
||||
data: CreatedIntegrationResult | { error?: AnyMappedTestConnectionError } | undefined | void,
|
||||
) => {
|
||||
if (data && "error" in data && data.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
@@ -116,7 +127,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
message: t("integration.page.create.notification.success.message"),
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
onSuccess(data && "integration" in data ? data : undefined);
|
||||
};
|
||||
|
||||
const onMutationError = () => {
|
||||
@@ -131,13 +142,15 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
const app = hasApp
|
||||
? appId !== null
|
||||
? { id: appId }
|
||||
: {
|
||||
name: values.name,
|
||||
href: hasCustomHref ? appHref : url,
|
||||
iconUrl: getIconUrl(kind),
|
||||
description: null,
|
||||
pingUrl: url,
|
||||
}
|
||||
: canCreateApps
|
||||
? {
|
||||
name: values.name,
|
||||
href: hasCustomHref ? appHref : url,
|
||||
iconUrl: getIconUrl(kind),
|
||||
description: null,
|
||||
pingUrl: url,
|
||||
}
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
await createIntegrationAsync(
|
||||
@@ -229,8 +242,10 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
|
||||
checkboxInputProps.onChange(event);
|
||||
});
|
||||
}}
|
||||
label={t("integration.field.createApp.label")}
|
||||
description={t("integration.field.createApp.description")}
|
||||
label={t(canCreateApps ? "integration.field.createApp.label" : "integration.field.linkApp.label")}
|
||||
description={t(
|
||||
canCreateApps ? "integration.field.createApp.description" : "integration.field.linkApp.description",
|
||||
)}
|
||||
/>
|
||||
|
||||
<Collapse expanded={form.values.hasApp}>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,344 @@
|
||||
"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, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Alert, Avatar, Badge, Box, Button, Group, Menu, Stack, 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 } from "@homarr/docker";
|
||||
import type { ContainerState, DockerEndpointCapability } from "@homarr/docker";
|
||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { AddDockerAppToHomarr, useDockerContainerRemovalConfirmation } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -32,9 +32,41 @@ 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, "id" | "name">) =>
|
||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
|
||||
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 createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
|
||||
{
|
||||
@@ -157,32 +189,39 @@ interface DockerTableProps {
|
||||
export function DockerTable({ initialData }: DockerTableProps) {
|
||||
const t = useI18n();
|
||||
const tDocker = useScopedI18n("docker");
|
||||
const { data, isFetching, refetch } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
const utils = clientApi.useUtils();
|
||||
const { data, isFetching } = 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,
|
||||
state: { isLoading: isFetching },
|
||||
getRowId: (row) => row.resourceId,
|
||||
state: { isLoading: isFetching || refreshInventory.isPending },
|
||||
enableDensityToggle: false,
|
||||
enableColumnActions: false,
|
||||
enableColumnFilters: false,
|
||||
@@ -207,13 +246,18 @@ 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} />,
|
||||
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => (
|
||||
<ContainerRowMenu
|
||||
container={row.original}
|
||||
endpoint={data?.endpoints.find(({ id }) => id === row.original.endpointId)}
|
||||
/>
|
||||
),
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button
|
||||
variant="default"
|
||||
rightSection={<IconRefresh size="1rem" />}
|
||||
onClick={() => mutate()}
|
||||
loading={isFetching}
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
loading={isFetching || refreshInventory.isPending}
|
||||
>
|
||||
{tDocker("action.refresh.label")}
|
||||
</Button>
|
||||
@@ -229,7 +273,7 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
||||
totalCount: String(table.getRowCount()),
|
||||
})}
|
||||
</Text>
|
||||
<ContainerActionBar selectedContainers={dockerContainers} />
|
||||
<ContainerActionBar selectedContainers={dockerContainers} endpoints={data?.endpoints ?? []} />
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -238,10 +282,18 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,22 +304,21 @@ const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon:
|
||||
{ action: "remove", icon: IconTrash, color: "red" },
|
||||
];
|
||||
|
||||
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
||||
const ContainerRowMenu = ({ container, endpoint }: { container: DockerContainer; endpoint?: DockerEndpoint }) => {
|
||||
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() {
|
||||
showSuccessNotification({
|
||||
title: t(`${action}.notification.success.title`),
|
||||
message: t(`${action}.notification.success.message`),
|
||||
});
|
||||
onSuccess(results) {
|
||||
showContainerActionResult(action, [container], results, tAll);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
@@ -294,6 +345,7 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileText size="1rem" />}
|
||||
disabled={!endpointHasCapability(endpoint, "logs")}
|
||||
onClick={() => router.push(createContainerLogsPath(container))}
|
||||
>
|
||||
{t("logs.label")}
|
||||
@@ -304,7 +356,12 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
||||
key={action}
|
||||
leftSection={<Icon size="1rem" />}
|
||||
color={color}
|
||||
onClick={() => mutations[action].mutate({ ids: [container.id] })}
|
||||
disabled={!endpointHasCapability(endpoint, action === "remove" ? "remove" : "lifecycle")}
|
||||
onClick={() => {
|
||||
const runAction = () => mutations[action].mutate({ targets: [getContainerTarget(container)] });
|
||||
if (action === "remove") confirmRemoval([container], runAction);
|
||||
else runAction();
|
||||
}}
|
||||
>
|
||||
{t(`${action}.label`)}
|
||||
</Menu.Item>
|
||||
@@ -324,18 +381,24 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
||||
|
||||
interface ContainerActionBarProps {
|
||||
selectedContainers: DockerContainer[];
|
||||
endpoints: DockerEndpoint[];
|
||||
}
|
||||
|
||||
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||
const ContainerActionBar = ({ selectedContainers, endpoints }: 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} selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton
|
||||
key={action}
|
||||
icon={icon}
|
||||
color={color}
|
||||
action={action}
|
||||
selectedContainers={selectedContainers}
|
||||
endpoints={endpoints}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
leftSection={<IconCategoryPlus />}
|
||||
@@ -353,12 +416,22 @@ interface ContainerActionBarButtonProps {
|
||||
icon: TablerIcon;
|
||||
color: MantineColor;
|
||||
action: "start" | "stop" | "restart" | "remove";
|
||||
selectedIds: string[];
|
||||
selectedContainers: DockerContainer[];
|
||||
endpoints: DockerEndpoint[];
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -367,23 +440,28 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
});
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
await mutateAsync(
|
||||
{ ids: props.selectedIds },
|
||||
{
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t(`${props.action}.notification.success.title`),
|
||||
message: t(`${props.action}.notification.success.message`),
|
||||
});
|
||||
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`),
|
||||
});
|
||||
},
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t(`${props.action}.notification.error.title`),
|
||||
message: t(`${props.action}.notification.error.message`),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
if (props.action === "remove") {
|
||||
confirmRemoval(props.selectedContainers, runActionAsync);
|
||||
return;
|
||||
}
|
||||
await runActionAsync();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -392,6 +470,7 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
color={props.color}
|
||||
onClick={handleClickAsync}
|
||||
loading={isPending}
|
||||
disabled={!isAllowed}
|
||||
variant="light"
|
||||
>
|
||||
{t(`${props.action}.label`)}
|
||||
@@ -399,6 +478,9 @@ 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<{ name?: string }>;
|
||||
searchParams: Promise<{ endpointId?: string; name?: string }>;
|
||||
}
|
||||
|
||||
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
|
||||
@@ -23,10 +23,12 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
|
||||
}
|
||||
|
||||
const { containerId } = await params;
|
||||
const { name } = await searchParams;
|
||||
const { endpointId, name } = await searchParams;
|
||||
const resolvedEndpointId = endpointId ?? (await getOnlyDockerEndpointIdAsync());
|
||||
if (!resolvedEndpointId) notFound();
|
||||
|
||||
try {
|
||||
await api.docker.logs({ id: containerId, tail: 1 });
|
||||
await api.docker.logs({ endpointId: resolvedEndpointId, id: containerId, tail: 1 });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
@@ -40,8 +42,17 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
|
||||
/>
|
||||
</Group>
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideDockerLogsTerminal containerId={containerId} />
|
||||
<ClientSideDockerLogsTerminal endpointId={resolvedEndpointId} 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,10 +11,11 @@ import { clientApi } from "@homarr/api/client";
|
||||
import classes from "./terminal.module.css";
|
||||
|
||||
interface DockerLogsTerminalProps {
|
||||
endpointId: string;
|
||||
containerId: string;
|
||||
}
|
||||
|
||||
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
|
||||
export const DockerLogsTerminal = ({ endpointId, containerId }: DockerLogsTerminalProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const pendingLogsRef = useRef<string[]>([]);
|
||||
@@ -31,7 +32,7 @@ export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) =>
|
||||
}, []);
|
||||
|
||||
clientApi.docker.subscribeLogs.useSubscription(
|
||||
{ id: containerId, tail: 200 },
|
||||
{ endpointId, id: containerId, tail: 200 },
|
||||
{
|
||||
onData: writeToTerminal,
|
||||
onError(err) {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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() {
|
||||
@@ -15,7 +16,7 @@ export default async function DockerPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { containers, timestamp } = await api.docker.getContainers();
|
||||
const { containers, endpoints, timestamp } = await api.docker.getContainers();
|
||||
const tDocker = await getScopedI18n("docker");
|
||||
|
||||
return (
|
||||
@@ -23,7 +24,8 @@ export default async function DockerPage() {
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tDocker("title")}</Title>
|
||||
<DockerTable initialData={{ containers, timestamp }} />
|
||||
<DockerReconciliation />
|
||||
<DockerTable initialData={{ containers, endpoints, timestamp }} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
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
@@ -0,0 +1,106 @@
|
||||
// @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
@@ -0,0 +1,196 @@
|
||||
"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
@@ -0,0 +1,30 @@
|
||||
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];
|
||||
@@ -0,0 +1,28 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+18
-4
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SimpleGrid, Skeleton, Stack, Title } from "@mantine/core";
|
||||
import { Alert, SimpleGrid, Skeleton, Stack, Title } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
@@ -11,21 +12,26 @@ 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();
|
||||
} = clientApi.kubernetes.cluster.getCluster.useQuery({ contextId: contextId ?? "" }, { enabled: Boolean(contextId) });
|
||||
|
||||
const {
|
||||
data: resourceCountsData,
|
||||
isLoading: isResourceCountsLoading,
|
||||
isError: isResourceCountsError,
|
||||
} = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery();
|
||||
} = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery(
|
||||
{ contextId: contextId ?? "" },
|
||||
{ enabled: Boolean(contextId) },
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack bg="var(--mantine-color-body)">
|
||||
@@ -46,6 +52,12 @@ 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()} />)
|
||||
@@ -63,9 +75,11 @@ export function ClusterDashboard() {
|
||||
? Array.from({ length: 8 }).map(() => <KubernetesErrorPage key={createId()} />)
|
||||
: isResourceCountsLoading
|
||||
? Array.from({ length: 8 }).map(() => <Skeleton key={createId()} height={100} />)
|
||||
: resourceCountsData?.map((clusterResourceCount) => (
|
||||
: contextId &&
|
||||
resourceCountsData?.map((clusterResourceCount) => (
|
||||
<ResourceTile
|
||||
count={clusterResourceCount.count}
|
||||
contextId={contextId}
|
||||
label={clusterResourceCount.label as KubernetesLabelResourceType}
|
||||
key={clusterResourceCount.label}
|
||||
/>
|
||||
|
||||
+6
-1
@@ -11,13 +11,18 @@ 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}`} className={classes.cardContainer}>
|
||||
<Card
|
||||
component={Link}
|
||||
href={`/manage/tools/kubernetes/${props.label}?context=${encodeURIComponent(props.contextId)}`}
|
||||
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">
|
||||
|
||||
+12
-7
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface ConfigMapsTableComponentProps {
|
||||
contextId: string;
|
||||
initialConfigMaps: RouterOutputs["kubernetes"]["configMaps"]["getConfigMaps"];
|
||||
}
|
||||
|
||||
@@ -39,15 +40,19 @@ const createColumns = (
|
||||
},
|
||||
];
|
||||
|
||||
export function ConfigmapsTable(initialData: ConfigMapsTableComponentProps) {
|
||||
export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTableComponentProps) {
|
||||
const tConfigMaps = useScopedI18n("kubernetes.configmaps");
|
||||
|
||||
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(undefined, {
|
||||
initialData: initialData.initialConfigMaps,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
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 table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -8,14 +8,24 @@ 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() {
|
||||
export default async function ConfigMapsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<KubernetesContextSearchParams>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const configMaps = await api.kubernetes.configMaps.getConfigMaps();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const configMaps =
|
||||
context.status === "unavailable"
|
||||
? []
|
||||
: await api.kubernetes.configMaps.getConfigMaps({ contextId: context.contextId });
|
||||
const tConfigMaps = await getScopedI18n("kubernetes.configmaps");
|
||||
|
||||
return (
|
||||
@@ -23,7 +33,7 @@ export default async function ConfigMapsPage() {
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tConfigMaps("label")}</Title>
|
||||
<ConfigmapsTable initialConfigMaps={configMaps} />
|
||||
<ConfigmapsTable contextId={context.contextId} initialConfigMaps={configMaps} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface IngressesTableComponentProps {
|
||||
contextId: string;
|
||||
initialIngresses: RouterOutputs["kubernetes"]["ingresses"]["getIngresses"];
|
||||
}
|
||||
|
||||
@@ -72,15 +73,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.ingresses">): MR
|
||||
},
|
||||
];
|
||||
|
||||
export function IngressesTable(initialData: IngressesTableComponentProps) {
|
||||
export function IngressesTable({ contextId, initialIngresses }: IngressesTableComponentProps) {
|
||||
const tIngresses = useScopedI18n("kubernetes.ingresses");
|
||||
|
||||
const { data } = clientApi.kubernetes.ingresses.getIngresses.useQuery(undefined, {
|
||||
initialData: initialData.initialIngresses,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
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 table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -8,21 +8,31 @@ 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() {
|
||||
export default async function NamespacesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<KubernetesContextSearchParams>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const ingresses = await api.kubernetes.ingresses.getIngresses();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const ingresses =
|
||||
context.status === "unavailable"
|
||||
? []
|
||||
: await api.kubernetes.ingresses.getIngresses({ contextId: context.contextId });
|
||||
const tIngresses = await getScopedI18n("kubernetes.ingresses");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tIngresses("label")}</Title>
|
||||
<IngressesTable initialIngresses={ingresses} />
|
||||
<IngressesTable contextId={context.contextId} initialIngresses={ingresses} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Alert, Badge, Button, Group, Select, Stack, Text } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const KubernetesContextSelector = () => {
|
||||
const t = useScopedI18n("kubernetes.context");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { data, isError, refetch } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: "always",
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const [storedContextId, setStoredContextId] = useLocalStorage<string | null>({
|
||||
key: "homarr-kubernetes-context",
|
||||
defaultValue: null,
|
||||
});
|
||||
const requestedContextId = searchParams.get("context");
|
||||
const contextId =
|
||||
data?.contexts.find((context) => context.contextId === requestedContextId)?.contextId ??
|
||||
data?.contexts.find((context) => context.contextId === storedContextId)?.contextId ??
|
||||
data?.contexts.find((context) => context.contextId === data.defaultContextId)?.contextId ??
|
||||
data?.contexts[0]?.contextId ??
|
||||
null;
|
||||
const context = data?.contexts.find((item) => item.contextId === contextId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextId || requestedContextId === contextId) return;
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("context", contextId);
|
||||
router.replace(`${pathname}?${next.toString()}`);
|
||||
}, [contextId, pathname, requestedContextId, router, searchParams]);
|
||||
|
||||
if (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 refetch()}>
|
||||
{t("loadError.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !contextId) return null;
|
||||
|
||||
const selectContext = (value: string | null) => {
|
||||
if (!value) return;
|
||||
setStoredContextId(value);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("context", value);
|
||||
router.replace(`${pathname}?${next.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="end">
|
||||
<Select
|
||||
label={t("label")}
|
||||
value={contextId}
|
||||
data={data.contexts.map((item) => ({ value: item.contextId, label: item.name }))}
|
||||
onChange={selectContext}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{context && (
|
||||
<Badge color={contextStatusColor(context.status)} variant="light">
|
||||
{t(`status.${context.status}`)}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{context?.status === "unavailable" && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("unavailable")}
|
||||
</Alert>
|
||||
)}
|
||||
{context?.status === "degraded" && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("metricsUnavailable")}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSelectedKubernetesContextId = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const { data } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const requested = searchParams.get("context");
|
||||
if (data?.contexts.some(({ contextId }) => contextId === requested)) return requested;
|
||||
return (
|
||||
data?.contexts.find(({ contextId }) => contextId === data.defaultContextId)?.contextId ??
|
||||
data?.contexts[0]?.contextId
|
||||
);
|
||||
};
|
||||
|
||||
const contextStatusColor = (status: "available" | "degraded" | "unavailable") => {
|
||||
if (status === "available") return "green";
|
||||
if (status === "degraded") return "yellow";
|
||||
return "red";
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
export interface KubernetesContextSearchParams {
|
||||
context?: string | string[];
|
||||
}
|
||||
|
||||
export const getSelectedKubernetesContextAsync = async (searchParams: Promise<KubernetesContextSearchParams>) => {
|
||||
const [{ context }, result] = await Promise.all([searchParams, api.kubernetes.contexts.getContexts()]);
|
||||
const requestedContextId = Array.isArray(context) ? context[0] : context;
|
||||
const selected =
|
||||
result.contexts.find(({ contextId }) => contextId === requestedContextId) ??
|
||||
result.contexts.find(({ contextId }) => contextId === result.defaultContextId) ??
|
||||
result.contexts[0];
|
||||
if (!selected) throw new Error("No Kubernetes context is configured");
|
||||
return selected;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Suspense } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
|
||||
import { KubernetesContextSelector } from "./kubernetes-context-selector";
|
||||
|
||||
export default function KubernetesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Stack>
|
||||
<Suspense fallback={null}>
|
||||
<KubernetesContextSelector />
|
||||
</Suspense>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+12
-7
@@ -18,6 +18,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface NamespacesTableComponentProps {
|
||||
contextId: string;
|
||||
initialNamespaces: RouterOutputs["kubernetes"]["namespaces"]["getNamespaces"];
|
||||
}
|
||||
|
||||
@@ -56,15 +57,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.namespaces">): M
|
||||
},
|
||||
];
|
||||
|
||||
export function NamespacesTable(initialData: NamespacesTableComponentProps) {
|
||||
export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTableComponentProps) {
|
||||
const tNamespaces = useScopedI18n("kubernetes.namespaces");
|
||||
|
||||
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(undefined, {
|
||||
initialData: initialData.initialNamespaces,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialNamespaces,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -8,21 +8,31 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { NamespacesTable } from "~/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function NamespacesPage() {
|
||||
export default async function NamespacesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<KubernetesContextSearchParams>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const namespaces = await api.kubernetes.namespaces.getNamespaces();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const namespaces =
|
||||
context.status === "unavailable"
|
||||
? []
|
||||
: await api.kubernetes.namespaces.getNamespaces({ contextId: context.contextId });
|
||||
const tNamespaces = await getScopedI18n("kubernetes.namespaces");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tNamespaces("label")}</Title>
|
||||
<NamespacesTable initialNamespaces={namespaces} />
|
||||
<NamespacesTable contextId={context.contextId} initialNamespaces={namespaces} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface NodesListComponentProps {
|
||||
contextId: string;
|
||||
initialNodes: RouterOutputs["kubernetes"]["nodes"]["getNodes"];
|
||||
}
|
||||
|
||||
@@ -49,14 +50,14 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_Co
|
||||
accessorKey: "allocatableCpuPercentage",
|
||||
header: t("field.cpu.label"),
|
||||
Cell({ cell }) {
|
||||
return getRingProgress(cell.row.original.allocatableCpuPercentage);
|
||||
return getRingProgress(cell.row.original.allocatableCpuPercentage, t("field.metricsUnavailable"));
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "allocatableRamPercentage",
|
||||
header: t("field.memory.label"),
|
||||
Cell({ cell }) {
|
||||
return getRingProgress(cell.row.original.allocatableRamPercentage);
|
||||
return getRingProgress(cell.row.original.allocatableRamPercentage, t("field.metricsUnavailable"));
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -82,15 +83,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_Co
|
||||
},
|
||||
];
|
||||
|
||||
export function NodesTable(initialData: NodesListComponentProps) {
|
||||
export function NodesTable({ contextId, initialNodes }: NodesListComponentProps) {
|
||||
const tNodes = useScopedI18n("kubernetes.nodes");
|
||||
|
||||
const { data } = clientApi.kubernetes.nodes.getNodes.useQuery(undefined, {
|
||||
initialData: initialData.initialNodes,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data } = clientApi.kubernetes.nodes.getNodes.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialNodes,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
@@ -115,7 +120,11 @@ export function NodesTable(initialData: NodesListComponentProps) {
|
||||
return <MantineReactTable table={table} />;
|
||||
}
|
||||
|
||||
function getRingProgress(value: number) {
|
||||
function getRingProgress(value: number | null, unavailableLabel: string) {
|
||||
if (value === null) {
|
||||
return <Text c="dimmed">{unavailableLabel}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<RingProgress
|
||||
size={70}
|
||||
|
||||
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { NodesTable } from "~/app/[locale]/manage/tools/kubernetes/nodes/nodes-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function NodesPage() {
|
||||
export default async function NodesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const nodes = await api.kubernetes.nodes.getNodes();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const nodes =
|
||||
context.status === "unavailable" ? [] : await api.kubernetes.nodes.getNodes({ contextId: context.contextId });
|
||||
const tNodes = await getScopedI18n("kubernetes.nodes");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tNodes("label")}</Title>
|
||||
<NodesTable initialNodes={nodes} />
|
||||
<NodesTable contextId={context.contextId} initialNodes={nodes} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { PodsTable } from "~/app/[locale]/manage/tools/kubernetes/pods/pods-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function PodsPage() {
|
||||
export default async function PodsPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const pods = await api.kubernetes.pods.getPods();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const pods =
|
||||
context.status === "unavailable" ? [] : await api.kubernetes.pods.getPods({ contextId: context.contextId });
|
||||
const tPods = await getScopedI18n("kubernetes.pods");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tPods("label")}</Title>
|
||||
<PodsTable initialPods={pods} />
|
||||
<PodsTable contextId={context.contextId} initialPods={pods} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface PodsTableComponentProps {
|
||||
contextId: string;
|
||||
initialPods: RouterOutputs["kubernetes"]["pods"]["getPods"];
|
||||
}
|
||||
|
||||
@@ -47,15 +48,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.pods">): MRT_Col
|
||||
},
|
||||
];
|
||||
|
||||
export function PodsTable(initialData: PodsTableComponentProps) {
|
||||
export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
|
||||
const tPods = useScopedI18n("kubernetes.pods");
|
||||
|
||||
const { data } = clientApi.kubernetes.pods.getPods.useQuery(undefined, {
|
||||
initialData: initialData.initialPods,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialPods,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { SecretsTable } from "~/app/[locale]/manage/tools/kubernetes/secrets/secrets-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function SecretsPage() {
|
||||
export default async function SecretsPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const secrets = await api.kubernetes.secrets.getSecrets();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const secrets =
|
||||
context.status === "unavailable" ? [] : await api.kubernetes.secrets.getSecrets({ contextId: context.contextId });
|
||||
const tSecrets = await getScopedI18n("kubernetes.secrets");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tSecrets("label")}</Title>
|
||||
<SecretsTable initialSecrets={secrets} />
|
||||
<SecretsTable contextId={context.contextId} initialSecrets={secrets} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface SecretsTableComponentProps {
|
||||
contextId: string;
|
||||
initialSecrets: RouterOutputs["kubernetes"]["secrets"]["getSecrets"];
|
||||
}
|
||||
|
||||
@@ -42,15 +43,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.secrets">): MRT_
|
||||
},
|
||||
];
|
||||
|
||||
export function SecretsTable(initialData: SecretsTableComponentProps) {
|
||||
export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponentProps) {
|
||||
const tSecrets = useScopedI18n("kubernetes.secrets");
|
||||
|
||||
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(undefined, {
|
||||
initialData: initialData.initialSecrets,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialSecrets,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ServicesTable } from "~/app/[locale]/manage/tools/kubernetes/services/services-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function ServicesPage() {
|
||||
export default async function ServicesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const services = await api.kubernetes.services.getServices();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const services =
|
||||
context.status === "unavailable" ? [] : await api.kubernetes.services.getServices({ contextId: context.contextId });
|
||||
const tServices = await getScopedI18n("kubernetes.services");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tServices("label")}</Title>
|
||||
<ServicesTable initialServices={services} />
|
||||
<ServicesTable contextId={context.contextId} initialServices={services} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface ServicesTableComponentProps {
|
||||
contextId: string;
|
||||
initialServices: RouterOutputs["kubernetes"]["services"]["getServices"];
|
||||
}
|
||||
|
||||
@@ -61,15 +62,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.services">): MRT
|
||||
},
|
||||
];
|
||||
|
||||
export function ServicesTable(initialData: ServicesTableComponentProps) {
|
||||
export function ServicesTable({ contextId, initialServices }: ServicesTableComponentProps) {
|
||||
const tServices = useScopedI18n("kubernetes.services");
|
||||
|
||||
const { data } = clientApi.kubernetes.services.getServices.useQuery(undefined, {
|
||||
initialData: initialData.initialServices,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data } = clientApi.kubernetes.services.getServices.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialServices,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { VolumesTable } from "~/app/[locale]/manage/tools/kubernetes/volumes/volumes-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function VolumesPage() {
|
||||
export default async function VolumesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const volumes = await api.kubernetes.volumes.getVolumes();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const volumes =
|
||||
context.status === "unavailable" ? [] : await api.kubernetes.volumes.getVolumes({ contextId: context.contextId });
|
||||
const tVolumes = await getScopedI18n("kubernetes.volumes");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tVolumes("label")}</Title>
|
||||
<VolumesTable initialVolumes={volumes} />
|
||||
<VolumesTable contextId={context.contextId} initialVolumes={volumes} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface VolumesTableComponentProps {
|
||||
contextId: string;
|
||||
initialVolumes: RouterOutputs["kubernetes"]["volumes"]["getVolumes"];
|
||||
}
|
||||
|
||||
@@ -66,15 +67,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.volumes">): MRT_
|
||||
},
|
||||
];
|
||||
|
||||
export function VolumesTable(initialData: VolumesTableComponentProps) {
|
||||
export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponentProps) {
|
||||
const tVolumes = useScopedI18n("kubernetes.volumes");
|
||||
|
||||
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(undefined, {
|
||||
initialData: initialData.initialVolumes,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialVolumes,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -246,7 +246,7 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr
|
||||
|
||||
**"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`).
|
||||
|
||||
**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs.
|
||||
**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with \`targets\` containing the endpointId and id returned for each container.
|
||||
|
||||
**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation.
|
||||
|
||||
|
||||
@@ -159,7 +159,6 @@ import { getAssistantIconSearchQuery } from "./assistant-tool-label";
|
||||
import { getAssistantProviderQuotaLevel, isAssistantProviderUnavailable } from "./assistant-provider-quota";
|
||||
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
|
||||
import { getSafeAssistantHttpUrl } from "./assistant-url";
|
||||
import { isEscapeOwnedByNestedOverlay } from "../board/advanced-focus/escape";
|
||||
|
||||
// Shiki's web grammar bundle is intentionally loaded only when a settled response contains a code
|
||||
// block. Most assistant conversations never need it, so it should not inflate the global dashboard
|
||||
@@ -408,27 +407,7 @@ type MarkdownSpanProps = ComponentPropsWithoutRef<"span"> & {
|
||||
"data-directive-type"?: string;
|
||||
};
|
||||
|
||||
interface AssistantDirectiveEntitiesState {
|
||||
entities: AssistantDirectiveEntity[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AssistantDirectiveEntitiesContext = createContext<AssistantDirectiveEntitiesState>({
|
||||
entities: [],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const AssistantDirectiveEntitiesProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<AssistantDirectiveEntitiesContext.Provider value={{ entities, isLoading }}>
|
||||
{children}
|
||||
</AssistantDirectiveEntitiesContext.Provider>
|
||||
);
|
||||
};
|
||||
const AssistantDirectiveEntitiesContext = createContext<AssistantDirectiveEntity[]>([]);
|
||||
|
||||
const AssistantMarkdownSpan = ({
|
||||
children,
|
||||
@@ -438,7 +417,7 @@ const AssistantMarkdownSpan = ({
|
||||
"data-directive-type": directiveType,
|
||||
...props
|
||||
}: MarkdownSpanProps) => {
|
||||
const { entities } = useContext(AssistantDirectiveEntitiesContext);
|
||||
const entities = useContext(AssistantDirectiveEntitiesContext);
|
||||
if (assistantDirective === "true" && directiveId && directiveLabel && directiveType) {
|
||||
const entity = resolveAssistantDirectiveEntity(entities, {
|
||||
id: directiveId,
|
||||
@@ -465,20 +444,26 @@ const assistantMarkdownComponents = unstable_memoizeMarkdownComponents({
|
||||
SyntaxHighlighter: AssistantSyntaxHighlighter,
|
||||
});
|
||||
|
||||
const AssistantTextPart = () => (
|
||||
<MarkdownTextPrimitive
|
||||
className={classes.messageMarkdown}
|
||||
preprocess={normalizeAssistantMarkdown}
|
||||
remarkPlugins={markdownRemarkPlugins}
|
||||
components={assistantMarkdownComponents}
|
||||
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
|
||||
defer
|
||||
/>
|
||||
);
|
||||
const AssistantTextPart = () => {
|
||||
const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 });
|
||||
|
||||
return (
|
||||
<AssistantDirectiveEntitiesContext.Provider value={entities}>
|
||||
<MarkdownTextPrimitive
|
||||
className={classes.messageMarkdown}
|
||||
preprocess={normalizeAssistantMarkdown}
|
||||
remarkPlugins={markdownRemarkPlugins}
|
||||
components={assistantMarkdownComponents}
|
||||
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
|
||||
defer
|
||||
/>
|
||||
</AssistantDirectiveEntitiesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const UserTextPart = () => {
|
||||
const { text } = useMessagePartText();
|
||||
const { entities } = useContext(AssistantDirectiveEntitiesContext);
|
||||
const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 });
|
||||
const segments = parseAssistantDirectives(text);
|
||||
return (
|
||||
<span className={classes.messageText}>
|
||||
@@ -563,7 +548,7 @@ const SentAttachment = () => <Attachment />;
|
||||
const HiddenMessagePart = () => null;
|
||||
|
||||
const ReasoningPart = (_props: ReasoningMessagePartProps) => (
|
||||
<AssistantDirectiveEntitiesContext.Provider value={{ entities: [], isLoading: false }}>
|
||||
<AssistantDirectiveEntitiesContext.Provider value={[]}>
|
||||
<MarkdownTextPrimitive
|
||||
className={`${classes.messageMarkdown} ${classes.reasoningText}`}
|
||||
preprocess={normalizeAssistantMarkdown}
|
||||
@@ -2279,7 +2264,9 @@ const ComposerTriggers = () => {
|
||||
const aui = useAui();
|
||||
const mentionViewportRef = useRef<HTMLDivElement>(null);
|
||||
const slashViewportRef = useRef<HTMLDivElement>(null);
|
||||
const { entities, isLoading } = useContext(AssistantDirectiveEntitiesContext);
|
||||
const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const categories = useMemo(
|
||||
() =>
|
||||
(["app", "integration", "board", "widget"] as const).map((type) => ({
|
||||
@@ -2927,7 +2914,7 @@ const AssistantActivityBar = ({
|
||||
|
||||
return (
|
||||
<Box component="output" className={classes.activityBar} aria-live="polite">
|
||||
<UnstyledButton className={classes.activityTrigger} onClick={onOpen}>
|
||||
<UnstyledButton className={classes.activityTrigger} onClick={onOpen} aria-label={t("activity.expand")}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={activityColor} radius="xl">
|
||||
<AssistantDotMatrix state={activityState} label={title} role="presentation" aria-hidden />
|
||||
@@ -3009,7 +2996,7 @@ export const AssistantConversationSurface = ({
|
||||
const scrollToLatestBehavior = isRunning || reducedMotion ? "instant" : "smooth";
|
||||
|
||||
return (
|
||||
<AssistantDirectiveEntitiesProvider>
|
||||
<>
|
||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||
{onWindowClose && onMinimize && (
|
||||
<Group
|
||||
@@ -3120,7 +3107,7 @@ export const AssistantConversationSurface = ({
|
||||
</ThreadPrimitive.Root>
|
||||
</ReasoningVisibilityContext.Provider>
|
||||
</AssistantQuestionPortalProvider>
|
||||
</AssistantDirectiveEntitiesProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3148,19 +3135,9 @@ export const AssistantPanel = ({
|
||||
}: AssistantPanelProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
const panelRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useWindowEvent("keydown", (event) => {
|
||||
if (
|
||||
!opened ||
|
||||
event.key !== "Escape" ||
|
||||
event.defaultPrevented ||
|
||||
event.isComposing ||
|
||||
isEscapeOwnedByNestedOverlay(event.target, panelRef.current)
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
if (opened && event.key === "Escape") onClose();
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
@@ -3197,7 +3174,7 @@ export const AssistantPanel = ({
|
||||
/>
|
||||
)}
|
||||
{opened && (
|
||||
<dialog ref={panelRef} className={classes.floatingPanel} aria-label={t("title")} open>
|
||||
<dialog className={classes.floatingPanel} aria-label={t("title")} open>
|
||||
<AssistantConversationSurface
|
||||
isRunning={isRunning}
|
||||
pendingAction={pendingAction}
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
boardName: "Home",
|
||||
kind: "audioStats",
|
||||
summary: "Show audio statistics",
|
||||
integrationIds: ["navidrome-1", "audiobookshelf-1", "plex-1"],
|
||||
integrationIds: ["navidrome-private", "navidrome-1", "audiobookshelf-1", "plex-1"],
|
||||
},
|
||||
settings,
|
||||
[
|
||||
@@ -55,28 +55,28 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
name: "Navidrome",
|
||||
kind: "navidrome",
|
||||
url: "https://music.example.com",
|
||||
permissions: { hasUseAccess: true },
|
||||
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
|
||||
},
|
||||
{
|
||||
id: "audiobookshelf-1",
|
||||
name: "Audiobookshelf",
|
||||
kind: "audiobookshelf",
|
||||
url: "https://books.example.com",
|
||||
permissions: { hasUseAccess: true },
|
||||
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
|
||||
},
|
||||
{
|
||||
id: "plex-1",
|
||||
name: "Plex",
|
||||
kind: "plex",
|
||||
url: "https://plex.example.com",
|
||||
permissions: { hasUseAccess: true },
|
||||
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
|
||||
},
|
||||
{
|
||||
id: "navidrome-private",
|
||||
name: "Private Navidrome",
|
||||
kind: "navidrome",
|
||||
url: "https://private.example.com",
|
||||
permissions: { hasUseAccess: false },
|
||||
permissions: { hasUseAccess: false, hasInteractAccess: false, hasFullAccess: false },
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@ma
|
||||
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
@@ -20,9 +21,10 @@ import { useAssistantAutomaticAction } from "./assistant-auto-approval";
|
||||
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
|
||||
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
|
||||
import type { ConfigureWidgetArgs, ConfigureWidgetResult } from "./assistant-tool-contracts";
|
||||
import { IntegrationSelectModal } from "../integration/integration-select-modal";
|
||||
|
||||
interface AssistantIntegration extends IntegrationSelectOption {
|
||||
permissions: { hasUseAccess: boolean };
|
||||
permissions: NonNullable<IntegrationSelectOption["permissions"]>;
|
||||
}
|
||||
|
||||
export const getAssistantWidgetConfiguration = (
|
||||
@@ -66,6 +68,7 @@ export const AssistantConfigureWidgetTool = ({
|
||||
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
|
||||
const t = useScopedI18n("common.assistant.configureWidget");
|
||||
const fullT = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const settings = useSettings();
|
||||
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
||||
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
|
||||
@@ -80,10 +83,11 @@ export const AssistantConfigureWidgetTool = ({
|
||||
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
||||
const missingRequiredIntegration =
|
||||
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const autoConfirming = useAssistantAutomaticAction({
|
||||
toolCallId,
|
||||
ready: result === undefined && configuration !== null,
|
||||
ready: result === undefined && configuration !== null && !missingRequiredIntegration,
|
||||
completed: result !== undefined,
|
||||
confirm: () => {
|
||||
if (!args || !configuration) return;
|
||||
@@ -154,6 +158,26 @@ export const AssistantConfigureWidgetTool = ({
|
||||
|
||||
if (!configuration) return null;
|
||||
|
||||
const openConfigurationModal = (
|
||||
value = configuration.value,
|
||||
integrationData: IntegrationSelectOption[] = configuration.integrationData,
|
||||
) =>
|
||||
openWidgetModal(
|
||||
{
|
||||
kind: args.kind,
|
||||
definition: widgetImports[args.kind].definition,
|
||||
value,
|
||||
integrationData,
|
||||
integrationSupport: configuration.integrationSupport,
|
||||
settings,
|
||||
onSuccessfulEdit: ({ options, integrationIds }) =>
|
||||
addResult({ boardId: args.boardId, kind: args.kind, options, integrationIds }),
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${args.kind}.name`)}`,
|
||||
},
|
||||
);
|
||||
|
||||
if (autoConfirming) {
|
||||
return (
|
||||
<Box className={classes.appTool}>
|
||||
@@ -168,9 +192,32 @@ export const AssistantConfigureWidgetTool = ({
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("noIntegrationDescription")}</Text>
|
||||
<Group gap="xs">
|
||||
<Button component="a" href="/manage/integrations" size="compact-sm" variant="light">
|
||||
{t("manageIntegrations")}
|
||||
</Button>
|
||||
{session?.user.permissions.includes("integration-create") ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
openIntegrationModal({
|
||||
allowedKinds:
|
||||
definition && "supportedIntegrations" in definition
|
||||
? (definition.supportedIntegrations as readonly IntegrationKind[])
|
||||
: [],
|
||||
onSuccess: (created) => {
|
||||
if (!created) return;
|
||||
openConfigurationModal({ ...configuration.value, integrationIds: [created.integration.id] }, [
|
||||
created.integration,
|
||||
]);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("connectIntegration")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button component="a" href="/manage/integrations" size="compact-sm" variant="light">
|
||||
{t("manageIntegrations")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
@@ -205,28 +252,7 @@ export const AssistantConfigureWidgetTool = ({
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Button
|
||||
mt="md"
|
||||
fullWidth
|
||||
leftSection={<IconSettings size={17} />}
|
||||
onClick={() =>
|
||||
openModal(
|
||||
{
|
||||
kind: args.kind,
|
||||
definition: widgetImports[args.kind].definition,
|
||||
value: configuration.value,
|
||||
integrationData: configuration.integrationData,
|
||||
integrationSupport: configuration.integrationSupport,
|
||||
settings,
|
||||
onSuccessfulEdit: ({ options, integrationIds }) =>
|
||||
addResult({ boardId: args.boardId, kind: args.kind, options, integrationIds }),
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${args.kind}.name`)}`,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button mt="md" fullWidth leftSection={<IconSettings size={17} />} onClick={() => openConfigurationModal()}>
|
||||
{t("review")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { ActionIcon, Box, Center, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { IconArrowsMaximize, IconRobot } from "@tabler/icons-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useAui, useAuiState } from "@assistant-ui/react";
|
||||
import { ActionIcon, Box, Button, Center, Group, Loader, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconArrowsMaximize, IconMessage, IconRobot } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
||||
@@ -29,22 +30,81 @@ export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) =
|
||||
);
|
||||
};
|
||||
|
||||
const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
|
||||
const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
|
||||
const t = useI18n();
|
||||
const board = useRequiredBoard();
|
||||
const assistant = useHomarrAssistant();
|
||||
const preferences = useAssistantPreferences();
|
||||
const aui = useAui();
|
||||
const currentThreadId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const messages = useAuiState((state) => state.thread.messages);
|
||||
const isLoading = useAuiState((state) => state.thread.isLoading);
|
||||
const latestAssistantMessage = messages.findLast((message) => message.role === "assistant");
|
||||
const pendingAction = getPendingAssistantAction(latestAssistantMessage);
|
||||
const selectModel = (modelId: string) => {
|
||||
preferences.setModelId(modelId);
|
||||
const threadListItem = aui.threadListItem();
|
||||
if (!threadListItem.getState().remoteId) return;
|
||||
threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId });
|
||||
const pinnedConversation = options.conversationMode === "pinned" ? options.conversation : null;
|
||||
const pinnedConversationIsActive = pinnedConversation === null || pinnedConversation.value === currentThreadId;
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [switchErrorConversationId, setSwitchErrorConversationId] = useState<string | null>(null);
|
||||
const hasSwitchError = switchErrorConversationId === pinnedConversation?.value;
|
||||
|
||||
const selectModel = useCallback(
|
||||
(modelId: string) => {
|
||||
preferences.setModelId(modelId);
|
||||
const threadListItem = aui.threadListItem();
|
||||
if (!threadListItem.getState().remoteId) return;
|
||||
threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId });
|
||||
},
|
||||
[aui, preferences],
|
||||
);
|
||||
|
||||
const openPinnedConversation = async () => {
|
||||
if (!pinnedConversation) return;
|
||||
setSwitchErrorConversationId(null);
|
||||
setSwitching(true);
|
||||
try {
|
||||
await aui.threads().switchToThread(pinnedConversation.value);
|
||||
} catch {
|
||||
setSwitchErrorConversationId(pinnedConversation.value);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (options.conversationMode === "pinned" && pinnedConversation === null) {
|
||||
return (
|
||||
<AssistantWidgetState
|
||||
icon={IconMessage}
|
||||
title={t("widget.assistant.pinned.selectTitle")}
|
||||
description={t("widget.assistant.pinned.selectDescription")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pinnedConversationIsActive && pinnedConversation) {
|
||||
return (
|
||||
<AssistantWidgetState
|
||||
icon={hasSwitchError ? IconAlertTriangle : IconMessage}
|
||||
color={hasSwitchError ? "red" : "gray"}
|
||||
title={t("widget.assistant.pinned.title")}
|
||||
description={
|
||||
hasSwitchError
|
||||
? t("widget.assistant.pinned.error")
|
||||
: t("widget.assistant.pinned.description", { conversation: pinnedConversation.label })
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color={hasSwitchError ? "red" : "gray"}
|
||||
onClick={() => void openPinnedConversation()}
|
||||
loading={switching}
|
||||
leftSection={switching ? <Loader type="bars" size="xs" /> : <IconMessage size={16} />}
|
||||
>
|
||||
{switching ? t("widget.assistant.pinned.opening") : t("widget.assistant.pinned.open")}
|
||||
</Button>
|
||||
</AssistantWidgetState>
|
||||
);
|
||||
}
|
||||
|
||||
if (width < 300 || height < 280) {
|
||||
return <CompactAssistantWidget onOpen={assistant.open} />;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getStepAfterRestorePreview, waitForServerReadinessAsync } from "./database-restore-flow";
|
||||
|
||||
it("requires confirmation after every restore preview", () => {
|
||||
expect(getStepAfterRestorePreview()).toBe("confirm");
|
||||
});
|
||||
import { waitForServerReadinessAsync } from "./database-restore-flow";
|
||||
|
||||
describe("waitForServerReadinessAsync", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -29,8 +29,6 @@ const RESTART_REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type ServerReadinessResult = "ready" | "timedOut" | "aborted";
|
||||
|
||||
export const getStepAfterRestorePreview = (): RestoreStep => "confirm";
|
||||
|
||||
interface WaitForServerReadinessOptions {
|
||||
restartAfterMs: number;
|
||||
signal: AbortSignal;
|
||||
@@ -291,8 +289,11 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
<>
|
||||
<BackupPreviewPanel analysis={analysis} />
|
||||
<Group justify="flex-end">
|
||||
<Button rightSection={<IconArrowRight size={16} />} onClick={() => setStep(getStepAfterRestorePreview())}>
|
||||
{t("continueToRestore")}
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => (variant === "standalone" ? void handleConfirm() : setStep("confirm"))}
|
||||
>
|
||||
{variant === "standalone" ? t("confirm.submit") : t("continueToRestore")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { CSSProperties, PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Overlay, Portal } from "@mantine/core";
|
||||
import { useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
|
||||
import { useIsomorphicEffect } from "@mantine/hooks";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { FocusRect } from "./geometry";
|
||||
interface ActiveFocus {
|
||||
itemId: string;
|
||||
source: HTMLElement;
|
||||
restoreFocusTarget: HTMLElement;
|
||||
sourceRect: FocusRect;
|
||||
activation: "preview" | "manual";
|
||||
autofocusClose: boolean;
|
||||
@@ -25,12 +24,10 @@ interface ActiveFocus {
|
||||
interface OpenAdvancedFocusOptions {
|
||||
activation?: ActiveFocus["activation"];
|
||||
autofocusClose?: boolean;
|
||||
restoreFocusTarget?: HTMLElement;
|
||||
}
|
||||
|
||||
interface AdvancedFocusContextValue {
|
||||
active: ActiveFocus | null;
|
||||
viewportSize: { width: number; height: number };
|
||||
open: (itemId: string, source: HTMLElement, options?: OpenAdvancedFocusOptions) => void;
|
||||
close: (restoreFocus?: boolean) => void;
|
||||
dismiss: (itemId: string) => void;
|
||||
@@ -49,7 +46,6 @@ const isEditableTarget = (target: EventTarget | null) =>
|
||||
|
||||
export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const viewportSize = useViewportSize();
|
||||
const [active, setActive] = useState<ActiveFocus | null>(null);
|
||||
const [isHoldPending, setIsHoldPending] = useState(false);
|
||||
const activeRef = useRef<ActiveFocus | null>(null);
|
||||
@@ -96,7 +92,6 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
updateActive({
|
||||
itemId,
|
||||
source,
|
||||
restoreFocusTarget: options.restoreFocusTarget ?? source,
|
||||
sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
||||
activation: options.activation ?? "manual",
|
||||
autofocusClose: options.autofocusClose ?? false,
|
||||
@@ -118,8 +113,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
closeTimerRef.current = null;
|
||||
updateActive(null);
|
||||
if (restoreFocus && current.restoreFocusTarget.isConnected)
|
||||
requestAnimationFrame(() => current.restoreFocusTarget.focus());
|
||||
if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus());
|
||||
}, closeDuration);
|
||||
},
|
||||
[cancelHoverTimer, cancelPreviewLeaveTimer, updateActive],
|
||||
@@ -216,7 +210,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (underlyingSource.hasAttribute("data-advanced-focus-enabled")) {
|
||||
if (underlyingSource.hasAttribute("aria-keyshortcuts")) {
|
||||
hover(itemId, underlyingSource);
|
||||
return;
|
||||
}
|
||||
@@ -316,8 +310,8 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
}, [active?.activation]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ active, viewportSize, open, close, dismiss, hover, leave }),
|
||||
[active, close, dismiss, hover, leave, open, viewportSize],
|
||||
() => ({ active, open, close, dismiss, hover, leave }),
|
||||
[active, close, dismiss, hover, leave, open],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: calc(var(--mantine-spacing-xl) * var(--board-canvas-inverse-scale, 1));
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-inline: var(--mantine-spacing-md);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
max-width: 42rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import { IconBox, IconLayoutDashboard, IconPlug, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useBoardAddActions } from "./use-board-add-actions";
|
||||
import classes from "./board-empty-state.module.css";
|
||||
|
||||
export const BoardEmptyState = () => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { addWidget, addApp, connectService, canConnectService } = useBoardAddActions();
|
||||
const t = useScopedI18n("board.emptyState");
|
||||
const hasBoardContent = board.items.length > 0 || board.sections.some((section) => section.kind !== "empty");
|
||||
|
||||
if (!isEditMode || hasBoardContent) return null;
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Paper className={classes.panel} withBorder radius="md" p="lg">
|
||||
<Stack align="center" gap="sm">
|
||||
<ThemeIcon variant="light" size="xl" radius="md">
|
||||
<IconLayoutDashboard size={24} />
|
||||
</ThemeIcon>
|
||||
<Stack align="center" gap={4}>
|
||||
<Title order={3} size="h4" ta="center">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm" ta="center" maw="60ch">
|
||||
{t("description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group justify="center">
|
||||
<Button leftSection={<IconResize size={18} />} onClick={addWidget}>
|
||||
{t("addWidget")}
|
||||
</Button>
|
||||
<Button variant="default" leftSection={<IconBox size={18} />} onClick={addApp}>
|
||||
{t("addApp")}
|
||||
</Button>
|
||||
{canConnectService && (
|
||||
<Button variant="default" leftSection={<IconPlug size={18} />} onClick={connectService}>
|
||||
{t("connectService")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
bottom: calc(var(--mantine-spacing-md) * var(--board-canvas-inverse-scale, 1));
|
||||
left: calc(var(--mantine-spacing-md) * var(--board-canvas-inverse-scale, 1));
|
||||
max-width: min(24rem, calc(100% - 2 * var(--mantine-spacing-md)));
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: min(24rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.step {
|
||||
border-radius: var(--mantine-radius-md);
|
||||
padding: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.step[data-complete] {
|
||||
background: light-dark(var(--mantine-color-green-0), rgba(47, 158, 68, 0.1));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
declare const classes: {
|
||||
readonly panel: string;
|
||||
readonly root: string;
|
||||
readonly step: string;
|
||||
};
|
||||
export default classes;
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Button, Group, Paper, Progress, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { IconCheck, IconChevronDown, IconChecklist } from "@tabler/icons-react";
|
||||
|
||||
import { useIntegrationsWithUseAccess } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import { getBoardSetupProgress } from "./board-setup-progress";
|
||||
import { useBoardAddActions } from "./use-board-add-actions";
|
||||
import classes from "./board-setup-checklist.module.css";
|
||||
|
||||
export const BoardSetupChecklist = () => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const integrations = useIntegrationsWithUseAccess();
|
||||
const { addWidget, addApp, connectService, canConnectService } = useBoardAddActions();
|
||||
const [collapsed, setCollapsed] = useLocalStorage({
|
||||
key: `homarr-board-setup-checklist-${board.id}`,
|
||||
defaultValue: false,
|
||||
});
|
||||
const t = useScopedI18n("board.setupChecklist");
|
||||
const trackSetup = useSetupAnalytics();
|
||||
const progress = getBoardSetupProgress({
|
||||
itemKinds: board.items.map((item) => item.kind),
|
||||
usableIntegrationCount: integrations.length,
|
||||
});
|
||||
|
||||
if (!isEditMode || progress.isComplete) return null;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconChecklist size={18} />}
|
||||
onClick={() => {
|
||||
trackSetup("checklist-resumed", { entryPoint: "board", outcome: "continued", hasBoardContext: true });
|
||||
setCollapsed(false);
|
||||
}}
|
||||
>
|
||||
{t("resume", { completed: progress.completedCount, total: progress.totalCount })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Paper className={classes.panel} withBorder shadow="md" radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={600}>{t("title")}</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("description")}
|
||||
</Text>
|
||||
</div>
|
||||
<Tooltip label={t("collapse")}>
|
||||
<ActionIcon variant="subtle" aria-label={t("collapse")} onClick={() => setCollapsed(true)}>
|
||||
<IconChevronDown size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Progress
|
||||
value={progress.totalCount === 0 ? 0 : (progress.completedCount / progress.totalCount) * 100}
|
||||
aria-label={t("progress")}
|
||||
/>
|
||||
<ChecklistStep
|
||||
complete={progress.steps.content}
|
||||
label={t("step.content.label")}
|
||||
description={t("step.content.description")}
|
||||
actionLabel={t("step.content.action")}
|
||||
onAction={addWidget}
|
||||
/>
|
||||
<ChecklistStep
|
||||
complete={progress.steps.app}
|
||||
label={t("step.app.label")}
|
||||
description={t("step.app.description")}
|
||||
actionLabel={t("step.app.action")}
|
||||
onAction={addApp}
|
||||
/>
|
||||
<ChecklistStep
|
||||
complete={progress.steps.service}
|
||||
label={t("step.service.label")}
|
||||
description={canConnectService ? t("step.service.description") : t("step.service.permissionDescription")}
|
||||
actionLabel={canConnectService ? t("step.service.action") : undefined}
|
||||
onAction={canConnectService ? connectService : undefined}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChecklistStep = ({
|
||||
complete,
|
||||
label,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
complete: boolean;
|
||||
label: string;
|
||||
description: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}) => (
|
||||
<Group className={classes.step} data-complete={complete || undefined} wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon variant={complete ? "filled" : "light"} color={complete ? "green" : "gray"} size="md" mt={2}>
|
||||
{complete ? <IconCheck size={14} /> : <IconChecklist size={14} />}
|
||||
</ThemeIcon>
|
||||
<Stack gap={1} style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={600} td={complete ? "line-through" : undefined}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!complete && onAction && actionLabel && (
|
||||
<Button variant="subtle" size="compact-xs" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getBoardSetupProgress } from "./board-setup-progress";
|
||||
|
||||
describe("getBoardSetupProgress", () => {
|
||||
test("derives resumable progress from the board and connected services", () => {
|
||||
expect(getBoardSetupProgress({ itemKinds: ["clock", "app"], usableIntegrationCount: 1 })).toEqual({
|
||||
steps: { content: true, app: true, service: true },
|
||||
completedCount: 3,
|
||||
totalCount: 3,
|
||||
isComplete: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not count an app as a data widget", () => {
|
||||
expect(getBoardSetupProgress({ itemKinds: ["app"], usableIntegrationCount: 0 })).toMatchObject({
|
||||
steps: { content: false, app: true, service: false },
|
||||
completedCount: 1,
|
||||
isComplete: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
export interface BoardSetupProgressInput {
|
||||
itemKinds: readonly WidgetKind[];
|
||||
usableIntegrationCount: number;
|
||||
}
|
||||
|
||||
export const getBoardSetupProgress = ({ itemKinds, usableIntegrationCount }: BoardSetupProgressInput) => {
|
||||
const steps = {
|
||||
content: itemKinds.some((kind) => kind !== "app"),
|
||||
app: itemKinds.includes("app"),
|
||||
service: usableIntegrationCount > 0,
|
||||
};
|
||||
const completedCount = Object.values(steps).filter(Boolean).length;
|
||||
|
||||
return {
|
||||
steps,
|
||||
completedCount,
|
||||
totalCount: Object.keys(steps).length,
|
||||
isComplete: completedCount === Object.keys(steps).length,
|
||||
};
|
||||
};
|
||||
@@ -4,8 +4,9 @@ import type { Modify } from "@homarr/common/types";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetDefaultSizes } from "@homarr/definitions";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
|
||||
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||
import type { Board, ContainerSection, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
@@ -15,33 +16,43 @@ export interface CreateItemInput {
|
||||
kind: WidgetKind;
|
||||
options?: Record<string, unknown>;
|
||||
integrationIds?: string[];
|
||||
targetSectionId?: string;
|
||||
advancedOptions?: BoardItemAdvancedOptions;
|
||||
}
|
||||
|
||||
export const createItemCallback =
|
||||
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
|
||||
({ id, kind, options = {}, integrationIds = [], targetSectionId, advancedOptions }: CreateItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const firstSection = previous.sections
|
||||
const firstCanvasSection = previous.sections
|
||||
.filter(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
const requestedSection = targetSectionId
|
||||
? previous.sections.find(
|
||||
(section): section is EmptySection | ContainerSection =>
|
||||
section.id === targetSectionId && (section.kind === "empty" || section.kind === "container"),
|
||||
)
|
||||
: undefined;
|
||||
let targetSection = requestedSection ?? firstCanvasSection;
|
||||
|
||||
if (!firstSection) return previous;
|
||||
if (!targetSection) return previous;
|
||||
|
||||
const itemLayouts = createItemLayouts(previous, firstSection, kind);
|
||||
let itemLayouts = createItemLayouts(previous, targetSection, kind);
|
||||
if (itemLayouts.length === 0 && firstCanvasSection && targetSection.id !== firstCanvasSection.id) {
|
||||
targetSection = firstCanvasSection;
|
||||
itemLayouts = createItemLayouts(previous, targetSection, kind);
|
||||
}
|
||||
if (itemLayouts.length === 0) return previous;
|
||||
const widget = {
|
||||
id: id ?? createId(),
|
||||
kind,
|
||||
options,
|
||||
layouts: itemLayouts,
|
||||
integrationIds,
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
},
|
||||
advancedOptions: advancedOptions ?? { title: null, customCssClasses: [], borderColor: "" },
|
||||
} satisfies Modify<
|
||||
Item,
|
||||
{
|
||||
@@ -59,14 +70,25 @@ const defaultItemSize = { width: 1, height: 1 };
|
||||
|
||||
const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize;
|
||||
|
||||
const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => {
|
||||
const createItemLayouts = (
|
||||
board: Board,
|
||||
currentSection: EmptySection | ContainerSection,
|
||||
kind: WidgetKind,
|
||||
): ItemLayout[] => {
|
||||
const layouts = getBoardLayouts(board);
|
||||
const itemSize = getWidgetItemSize(kind);
|
||||
|
||||
return layouts.map((layoutId) => {
|
||||
return layouts.flatMap((layoutId) => {
|
||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width;
|
||||
const containerLayout =
|
||||
currentSection.kind === "container"
|
||||
? currentSection.layouts.find((layout) => layout.layoutId === layoutId)
|
||||
: undefined;
|
||||
const lane = currentSection.kind === "empty" ? getRootSectionLane(currentSection.xOffset) : "main";
|
||||
if (currentSection.kind === "container" && !containerLayout) return [];
|
||||
const columnCount = containerLayout?.width ?? (boardLayout ? getBoardLaneColumnCount(boardLayout, lane) : 0);
|
||||
if (columnCount === 0) return [];
|
||||
const size = {
|
||||
width: Math.min(itemSize.width, columnCount),
|
||||
height: itemSize.height,
|
||||
@@ -80,12 +102,14 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
throw new Error("Your board is full");
|
||||
}
|
||||
|
||||
return {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
...emptyPosition,
|
||||
sectionId: currentSection.id,
|
||||
layoutId,
|
||||
};
|
||||
return [
|
||||
{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
...emptyPosition,
|
||||
sectionId: currentSection.id,
|
||||
layoutId,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
@@ -149,6 +149,99 @@ describe("item actions create-item", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("places a new item directly in the requested container", () => {
|
||||
const board = new BoardMockBuilder()
|
||||
.addEmptySection({ id: "canvas" })
|
||||
.addSection(
|
||||
new ContainerSectionMockBuilder({ id: "media" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "canvas", width: 4, height: 3 })
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
const layout = board.layouts.at(0);
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
|
||||
const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board);
|
||||
|
||||
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
|
||||
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("omits layouts where the requested container has no layout entry", () => {
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 })
|
||||
.addEmptySection({ id: "canvas" })
|
||||
.addSection(
|
||||
new ContainerSectionMockBuilder({ id: "media" })
|
||||
.addLayout({ layoutId: "base", parentSectionId: "canvas", width: 4, height: 3 })
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
const baseLayout = board.layouts.find((layout) => layout.role === "base");
|
||||
if (!baseLayout) throw new Error("Expected a base layout");
|
||||
baseLayout.id = "base";
|
||||
|
||||
const result = createItemCallback({ id: "calendar", kind: "clock", targetSectionId: "media" })(board);
|
||||
|
||||
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
|
||||
expect.objectContaining({ layoutId: "base", sectionId: "media" }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("sizes a new item for its requested rail and omits layouts where that rail is hidden", () => {
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 })
|
||||
.addEmptySection({ id: "canvas" })
|
||||
.addEmptySection({ id: "left-rail", xOffset: -1 })
|
||||
.build();
|
||||
const baseLayout = board.layouts.find((layout) => layout.role === "base");
|
||||
if (!baseLayout) throw new Error("Expected a base layout");
|
||||
baseLayout.leftGutterColumnCount = 2;
|
||||
|
||||
const result = createItemCallback({ id: "missing", kind: "mediaMissing", targetSectionId: "left-rail" })(board);
|
||||
|
||||
expect(result.items.find((item) => item.id === "missing")?.layouts).toEqual([
|
||||
expect.objectContaining({ layoutId: baseLayout.id, sectionId: "left-rail", width: 2 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("falls back to the main canvas instead of creating a ghost item for a disabled rail", () => {
|
||||
const board = new BoardMockBuilder()
|
||||
.addEmptySection({ id: "canvas" })
|
||||
.addEmptySection({ id: "left-rail", xOffset: -1 })
|
||||
.build();
|
||||
|
||||
const result = createItemCallback({ id: "clock", kind: "clock", targetSectionId: "left-rail" })(board);
|
||||
|
||||
expect(result.items.find((item) => item.id === "clock")?.layouts).toEqual([
|
||||
expect.objectContaining({ sectionId: "canvas" }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("falls back to the main canvas when a requested destination no longer exists", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas" }).build();
|
||||
const layout = board.layouts.at(0);
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
|
||||
const result = createItemCallback({ id: "clock", kind: "clock", targetSectionId: "removed" })(board);
|
||||
|
||||
expect(result.items.find((item) => item.id === "clock")?.layouts).toEqual([
|
||||
expect.objectContaining({ layoutId: "layout", sectionId: "canvas" }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("commits configured advanced options in the creation transaction", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas" }).build();
|
||||
const advancedOptions = { title: "Kitchen", customCssClasses: ["compact"], borderColor: "#abc" };
|
||||
|
||||
const result = createItemCallback({ id: "clock", kind: "clock", advancedOptions })(board);
|
||||
|
||||
expect(result.items.find((item) => item.id === "clock")?.advancedOptions).toEqual(advancedOptions);
|
||||
});
|
||||
|
||||
test("clamps wide defaults and places below a full automatic canvas", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getBoardRecipeDismissalKey, getBoardRecipeRecommendations } from "./board-recipe-recommendations";
|
||||
|
||||
test("recipe dismissal is scoped to both the board and integration kind", () => {
|
||||
expect(getBoardRecipeDismissalKey("board-a", "piHole")).not.toBe(getBoardRecipeDismissalKey("board-b", "piHole"));
|
||||
expect(getBoardRecipeDismissalKey("board-a", "piHole")).not.toBe(getBoardRecipeDismissalKey("board-a", "jellyfin"));
|
||||
});
|
||||
|
||||
describe("getBoardRecipeRecommendations", () => {
|
||||
test("prioritizes widgets unlocked by the newly connected service", () => {
|
||||
const recommendations = getBoardRecipeRecommendations({
|
||||
configuredIntegrationKinds: ["piHole", "jellyfin"],
|
||||
existingItemKinds: [],
|
||||
preferredIntegrationKind: "jellyfin",
|
||||
});
|
||||
|
||||
const firstFallbackIndex = recommendations.findIndex(({ isNewlyAvailable }) => !isNewlyAvailable);
|
||||
expect(recommendations.some(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true);
|
||||
if (firstFallbackIndex !== -1) {
|
||||
expect(recommendations.slice(0, firstFallbackIndex).every(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true);
|
||||
expect(recommendations.slice(firstFallbackIndex).every(({ isNewlyAvailable }) => !isNewlyAvailable)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not recommend widget kinds already present on the board", () => {
|
||||
const recommendations = getBoardRecipeRecommendations({
|
||||
configuredIntegrationKinds: ["piHole"],
|
||||
existingItemKinds: ["dnsHoleSummary"],
|
||||
preferredIntegrationKind: "piHole",
|
||||
});
|
||||
|
||||
expect(recommendations.every(({ widgetKind }) => widgetKind !== "dnsHoleSummary")).toBe(true);
|
||||
expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true);
|
||||
});
|
||||
|
||||
test("deduplicates configured kinds and emitted widget capabilities", () => {
|
||||
const recommendations = getBoardRecipeRecommendations({
|
||||
configuredIntegrationKinds: ["piHole", "piHole"],
|
||||
existingItemKinds: [],
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const widgetKinds = recommendations.map(({ widgetKind }) => widgetKind);
|
||||
expect(new Set(widgetKinds).size).toBe(widgetKinds.length);
|
||||
expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true);
|
||||
});
|
||||
|
||||
test("falls back to other configured integrations when the new service has no missing widgets", () => {
|
||||
const recommendations = getBoardRecipeRecommendations({
|
||||
configuredIntegrationKinds: ["piHole", "jellyfin"],
|
||||
existingItemKinds: ["dnsHoleSummary", "dnsHoleControls"],
|
||||
preferredIntegrationKind: "piHole",
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
expect(recommendations).toEqual([
|
||||
expect.objectContaining({ integrationKind: "jellyfin", isNewlyAvailable: false }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns no recipes without a usable connection or capacity", () => {
|
||||
expect(
|
||||
getBoardRecipeRecommendations({
|
||||
configuredIntegrationKinds: [],
|
||||
existingItemKinds: [],
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
getBoardRecipeRecommendations({
|
||||
configuredIntegrationKinds: ["piHole"],
|
||||
existingItemKinds: [],
|
||||
limit: 0,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions";
|
||||
|
||||
export interface BoardRecipeRecommendation {
|
||||
widgetKind: WidgetKind;
|
||||
integrationKind: IntegrationKind;
|
||||
isNewlyAvailable: boolean;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export const getBoardRecipeDismissalKey = (boardId: string, integrationKind: IntegrationKind) =>
|
||||
`homarr-board-recipes-${boardId}-${integrationKind}`;
|
||||
|
||||
interface BoardRecipeRecommendationInput {
|
||||
configuredIntegrationKinds: readonly IntegrationKind[];
|
||||
existingItemKinds: readonly WidgetKind[];
|
||||
preferredIntegrationKind?: IntegrationKind;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggests missing widgets that can use an existing connection. The newly
|
||||
* connected service ranks first, followed by specific capabilities with fewer
|
||||
* unrelated provider matches. Inputs and outputs are deduplicated so this can
|
||||
* remain a small, explainable client-side recommendation layer.
|
||||
*/
|
||||
export const getBoardRecipeRecommendations = ({
|
||||
configuredIntegrationKinds,
|
||||
existingItemKinds,
|
||||
preferredIntegrationKind,
|
||||
limit = 3,
|
||||
}: BoardRecipeRecommendationInput): BoardRecipeRecommendation[] => {
|
||||
const availableKinds = new Set(configuredIntegrationKinds);
|
||||
const existingKinds = new Set(existingItemKinds);
|
||||
|
||||
return widgetKinds
|
||||
.filter((widgetKind) => !existingKinds.has(widgetKind))
|
||||
.flatMap((widgetKind) => {
|
||||
const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities];
|
||||
if (!capability) return [];
|
||||
|
||||
const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind));
|
||||
if (matches.length === 0) return [];
|
||||
|
||||
const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind);
|
||||
const integrationKind = preferredMatch ? preferredIntegrationKind : matches[0];
|
||||
if (!integrationKind) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
widgetKind,
|
||||
integrationKind,
|
||||
isNewlyAvailable: Boolean(preferredMatch),
|
||||
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length,
|
||||
},
|
||||
];
|
||||
})
|
||||
.toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
|
||||
.slice(0, Math.max(0, limit));
|
||||
};
|
||||
@@ -24,18 +24,3 @@
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.advancedFocusTrigger {
|
||||
position: absolute;
|
||||
top: var(--mantine-spacing-xs);
|
||||
left: 50%;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.advancedFocusTrigger:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ComponentType, CSSProperties, MutableRefObject } from "react";
|
||||
import type { ComponentType, CSSProperties, KeyboardEvent, MutableRefObject } from "react";
|
||||
import { memo, Suspense, use, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { CardProps } from "@mantine/core";
|
||||
import { Badge, Box, Button, Card, Center, Loader, Portal } from "@mantine/core";
|
||||
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
||||
import { Badge, Box, Card, Center, Loader, Portal } from "@mantine/core";
|
||||
import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
@@ -153,16 +153,15 @@ const LoadedBoardItemContent = ({
|
||||
}: LoadedBoardItemContentProps) => {
|
||||
const { definition, Component } = use(loadWidgetResources(item.kind));
|
||||
const sourceRef = useRef<HTMLDivElement>(null);
|
||||
const advancedFocusTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const previewEntranceFrameRef = useRef<number | null>(null);
|
||||
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
const [isEditMode] = useEditMode();
|
||||
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
||||
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||
const { width: viewportWidth, height: viewportHeight } = viewportSize;
|
||||
const { active, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
|
||||
const widgetName = t(`widget.${item.kind}.name`);
|
||||
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
|
||||
@@ -186,13 +185,17 @@ const LoadedBoardItemContent = ({
|
||||
|
||||
useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
|
||||
|
||||
const openAdvancedView = () => {
|
||||
if (sourceRef.current)
|
||||
open(item.id, sourceRef.current, {
|
||||
activation: "manual",
|
||||
autofocusClose: true,
|
||||
restoreFocusTarget: advancedFocusTriggerRef.current ?? sourceRef.current,
|
||||
});
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
event.currentTarget !== event.target ||
|
||||
event.key !== "Enter" ||
|
||||
!event.shiftKey ||
|
||||
isEditMode ||
|
||||
!supportsAdvancedFocus
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose: true });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -350,10 +353,16 @@ const LoadedBoardItemContent = ({
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-advanced-focus-source
|
||||
data-advanced-focus-enabled={supportsAdvancedFocus || undefined}
|
||||
data-item-id={item.id}
|
||||
tabIndex={!isEditMode && supportsAdvancedFocus ? 0 : undefined}
|
||||
role={!isEditMode && supportsAdvancedFocus ? "group" : undefined}
|
||||
aria-label={!isEditMode && supportsAdvancedFocus ? widgetName : undefined}
|
||||
aria-label={
|
||||
!isEditMode && supportsAdvancedFocus ? `${widgetName}: ${t("item.advancedFocus.open")}` : undefined
|
||||
}
|
||||
aria-expanded={supportsAdvancedFocus ? isAdvanced : undefined}
|
||||
aria-controls={isAdvanced ? advancedViewId : undefined}
|
||||
aria-keyshortcuts={supportsAdvancedFocus ? "Shift+Enter" : undefined}
|
||||
onKeyDown={supportsAdvancedFocus ? handleKeyDown : undefined}
|
||||
onPointerEnter={() => {
|
||||
if (supportsAdvancedFocus && sourceRef.current) hover(item.id, sourceRef.current);
|
||||
}}
|
||||
@@ -362,21 +371,6 @@ const LoadedBoardItemContent = ({
|
||||
}}
|
||||
className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)}
|
||||
>
|
||||
{!isEditMode && supportsAdvancedFocus && (
|
||||
<Button
|
||||
ref={advancedFocusTriggerRef}
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
className={itemContentClasses.advancedFocusTrigger}
|
||||
aria-expanded={isAdvanced}
|
||||
aria-controls={isAdvanced ? advancedViewId : undefined}
|
||||
aria-keyshortcuts="Shift+Enter"
|
||||
data-advanced-focus-trigger
|
||||
onClick={openAdvancedView}
|
||||
>
|
||||
{t("item.advancedFocus.open")}
|
||||
</Button>
|
||||
)}
|
||||
<div ref={mountCompactHost} className={advancedFocusClasses.surfaceHost}>
|
||||
{!supportsAdvancedFocus && widgetCard}
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { integrations: integrationData = [], section } = useSectionContext();
|
||||
const { integrations: integrationData, section } = useSectionContext();
|
||||
const settings = useSettings();
|
||||
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
|
||||
@@ -77,8 +77,10 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
});
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
integrationData: integrationData.filter(
|
||||
onIntegrationSaved: resetErrorBoundary,
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
(integration) =>
|
||||
integration.permissions.hasUseAccess &&
|
||||
"supportedIntegrations" in definition &&
|
||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||
import {
|
||||
getWidgetConnectionStatus,
|
||||
resolveMatchingIntegrationsAsync,
|
||||
tryLockSelection,
|
||||
unlockSelection,
|
||||
} from "./item-select-data";
|
||||
|
||||
describe("getWidgetConnectionStatus", () => {
|
||||
const availableKinds = new Set(["sonarr" as const]);
|
||||
|
||||
it("reports widgets with a configured compatible integration as ready", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("ready");
|
||||
});
|
||||
|
||||
it("reports required integrations that are not configured as needing setup", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["radarr"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("needsSetup");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ supportedIntegrations: [], connectionOptional: false },
|
||||
{ supportedIntegrations: ["radarr" as const], connectionOptional: true },
|
||||
])("reports a widget without a required connection", ({ supportedIntegrations, connectionOptional }) => {
|
||||
expect(getWidgetConnectionStatus({ supportedIntegrations, availableKinds, connectionOptional })).toBe(
|
||||
"noConnectionRequired",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("widget selection lock", () => {
|
||||
it("rejects repeated selection synchronously until the active selection finishes", () => {
|
||||
@@ -45,4 +83,20 @@ describe("resolveMatchingIntegrationsAsync", () => {
|
||||
).resolves.toEqual([]);
|
||||
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("excludes matching integrations the user cannot use", async () => {
|
||||
const currentData = [
|
||||
{ id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } },
|
||||
{ id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } },
|
||||
];
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData,
|
||||
ensureDataAsync: vi.fn(),
|
||||
}),
|
||||
).resolves.toEqual([currentData[0]]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
export type WidgetConnectionStatus = "ready" | "needsSetup" | "noConnectionRequired";
|
||||
|
||||
export const getWidgetConnectionStatus = ({
|
||||
supportedIntegrations,
|
||||
availableKinds,
|
||||
connectionOptional,
|
||||
}: {
|
||||
supportedIntegrations: readonly IntegrationKind[];
|
||||
availableKinds: ReadonlySet<IntegrationKind>;
|
||||
connectionOptional: boolean;
|
||||
}): WidgetConnectionStatus => {
|
||||
if (supportedIntegrations.some((kind) => availableKinds.has(kind))) return "ready";
|
||||
if (supportedIntegrations.length === 0 || connectionOptional) return "noConnectionRequired";
|
||||
return "needsSetup";
|
||||
};
|
||||
|
||||
export const tryLockSelection = (lock: { current: boolean }) => {
|
||||
if (lock.current) return false;
|
||||
lock.current = true;
|
||||
@@ -10,7 +26,9 @@ export const unlockSelection = (lock: { current: boolean }) => {
|
||||
lock.current = false;
|
||||
};
|
||||
|
||||
export const resolveMatchingIntegrationsAsync = async <TIntegration extends { kind: IntegrationKind }>({
|
||||
export const resolveMatchingIntegrationsAsync = async <
|
||||
TIntegration extends { kind: IntegrationKind; permissions?: { hasUseAccess: boolean } },
|
||||
>({
|
||||
hasIntegrationSupport,
|
||||
supportedIntegrations,
|
||||
currentData,
|
||||
@@ -24,5 +42,8 @@ export const resolveMatchingIntegrationsAsync = async <TIntegration extends { ki
|
||||
if (!hasIntegrationSupport) return [];
|
||||
|
||||
const integrations = currentData ?? (await ensureDataAsync());
|
||||
return integrations.filter((integration) => supportedIntegrations.includes(integration.kind));
|
||||
return integrations.filter(
|
||||
(integration) =>
|
||||
integration.permissions?.hasUseAccess !== false && supportedIntegrations.includes(integration.kind),
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,45 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Badge, Box, Button, Center, Divider, Group, Image, Select, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
||||
import {
|
||||
getIconUrl,
|
||||
getBoardLaneColumnCount,
|
||||
getIntegrationName,
|
||||
getRootSectionLane,
|
||||
widgetIntegrationSupport,
|
||||
widgetKinds,
|
||||
widgetKindsWithOptionalIntegrations,
|
||||
} from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||
import type { WidgetConnectionStatus } from "./item-select-data";
|
||||
import {
|
||||
getWidgetConnectionStatus,
|
||||
resolveMatchingIntegrationsAsync,
|
||||
tryLockSelection,
|
||||
unlockSelection,
|
||||
} from "./item-select-data";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
import classes from "./item-select-modal.module.css";
|
||||
|
||||
interface ItemSelectModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
@@ -29,6 +47,8 @@ interface ItemSelectModalContentProps {
|
||||
customWidgetDefs: RouterOutputs["customWidget"]["available"] | undefined;
|
||||
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
|
||||
isAdmin: boolean;
|
||||
canCreateIntegration: boolean;
|
||||
initialSearch?: string;
|
||||
}
|
||||
|
||||
const ItemSelectModalContent = ({
|
||||
@@ -37,16 +57,63 @@ const ItemSelectModalContent = ({
|
||||
customWidgetDefs,
|
||||
ensureIntegrationDataAsync,
|
||||
isAdmin,
|
||||
canCreateIntegration,
|
||||
initialSearch = "",
|
||||
}: ItemSelectModalContentProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
const t = useI18n();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const mainCanvasSection = board.sections
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
const placementOptions = useMemo(
|
||||
() => [
|
||||
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "empty") return [];
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return [];
|
||||
return [
|
||||
{
|
||||
value: section.id,
|
||||
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
|
||||
},
|
||||
];
|
||||
}),
|
||||
...board.sections
|
||||
.filter((section) => section.kind === "container")
|
||||
.map((section) => ({
|
||||
value: section.id,
|
||||
label: section.options.title.trim() || t("section.container.untitled"),
|
||||
})),
|
||||
],
|
||||
[board.sections, currentLayout, mainCanvasSection, t],
|
||||
);
|
||||
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||
const selectionLock = useRef(false);
|
||||
const t = useI18n();
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const settings = useSettings();
|
||||
const { createItem, removeItem } = useItemActions();
|
||||
|
||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||
useEffect(() => {
|
||||
if (placementOptions.some(({ value }) => value === targetSectionId)) return;
|
||||
setTargetSectionId(placementOptions[0]?.value ?? null);
|
||||
}, [placementOptions, targetSectionId]);
|
||||
|
||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const settings = useSettings();
|
||||
const trackSetup = useSetupAnalytics();
|
||||
const flowStartedAt = useRef<number | null>(null);
|
||||
|
||||
const availableKinds = useMemo(
|
||||
() => new Set((integrationData ?? []).filter(({ permissions }) => permissions.hasUseAccess).map((i) => i.kind)),
|
||||
[integrationData],
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
@@ -94,6 +161,49 @@ const ItemSelectModalContent = ({
|
||||
});
|
||||
};
|
||||
|
||||
const notifyCreated = (updatedBoard: ReturnType<typeof createItem>, itemId: string, name: string) => {
|
||||
const createdItem = updatedBoard?.items.find((item) => item.id === itemId);
|
||||
if (!createdItem) {
|
||||
showErrorNotification({
|
||||
title: t("item.create.notification.error.title"),
|
||||
message: t("item.create.notification.error.message"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const actualSectionId =
|
||||
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
|
||||
createdItem.layouts[0]?.sectionId;
|
||||
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
|
||||
const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
|
||||
const notificationId = `board-item-created:${itemId}`;
|
||||
showSuccessNotification({
|
||||
id: notificationId,
|
||||
title: t("item.create.notification.success.title"),
|
||||
message: (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm">
|
||||
{wasRelocated
|
||||
? t("item.create.notification.success.relocatedMessage", { name, destination: actualDestination })
|
||||
: t("item.create.notification.success.message", { name })}
|
||||
</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={() => {
|
||||
removeItem({ itemId });
|
||||
notifications.hide(notificationId);
|
||||
}}
|
||||
>
|
||||
{t("common.action.undo")}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
autoClose: 10_000,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAddCustomWidget = async (customWidgetDefinition: NonNullable<typeof customWidgetDefs>[number]) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(`custom:${customWidgetDefinition.id}`);
|
||||
@@ -119,9 +229,15 @@ const ItemSelectModalContent = ({
|
||||
integrationIds: [],
|
||||
},
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: configuredOptions });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
const updatedBoard = createItem({
|
||||
id: itemId,
|
||||
kind: "customApi",
|
||||
integrationIds: [],
|
||||
options: configuredOptions,
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
@@ -141,6 +257,8 @@ const ItemSelectModalContent = ({
|
||||
|
||||
const handleAdd = async (kind: WidgetKind) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
flowStartedAt.current = performance.now();
|
||||
trackSetup("widget-started", { entryPoint: "board", intent: kind });
|
||||
setLoadingSelection(kind);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
@@ -154,35 +272,89 @@ const ItemSelectModalContent = ({
|
||||
ensureDataAsync: ensureIntegrationDataAsync,
|
||||
});
|
||||
|
||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||
const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity;
|
||||
const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
const openEditor = (
|
||||
availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[],
|
||||
) => {
|
||||
const selectedIntegrationIds = availableIntegrations
|
||||
.slice(0, maxIntegrations)
|
||||
.map((integration) => integration.id);
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds: selectedIntegrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
const updatedBoard = createItem({
|
||||
id: itemId,
|
||||
kind,
|
||||
options,
|
||||
integrationIds: newIntegrationIds,
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
|
||||
trackSetup("widget-completed", {
|
||||
entryPoint: "board",
|
||||
intent: kind,
|
||||
outcome: "completed",
|
||||
elapsedMs: flowStartedAt.current ? Math.round(performance.now() - flowStartedAt.current) : undefined,
|
||||
});
|
||||
},
|
||||
integrationData: availableIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
const integrationsRequired =
|
||||
hasIntegrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false);
|
||||
|
||||
if (integrationsRequired && matchingIntegrationCount === 0) {
|
||||
trackSetup("dependency-blocked", {
|
||||
entryPoint: "board",
|
||||
intent: kind,
|
||||
outcome: "blocked",
|
||||
canResolveInline: canCreateIntegration,
|
||||
});
|
||||
if (!canCreateIntegration) {
|
||||
showErrorNotification({
|
||||
title: t("item.create.missingIntegration.title"),
|
||||
message: t("item.create.missingIntegration.noPermission"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
actions.closeModal();
|
||||
openIntegrationModal({
|
||||
allowedKinds: (definition.supportedIntegrations ?? []).filter((integration) => integration !== "mock"),
|
||||
onSuccess: (result) => {
|
||||
if (result) {
|
||||
trackSetup("dependency-resolved-inline", {
|
||||
entryPoint: "board",
|
||||
intent: kind,
|
||||
outcome: "continued",
|
||||
canResolveInline: true,
|
||||
});
|
||||
openEditor([result.integration]);
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createItem({ id: itemId, kind, integrationIds });
|
||||
actions.closeModal();
|
||||
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
updateItemOptions({ itemId, newOptions: options });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||
},
|
||||
integrationData: matchingIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
},
|
||||
);
|
||||
openEditor(matchingIntegrations);
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
@@ -204,6 +376,18 @@ const ItemSelectModalContent = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{placementOptions.length > 1 && (
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<Select
|
||||
label={t("item.create.destination.label")}
|
||||
description={t("item.create.destination.description")}
|
||||
data={placementOptions}
|
||||
value={targetSectionId}
|
||||
onChange={setTargetSectionId}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
@@ -215,7 +399,11 @@ const ItemSelectModalContent = ({
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
|
||||
connectionStatus={getWidgetConnectionStatus({
|
||||
supportedIntegrations: item.supportedIntegrations,
|
||||
availableKinds,
|
||||
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -228,13 +416,15 @@ const ItemSelectModalContent = ({
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<Card
|
||||
<CatalogItem
|
||||
key={def.id}
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden" }}
|
||||
height={selectGridCardHeight}
|
||||
label={def.name}
|
||||
status={t("item.create.connectionStatus.noConnectionRequired")}
|
||||
disabled={loadingSelection !== null}
|
||||
busy={loadingSelection === `custom:${def.id}`}
|
||||
onSelect={() => void handleAddCustomWidget(def)}
|
||||
onFocus={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
||||
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
@@ -251,30 +441,9 @@ const ItemSelectModalContent = ({
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
<ConnectionStatusBadge status="noConnectionRequired" />
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => void handleAddCustomWidget(def)}
|
||||
variant="light"
|
||||
size="xs"
|
||||
fullWidth
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === `custom:${def.id}`}
|
||||
>
|
||||
{t("item.create.addToBoard")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</CatalogItem>
|
||||
))}
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
@@ -296,11 +465,13 @@ const ItemSelectModalFrame = ({
|
||||
innerProps,
|
||||
}: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: { boardId: string };
|
||||
innerProps: { boardId: string; initialIntegrationKind?: IntegrationKind; initialWidgetKind?: WidgetKind };
|
||||
}) => {
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const canCreateIntegration = session?.user.permissions.includes("integration-create") ?? false;
|
||||
const t = useI18n();
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: innerProps.boardId },
|
||||
@@ -314,13 +485,23 @@ const ItemSelectModalFrame = ({
|
||||
customWidgetDefs={customWidgetDefs}
|
||||
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
|
||||
isAdmin={isAdmin}
|
||||
canCreateIntegration={canCreateIntegration}
|
||||
initialSearch={
|
||||
innerProps.initialWidgetKind
|
||||
? t(`widget.${innerProps.initialWidgetKind}.name`)
|
||||
: innerProps.initialIntegrationKind
|
||||
? getIntegrationName(innerProps.initialIntegrationKind)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ItemSelectModal = createModal<{ boardId: string }>((props) => (
|
||||
<ItemSelectModalFrame {...props} />
|
||||
)).withOptions({
|
||||
export const ItemSelectModal = createModal<{
|
||||
boardId: string;
|
||||
initialIntegrationKind?: IntegrationKind;
|
||||
initialWidgetKind?: WidgetKind;
|
||||
}>((props) => <ItemSelectModalFrame {...props} />).withOptions({
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
@@ -331,7 +512,7 @@ const WidgetItem = ({
|
||||
onIntent,
|
||||
disabled,
|
||||
loading,
|
||||
hasMatchingIntegration,
|
||||
connectionStatus,
|
||||
}: {
|
||||
item: {
|
||||
kind: WidgetKind;
|
||||
@@ -344,24 +525,21 @@ const WidgetItem = ({
|
||||
onIntent: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
hasMatchingIntegration: boolean;
|
||||
connectionStatus: WidgetConnectionStatus;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const statusLabel = t(`item.create.connectionStatus.${connectionStatus}`);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: hasMatchingIntegration ? 2 : undefined,
|
||||
}}
|
||||
<CatalogItem
|
||||
height={selectGridCardHeight}
|
||||
label={item.name}
|
||||
status={statusLabel}
|
||||
onFocus={onIntent}
|
||||
onPointerEnter={onIntent}
|
||||
aria-busy={loading || undefined}
|
||||
onSelect={onSelect}
|
||||
disabled={disabled}
|
||||
busy={loading}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
@@ -375,24 +553,21 @@ const WidgetItem = ({
|
||||
{item.description}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<ConnectionStatusBadge status={connectionStatus} />
|
||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</CatalogItem>
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
|
||||
const t = useI18n();
|
||||
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
|
||||
|
||||
return (
|
||||
<Badge variant="light" color={color} size="xs" tt="none" w="fit-content" maw="100%">
|
||||
{t(`item.create.connectionStatus.${status}`)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -405,7 +580,7 @@ const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind
|
||||
const moreCount = integrations.length - countToShow;
|
||||
|
||||
return (
|
||||
<Group gap={2} mt="auto">
|
||||
<Group gap={2}>
|
||||
<Tooltip.Group closeDelay={100}>
|
||||
<Group gap={2}>
|
||||
{integrations.slice(0, countToShow).map((integration) => (
|
||||
|
||||
@@ -6,10 +6,15 @@ import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||
|
||||
let widgetEditModalPromise: Promise<typeof import("@homarr/widgets/modals")> | undefined;
|
||||
import type * as IntegrationEditModalModule from "~/components/integration/embedded-integration-edit-modal";
|
||||
import type { EmbeddedIntegrationEditModalProps } from "~/components/integration/embedded-integration-edit-modal";
|
||||
|
||||
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
|
||||
|
||||
export const loadWidgetEditModal = () => {
|
||||
if (widgetEditModalPromise) return widgetEditModalPromise;
|
||||
@@ -24,6 +29,59 @@ export const loadWidgetEditModal = () => {
|
||||
|
||||
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
|
||||
|
||||
let integrationEditModalPromise: Promise<typeof IntegrationEditModalModule> | undefined;
|
||||
|
||||
const loadIntegrationEditModal = () => {
|
||||
if (integrationEditModalPromise) return integrationEditModalPromise;
|
||||
|
||||
const currentPromise = import("~/components/integration/embedded-integration-edit-modal");
|
||||
integrationEditModalPromise = currentPromise;
|
||||
void currentPromise.catch(() => {
|
||||
if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined;
|
||||
});
|
||||
return currentPromise;
|
||||
};
|
||||
|
||||
const LazyIntegrationEditModalContent = (props: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: EmbeddedIntegrationEditModalProps;
|
||||
}) => {
|
||||
const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal());
|
||||
const Component = EmbeddedIntegrationEditModal.component;
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>((props) => (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
integrationEditModalPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LazyIntegrationEditModalContent {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)).withOptions({
|
||||
defaultTitle: (t) => t("item.edit.tab.integration"),
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
|
||||
interface LazyWidgetEditModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: WidgetEditModalProps<WidgetKind>;
|
||||
@@ -32,7 +90,20 @@ interface LazyWidgetEditModalContentProps {
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const Component = WidgetEditModal.component;
|
||||
return <Component {...props} />;
|
||||
const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal);
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
innerProps={{
|
||||
...props.innerProps,
|
||||
onEditIntegration: (integrationId) =>
|
||||
openIntegrationEditModal({
|
||||
integrationId,
|
||||
onSuccess: props.innerProps.onIntegrationSaved,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>((props) => (
|
||||
@@ -65,4 +136,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -178,8 +178,11 @@ export const WidgetContextMenu = ({
|
||||
),
|
||||
}));
|
||||
},
|
||||
integrationData: integrationData.filter((integration) =>
|
||||
definition.supportedIntegrations?.some((kind) => kind === integration.kind),
|
||||
onIntegrationSaved: handleRefetch,
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
definition.supportedIntegrations?.some((kind) => kind === integration.kind) &&
|
||||
integration.permissions.hasUseAccess,
|
||||
),
|
||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||
settings,
|
||||
@@ -192,6 +195,7 @@ export const WidgetContextMenu = ({
|
||||
definition,
|
||||
integrationData,
|
||||
item,
|
||||
handleRefetch,
|
||||
openModal,
|
||||
persistBoard,
|
||||
settings,
|
||||
@@ -227,12 +231,7 @@ export const WidgetContextMenu = ({
|
||||
closeMenuOnClick
|
||||
leftSection={<IconMaximize size={16} />}
|
||||
onClick={() => {
|
||||
if (sourceRef.current)
|
||||
openAdvancedFocus(item.id, sourceRef.current, {
|
||||
restoreFocusTarget:
|
||||
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ??
|
||||
sourceRef.current,
|
||||
});
|
||||
if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current);
|
||||
}}
|
||||
>
|
||||
{t("item.advancedFocus.open")}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "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 { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { useItemActions } from "./items/item-actions";
|
||||
import { ItemSelectModal } from "./items/item-select-modal";
|
||||
import { useContainerActions } from "./sections/container/container-actions";
|
||||
|
||||
export const useBoardAddActions = () => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addContainer } = useContainerActions();
|
||||
const { createItem } = useItemActions();
|
||||
const canCreateApp = session?.user.permissions.includes("app-create") ?? false;
|
||||
const canConnectService = session?.user.permissions.includes("integration-create") ?? false;
|
||||
const addWidget = useCallback(() => openItemSelectModal({ boardId: board.id }), [board.id, openItemSelectModal]);
|
||||
const addApp = useCallback(
|
||||
() =>
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
|
||||
withCreate: canCreateApp,
|
||||
}),
|
||||
[canCreateApp, createItem, openAppSelectModal],
|
||||
);
|
||||
const connectService = useCallback(
|
||||
() => openIntegrationSelectModal({ completionBoardId: board.id }),
|
||||
[board.id, openIntegrationSelectModal],
|
||||
);
|
||||
|
||||
return {
|
||||
addWidget,
|
||||
addApp,
|
||||
connectService,
|
||||
addContainer,
|
||||
canConnectService,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { SetupMetricProperties } from "./setup-analytics";
|
||||
|
||||
describe("SetupMetricProperties", () => {
|
||||
test("keeps the measurement contract coarse and configuration-free", () => {
|
||||
const allowedKeys = [
|
||||
"entryPoint",
|
||||
"intent",
|
||||
"outcome",
|
||||
"elapsedMs",
|
||||
"hasBoardContext",
|
||||
"canResolveInline",
|
||||
] satisfies (keyof SetupMetricProperties)[];
|
||||
type ExpectedKey = (typeof allowedKeys)[number];
|
||||
type ActualKey = keyof SetupMetricProperties;
|
||||
const exactContract: [ActualKey] extends [ExpectedKey]
|
||||
? [ExpectedKey] extends [ActualKey]
|
||||
? true
|
||||
: false
|
||||
: false = true;
|
||||
|
||||
expect(exactContract).toBe(true);
|
||||
expect(allowedKeys).not.toContain("url");
|
||||
expect(allowedKeys).not.toContain("query");
|
||||
expect(allowedKeys).not.toContain("recordId");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export type SetupMetricEvent =
|
||||
| "surface-opened"
|
||||
| "intent-selected"
|
||||
| "widget-started"
|
||||
| "dependency-blocked"
|
||||
| "dependency-resolved-inline"
|
||||
| "widget-completed"
|
||||
| "completion-recipe-selected"
|
||||
| "checklist-resumed";
|
||||
|
||||
export type SetupMetricEntryPoint = "header" | "spotlight" | "board" | "assistant" | "docker" | "management";
|
||||
|
||||
export interface SetupMetricProperties {
|
||||
entryPoint: SetupMetricEntryPoint;
|
||||
intent?: string;
|
||||
outcome?: "completed" | "blocked" | "continued";
|
||||
elapsedMs?: number;
|
||||
hasBoardContext?: boolean;
|
||||
canResolveInline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits only coarse workflow measurements through Homarr's existing opt-in analytics gate.
|
||||
* Never pass record IDs, names, URLs, search text, credentials, or provider responses here.
|
||||
*/
|
||||
export const useSetupAnalytics = () => {
|
||||
const t = useI18n();
|
||||
const { mutate } = clientApi.analytics.trackFeature.useMutation({
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: t("universalCreate.analyticsError"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return useCallback(
|
||||
(event: SetupMetricEvent, properties: SetupMetricProperties) => {
|
||||
mutate({ feature: `setup:${event}`, properties: { ...properties } });
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { RankedUniversalCreateAction } from "./universal-create-actions";
|
||||
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
|
||||
|
||||
describe("getUniversalCreateActionDefinitions", () => {
|
||||
test("only exposes actions allowed without a board context", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: false,
|
||||
permissions: ["board-create", "integration-create"],
|
||||
});
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]);
|
||||
});
|
||||
|
||||
test("places globally-created apps in the library group", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: false, permissions: ["app-create"] });
|
||||
|
||||
expect(actions).toEqual([expect.objectContaining({ key: "app", group: "library" })]);
|
||||
});
|
||||
|
||||
test("keeps board content actions available without global create permissions", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: true, permissions: [] });
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual(["widget", "app", "container"]);
|
||||
});
|
||||
|
||||
test("exposes the complete surface to administrators on a board", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: true,
|
||||
permissions: ["admin", "app-create", "integration-create", "board-create"],
|
||||
});
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual([
|
||||
"widget",
|
||||
"app",
|
||||
"integration",
|
||||
"container",
|
||||
"board",
|
||||
"workshop",
|
||||
"customWidget",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterAndRankUniversalCreateActions", () => {
|
||||
const actions = [
|
||||
{ key: "widget", group: "currentBoard", priority: 100, name: "Add widget", description: "Add content" },
|
||||
{
|
||||
key: "integration",
|
||||
group: "library",
|
||||
priority: 80,
|
||||
name: "Connect service",
|
||||
description: "Create an integration",
|
||||
keywords: ["provider"],
|
||||
},
|
||||
{ key: "board", group: "boards", priority: 60, name: "Create board", description: "Start a dashboard" },
|
||||
] satisfies RankedUniversalCreateAction[];
|
||||
|
||||
test("uses product priority when no search is entered", () => {
|
||||
expect(filterAndRankUniversalCreateActions(actions, "").map(({ key }) => key)).toEqual([
|
||||
"widget",
|
||||
"integration",
|
||||
"board",
|
||||
]);
|
||||
});
|
||||
|
||||
test("ranks a name prefix ahead of a description or keyword match", () => {
|
||||
const results = filterAndRankUniversalCreateActions(actions, "create");
|
||||
|
||||
expect(results.map(({ key }) => key)).toEqual(["board", "integration"]);
|
||||
});
|
||||
|
||||
test("matches keywords and removes unrelated actions", () => {
|
||||
expect(filterAndRankUniversalCreateActions(actions, "provider").map(({ key }) => key)).toEqual(["integration"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
export const universalCreateActionKeys = [
|
||||
"widget",
|
||||
"app",
|
||||
"integration",
|
||||
"container",
|
||||
"board",
|
||||
"workshop",
|
||||
"customWidget",
|
||||
] as const;
|
||||
|
||||
export type UniversalCreateActionKey = (typeof universalCreateActionKeys)[number];
|
||||
export type UniversalCreateActionGroup = "currentBoard" | "library" | "boards";
|
||||
|
||||
export interface UniversalCreateContext {
|
||||
hasBoardContext: boolean;
|
||||
permissions: readonly GroupPermissionKey[];
|
||||
}
|
||||
|
||||
interface UniversalCreateActionDefinition {
|
||||
key: UniversalCreateActionKey;
|
||||
group: UniversalCreateActionGroup;
|
||||
priority: number;
|
||||
isVisible: (context: UniversalCreateContext) => boolean;
|
||||
}
|
||||
|
||||
const hasPermission = (context: UniversalCreateContext, permission: GroupPermissionKey) =>
|
||||
context.permissions.includes(permission);
|
||||
|
||||
const definitions: readonly UniversalCreateActionDefinition[] = [
|
||||
{
|
||||
key: "widget",
|
||||
group: "currentBoard",
|
||||
priority: 100,
|
||||
isVisible: ({ hasBoardContext }) => hasBoardContext,
|
||||
},
|
||||
{
|
||||
key: "app",
|
||||
group: "currentBoard",
|
||||
priority: 90,
|
||||
isVisible: (context) => context.hasBoardContext || hasPermission(context, "app-create"),
|
||||
},
|
||||
{
|
||||
key: "integration",
|
||||
group: "library",
|
||||
priority: 80,
|
||||
isVisible: (context) => hasPermission(context, "integration-create"),
|
||||
},
|
||||
{
|
||||
key: "container",
|
||||
group: "currentBoard",
|
||||
priority: 70,
|
||||
isVisible: ({ hasBoardContext }) => hasBoardContext,
|
||||
},
|
||||
{
|
||||
key: "board",
|
||||
group: "boards",
|
||||
priority: 60,
|
||||
isVisible: (context) => hasPermission(context, "board-create"),
|
||||
},
|
||||
{
|
||||
key: "workshop",
|
||||
group: "library",
|
||||
priority: 40,
|
||||
isVisible: (context) => hasPermission(context, "admin"),
|
||||
},
|
||||
{
|
||||
key: "customWidget",
|
||||
group: "library",
|
||||
priority: 30,
|
||||
isVisible: (context) => hasPermission(context, "admin"),
|
||||
},
|
||||
];
|
||||
|
||||
export interface RankedUniversalCreateAction {
|
||||
key: UniversalCreateActionKey;
|
||||
group: UniversalCreateActionGroup;
|
||||
priority: number;
|
||||
name: string;
|
||||
description: string;
|
||||
keywords?: readonly string[];
|
||||
}
|
||||
|
||||
export const getUniversalCreateActionDefinitions = (context: UniversalCreateContext) =>
|
||||
definitions
|
||||
.filter((definition) => definition.isVisible(context))
|
||||
.map((definition) =>
|
||||
definition.key === "app" && !context.hasBoardContext ? { ...definition, group: "library" as const } : definition,
|
||||
);
|
||||
|
||||
const normalize = (value: string) => value.trim().toLocaleLowerCase();
|
||||
|
||||
export const filterAndRankUniversalCreateActions = <TAction extends RankedUniversalCreateAction>(
|
||||
actions: readonly TAction[],
|
||||
query: string,
|
||||
) => {
|
||||
const normalizedQuery = normalize(query);
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const name = normalize(action.name);
|
||||
const searchableText = normalize([action.name, action.description, ...(action.keywords ?? [])].join(" "));
|
||||
const relevance =
|
||||
normalizedQuery.length === 0
|
||||
? 0
|
||||
: name === normalizedQuery
|
||||
? 300
|
||||
: name.startsWith(normalizedQuery)
|
||||
? 200
|
||||
: searchableText.includes(normalizedQuery)
|
||||
? 100
|
||||
: -1;
|
||||
|
||||
return { action, relevance };
|
||||
})
|
||||
.filter(({ relevance }) => relevance >= 0)
|
||||
.toSorted((left, right) => right.relevance - left.relevance || right.action.priority - left.action.priority)
|
||||
.map(({ action }) => action);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
.action {
|
||||
width: 100%;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.action:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user