Compare commits

..
Author SHA1 Message Date
Thomas Camlong d150285165 feat(board): dock the setup checklist to the bottom-left 2026-08-18 09:38:14 +02:00
Thomas Camlong 679cede4ae ci: retry status check and read contents for gh api 2026-08-17 17:33:15 +02:00
Thomas Camlong bd9e0f7565 ci: stabilize unit and status-check workflows
- Stub fetch in the icon-url unit test so it no longer depends on the
  network (jsDelivr 404s in CI)
- Retry the gh api status POST in skip-stability-days (gh CLI
  occasionally 502s on the statuses endpoint)
2026-08-17 16:50:02 +02:00
Thomas Camlong c6d6f43c85 feat(board): remove live widget preview, streamline integration editing
- Drop the narrow live preview panel from the widget picker (it kept
  rendering oversized widgets with errors) along with its translations
- The Edit integration tab now opens the connection editor directly when
  a widget has exactly one integration
- The connection editor closes on outside click
- Keep the save/cancel footer floating at the bottom of the editor
2026-08-17 16:21:44 +02:00
Thomas Camlong 482a8c20e3 test(e2e): make lazy-widgets spec resilient to parallel load
The clock widget can be rendered transiently twice while the board
hydrates under 4-worker parallel load. Pin the assertion to a visible
instance and give the post-login redirect a longer window.
2026-08-17 15:30:56 +02:00
Thomas Camlong f1295f4d28 ci: gate e2e job on fast-gate success
Resolve the audit blocker: the e2e (container) job must not run when the
fast gate failed. It stays independent of the preview build -- when the
amd64 digest is missing it falls back to a local build for forks, merge
groups and validate dispatches.
2026-08-17 13:41:55 +02:00
Thomas Camlong 46461136a0 ci: skip e2e job when the run is cancelled 2026-08-17 13:34:57 +02:00
Thomas Camlong fe657fd79d ci: reuse amd64 preview image for e2e instead of rebuilding
The container (e2e) job now depends on the amd64 preview job and pulls
the pushed digest, retagging it as homarr-e2e, so the image is compiled
once. Falls back to a local build for forks and merge queues where the
preview job does not run.
2026-08-17 13:31:42 +02:00
Thomas Camlong f6d89f03b2 ci: build and publish amd64 preview independently of e2e
The amd64 digest build moves to its own job that runs right after the
fast gate, so the homarr-test image ships even while (or if) e2e is
still running or fails. The container job is now e2e-only with its own
docker cache scope.
2026-08-17 13:24:11 +02:00
Thomas Camlong b2ff7c1011 ci: publish preview image before e2e and parallelize tests
- Push the amd64 preview image digest upfront so a failing E2E can no longer block PR preview publication
- Run publish-preview with always() so the multi-platform manifest still ships when container job fails
- Enable vitest file parallelism for e2e and raise workers to 4 (drop retries to 1)
- Skip obsolete SQLite onboarding restore test
2026-08-17 13:01:49 +02:00
Thomas Camlong 510845d3b9 Merge remote-tracking branch 'origin/release/v2' into feat/pr6582-nice-changes-release
# Conflicts:
#	apps/nextjs/src/components/board/items/item-select-modal.tsx
2026-08-17 12:29:50 +02:00
Thomas Camlong 6d6815a901 fix(board): fit preview to panel, keep app selection on search, keep checklist under modals
- Scale oversized widget previews to fit the 320px panel
- Derive selected apps from all apps so search doesn't drop selections
- Drop checklist Affix z-index override so modals stay on top
- Always render preview panel (placeholder state) and add custom widget previews
- Hide preview column below lg breakpoint to avoid crushing the grid
2026-08-17 11:22:23 +02:00
Thomas Camlong f10e49fa20 feat(board): add widget preview, floating checklist and multi-select apps
- Show a live widget preview panel next to the grid in the add-widget modal,
  sizing from the widget default size and using matching integrations
- Pin the board setup checklist below the app header with Affix
- Allow selecting and adding multiple apps at once from the board
2026-08-17 09:03:07 +02:00
Thomas Camlong 8ecdd13cd5 feat: port release-v2 experience improvements from PR #6582
Brings the semantically-correct unification work from the closed
agent/release-v2-experience PR into release/v2:

- universal create surface (header, board, spotlight) with permission-aware actions
- board empty state and setup checklist with widget/connection recipes
- in-widget integration editing via inspector modal, preserving unsaved widget settings
- single-source widget-integration capability map (nativeFeatureCapabilities)
- multi-endpoint Docker singleton with per-endpoint failure isolation, DOCKER_ENDPOINTS descriptors and service reconciliation
- Kubernetes multi-context registry with availability probing and context selector
- response-contract test harness and setup analytics property boundaries

Excludes the feature-platform generator/workbench tooling (deferred).
2026-08-16 17:30:13 +02:00
201 changed files with 2097 additions and 11267 deletions

No files matched your search

-3
View File
@@ -92,6 +92,3 @@ homarr-backup*.zip
*.sqlite-wal
*.sqlite-shm
/scripts/benchmarks/
# PR description screenshots belong on the pull request, not in the repository
.screenshots/
@@ -58,12 +58,12 @@ A random secret can be generated by using the following command: `openssl rand -
## Docker
| Environment Variable | Description | Possible values | Default |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
| `DOCKER_SOCKET_PATHS` | Comma-separated mounted Docker-compatible socket paths. Homarr discovers each path as a selectable Docker environment | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
| `DOCKER_HOSTNAMES` | Comma-separated plaintext TCP hostnames. Pair each hostname with the port in the same position in `DOCKER_PORTS` | For example `localhost,docker.example.com` | - |
| `DOCKER_PORTS` | Comma-separated plaintext TCP ports paired with `DOCKER_HOSTNAMES` | For example `2375,2376` | - |
| `DOCKER_ENDPOINTS` | Optional advanced JSON configuration for custom names, TLS, or restricted capabilities. It takes precedence over the simple variables above. See the [Docker and Podman connection guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
| 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
+16 -31
View File
@@ -24,11 +24,12 @@ import { dockerContainersWidget } from "@site/docs/widgets/docker-containers";
]}
/>
## Connecting Docker to Homarr
## Enabling the Docker integration
Docker connections come directly from Homarr's environment variables. They are automatically available in the Docker management table and Docker widget; you do not create a Docker integration under **Management → Integrations**.
If Homarr runs directly on a host with Docker installed, it uses `/var/run/docker.sock` by default. When Homarr runs in a container, mount the [Docker socket](https://docs.docker.com/engine/security/protect-access/) into it so Homarr can interact with Docker.
This integration must first be enabled.
If you directly run on the host system and you have docker installed, it will be enabled by default.
Otherwise, you'll need to mount the [docker socket](https://docs.docker.com/engine/security/protect-access/) to the Homarr container,
so Homarr can interact with the Docker service.
In Docker Compose, you can simply add this line:
@@ -121,23 +122,7 @@ services:
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
```
For remote socket proxies, list the hostnames and matching ports in the same order:
```yaml
environment:
DOCKER_HOSTNAMES: docker-proxy-home,docker-proxy-lab
DOCKER_PORTS: 2375,2375
```
Homarr automatically discovers each comma-separated entry as a separate Docker environment. All environments are selected by default; the Docker widget lets you select one or more without any additional setup.
:::warning[Plaintext TCP]
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy on a private Docker network, or TLS.
:::
### Advanced endpoint configuration
Most users should use the comma-separated variables above. Use `DOCKER_ENDPOINTS` only when you need custom names, TLS, or restricted capabilities. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
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:
@@ -178,12 +163,16 @@ Supported transports are:
- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS
- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true`
The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`. When `DOCKER_ENDPOINTS` is set, it takes precedence over `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS`.
:::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 collapsed **Assisted Docker setup** helper optionally compares visible containers with your existing Homarr apps and integrations. Open it only when you want setup suggestions; it never blocks normal container management. Every service stays distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
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:
@@ -192,15 +181,15 @@ Homarr can show a service as:
- **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
Set the usual server address once, then choose whether services normally use different ports, subdomains, or paths. Homarr uses that pattern to prefill every detected service. Each suggestion has one editable URL field with the generated and Docker-reported addresses available as autocomplete options, so you can select a suggestion or type any address directly.
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. **Set up integration** opens the standard prefilled integration form, while **Create app** opens the existing Docker app form, so you can review and confirm the final setup.
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.
:::
Suggestions appear as a compact task list. Expand an item only when you want to edit its URL or inspect relevant setup statuses. Use the 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 helper. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
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.
Compact status icons summarize the setup information Homarr can act on. Unknown and non-applicable runtime layers are omitted to keep the helper focused; use the integration form's connection test or the relevant board action for live diagnostics.
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.
@@ -227,10 +216,6 @@ This opens a dedicated page with an xterm.js terminal that streams the container
The Docker management table supports optional **CPU usage** and **Memory usage** columns.
These columns are hidden by default and can be toggled on from the table column visibility settings.
#### Choosing Docker environments in the widget
The Docker widget can show one or more configured Docker environments. Leave **Docker environments** empty to include every endpoint, which is also the default for existing widgets. Selecting one or more entries limits container and statistics requests to those endpoints.
#### Add apps from containers
Homarr also lets you add apps from containers. You can simply select all containers you want to add an app for and then click the `Add to Homarr` button.
-32
View File
@@ -72,34 +72,6 @@ API key authentication enables programmatic access for:
- Custom dashboards and monitoring tools
- Container orchestration systems that need to sync app definitions
## Permissions
API keys act as the user that created them, so every request is subject to that user's group
permissions. A call that the user is not allowed to make returns `FORBIDDEN`, and list endpoints
return only the records the user may see rather than failing.
### App endpoints require app management access
`GET /api/apps`, `GET /api/apps/paginated` and `GET /api/apps/search` (tRPC `app.all`,
`app.getPaginated`, `app.search`) require the **modify all apps** permission. They previously
returned the full app catalogue — including internal URLs — to any authenticated user.
If a script that used to work now returns `FORBIDDEN`, grant **modify all apps** to a group the key
owner belongs to, or switch to `GET /api/apps/selectable`, which returns the reduced fields used by
board pickers.
### Integration endpoints are filtered per key
`integration.all`, `integration.allThatSupportSearch`, `integration.allOfGivenCategory` and
`integration.search` filter server side. Without a global integration permission, a key only
receives the integrations its owner has been granted access to, so responses may contain fewer
records than before rather than returning an error. `integration.search` applies its `limit` after
filtering, so a scoped key still receives a full page of results.
Each returned integration carries a `permissions` object — check `hasUseAccess` before reading data
and `hasInteractAccess` before performing actions; `false` means the key owner lacks that level for
that integration, not that the call failed.
## Board Automation
The OpenAPI and MCP APIs include board management endpoints for common automation workflows:
@@ -138,10 +110,6 @@ Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.resta
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different
Docker or Podman endpoints from targeting the wrong service.
`docker.getContainers` also accepts an optional `endpointIds` array. Omit it or pass an empty array to query every configured
Docker environment, or pass one or more IDs returned by `docker.getEndpoints` to query only those Dockerode clients. The
Docker widget uses the same behavior for its environment selector.
## Partial integration failures
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
+2 -13
View File
@@ -51,17 +51,6 @@ Note that the ping URL only accepts `http://` and `https://` URLs — custom URI
## List of apps
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app
directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
### Accessing the page
The `/manage/apps` page is reachable when you can manage all apps (`app-modify-all`) or create apps (`app-create`):
- **Manage all (`app-modify-all`)**: the full list of apps is shown and searchable, and each row can be edited.
- **Create only (`app-create`)**: the page shows an empty "create an app" state instead of the list, so the names and
URLs of existing apps are never disclosed. The create action is still available.
- **Neither**: the page is not reachable and returns a not-found page.
Creating an app requires `app-create`, editing requires `app-modify-all`, and deleting requires `app-full-all`;
board permissions are not required.
Any signed-in user can view this list. Creating, editing, and deleting apps requires the corresponding app permission; board permissions are not required.
+1 -1
View File
@@ -205,7 +205,7 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
/>
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
Spotlight. It lists the available actions directly and can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
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.
@@ -17,24 +17,6 @@ To create an integration, choose **New integration** and select the supported ap
The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use.
### Accessing the page
The `/manage/integrations` page is reachable when at least one of the following applies:
- you can manage all integrations (global `integration-full-all`),
- you can create integrations (`integration-create`), or
- you have been granted full access to at least one specific integration.
What the page shows depends on the case:
- **Manage all (`integration-full-all`)**: the full list of integrations is shown.
- **Create only (`integration-create`)**: the page shows an empty "create an integration" state instead of the list, with the create action still available.
- **Delegated (full access on one or more)**: only the integrations you have full access to are listed, above a note making clear that the view is scoped to what you can manage.
- **None**: the page is not reachable and returns a not-found page.
Where you have a mix of access, the list combines the integrations you can manage. Integration lists are filtered
server side, so a user never receives data about integrations they lack access to.
Next, you'll need to fill out the required fields to configure the integration:
![](./img/form-create-integration.png)
@@ -81,9 +63,9 @@ When you connect a service from a board, Homarr keeps you in the same setup flow
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 directly in the **Edit widget → Edit integration**
tab 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.
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.
+12 -70
View File
@@ -163,77 +163,19 @@ Members can be removed from the group using the remove button at the end:
#### Permissions tab
In this tab, you manage what permissions the members of this group will have.
Permissions are shown as a matrix with one row per category (boards, apps, integrations, and so on). Each row has a
segmented control that lets you pick a single **access level**, plus a checkbox for each **capability** the category
has.
Currently, the following permissions are available:
Levels in a category are cumulative: selecting a level grants that level and every level below it in the same
category. Choosing **No access** grants no access level for that category.
##### Capabilities are separate from the access level
A capability is something you can grant on its own, independently of how much of everyone else's data the members
can see. Creating is the usual example: you can let members create their own boards without letting them view or
modify anyone else's boards. Apps additionally have **use all apps**, which lets members put any app on a board
without being able to edit apps.
Some access levels already include a capability through permission inheritance (marked in the table below); the
checkbox is then ticked, disabled, and explains which level includes it.
##### Administration
Selecting the **Administrator** level grants full access to everything. While it is selected, every other category
is disabled and explains that administrators already have that access. Setting the administrator row back to
**No access** re-enables the other categories with the selections they had before.
##### Access levels
The access levels below are cumulative, in ascending order, with the category's capabilities listed separately:
| Category | Access levels (ascending) | Capabilities | Already included at |
| -------------- | ---------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------- |
| Boards | view all boards → modify all boards → full board access | create boards | full board access |
| Apps | modify all apps → full app access | use all apps, create apps | create apps: modify all apps and full app access; use all apps: full app access |
| Integrations | use all integrations → interact with any integration → full integration access | create integrations | full integration access |
| Search engines | modify all search engines → full search engine access | create search engines | modify all search engines, full search engine access |
| Medias | view all medias → full media access | upload medias | full media access |
| Other | view logs | — | — |
| Administrator | administrator | — | — |
Apps are the one category where "use" is not a level. Being able to modify every app does **not** imply being able
to put any app on a board — only full app access grants both — so the two are kept independent rather than stacked,
and picking **Modify** never silently grants use of every app.
The levels grant the following:
- **Boards**: view all boards; modify all boards (not access control and danger zone); full board access (view, modify and delete every board, including access control and the danger zone).
- **Apps**: modify all apps; full app access (manage, use and delete any app).
- **Integrations**: use all integrations; interact with any integration; full integration access (manage, use and interact with any integration).
- **Search engines**: modify all search engines; full search engine access (manage and delete any search engine).
- **Medias**: view all medias; full media access (manage and delete any media).
- **Other**: view logs.
##### Presets
Above the rows you can apply a preset to fill in a common configuration in one click:
- **Viewer**: read-only - view all boards and medias, use any app on a board, and use existing integrations.
- **Editor**: day-to-day management - modify all boards and apps, create boards and integrations, interact with any integration, modify search engines, and upload medias, without deleting resources or managing access.
- **Admin**: grants the administrator permission (full access to everything).
When the current selection does not match any preset, a **Custom** badge is shown.
##### Also grants
Some levels include additional permissions automatically. When a level implies extra permissions (for example,
modifying all apps also allows creating apps), a dimmed **Also grants: …** hint is shown below the category row,
listing the implied permissions.
##### Effective permissions
Below the rows, the **Effective permissions** panel can be expanded to review everything members of this group will
be able to do - the full, de-duplicated set of permissions, including everything granted indirectly through the
levels - grouped by category, with a count for each.
- Boards
- **Create boards**: Allow members to create boards
- **View all boards**: Allow members to view all boards
- **Modify all boards**: Allow members to modify all boards (Does not include access control and danger zone)
- **Full board access**: Allow members to view, modify, and delete all boards (Including access control and danger zone)
- Integrations
- **Create integrations**: Allow members to create integrations
- **Use all integrations**: Allows members to add any integrations to their boards
- **Interact with any integration**: Allow members to interact with any integration
- **Full integration access**: Allow members to manage, use and interact with any integration
- **Administrator**: Members with this permission have full access to all features and settings
Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved
for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change
@@ -1,47 +0,0 @@
---
title: "Air Quality & UV"
description: "Displays air quality, ultraviolet index, pollutants and pollen for a location."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
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 { airQualityWidget } from ".";
<WidgetHeader widget={airQualityWidget} categories={["Health", "Weather"]} />
The Air Quality & UV widget shows the current air quality index for a selected location. It adds the UV index,
dominant pollutant, particulate matter and the highest available pollen reading as space allows. Open the advanced
view for a 24-hour AQI and UV chart, all available pollutant and pollen readings, and seven-day daily maxima.
Select the European or US AQI scale, or use the automatic setting. Automatic uses the US scale for viewers whose
locale region is the United States and the European scale everywhere else. The selected scale stays in widget options
instead of being repeated beside every reading. Pollen values are individual measured concentrations; the widget does
not combine them into a single pollen index.
The categories and colors summarize published index bands and are not medical advice. Pollutant concentrations use
micrograms per cubic meter (`µg/m³`), and pollen uses grains per cubic meter.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={airQualityWidget.configuration} />
### Data source
Homarr uses the free Open-Meteo Air Quality API. The widget refreshes active data every 15 minutes and requests a
seven-day forecast for European AQI, US AQI, UV, particulate matter, ozone, nitrogen dioxide, sulphur dioxide, carbon
monoxide, and available alder, birch, grass, mugwort, olive and ragweed pollen. Identical location requests reuse the
server response for 15 minutes. If a refresh fails, Homarr can keep a recent successful response visible for up to 24
hours instead of replacing it with an empty state.
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests. If you need to allowlist the
service, use `https://air-quality-api.open-meteo.com`.
@@ -1,43 +0,0 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconWind } from "@tabler/icons-react";
export const airQualityWidget: WidgetDefinition = {
icon: IconWind,
name: "Air Quality & UV",
description: "Displays air quality, ultraviolet index, pollutants and pollen for a location.",
path: "../../widgets/air-quality",
configuration: {
items: [
{
name: "Air quality location",
description: "Location used for air quality, UV and pollen data",
values: "Select a location through search or longitude/latitude",
defaultValue: "Paris / 48.85341, 2.3488",
},
{
name: "AQI standard",
description: "Choose the European or US air quality scale, or select one from the viewer's locale",
values: { type: "select", options: ["Automatic for locale", "European AQI", "US AQI"] },
defaultValue: "Automatic for locale",
},
{
name: "Show UV index",
description: "Display the current UV index and its forecast",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show pollutant concentrations",
description: "Display particulate matter and gaseous pollutant concentrations",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show pollen concentrations",
description: "Display available pollen concentrations",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+1 -21
View File
@@ -15,26 +15,6 @@ import { clockWidget } from ".";
The clock widget displays the current date and time in a customizable format.
Advanced view turns it into a World Clock. The configured primary timezone stays prominent while New York, Paris, and
Tokyo are included as secondary defaults. You can add, rename, remove, and reorder up to six cities in widget options;
London, Sydney, and UTC are also available as quick presets.
The digital advanced view keeps the primary timezone prominent and presents every secondary city as a concise,
comparable card. The primary clock includes a segmented 24-hour rail with a current-time marker, elapsed and remaining
time, six-hour ticks, and a legend for night, dawn, day, and dusk. These phases are based on local clock time and are not
astronomical sunrise or sunset calculations. Secondary cards stop after their localized date and timezone offsets.
The compact widget remains a single numeric clock. The time-format selector shows common Day.js presets with live
examples and applies to the primary clock. Choosing a format with seconds enables second-level updates; 12-hour and
24-hour display are selected through those same formats. The date-format selector likewise
shows live Day.js examples and their tokens. Secondary cities always include a short localized date and a
Yesterday, Today, or Tomorrow label relative to the primary timezone. UTC and relative offsets follow current timezone
rules, including daylight-saving changes.
Weather remains optional and uses its own configured location rather than a World Clock city. Its icon is static by
default and can be animated explicitly; reduced-motion preferences always disable animation. An unsupported saved
timezone is identified without preventing valid clocks from rendering.
### Screenshots
import localClock from "./img/local.png";
@@ -47,7 +27,7 @@ import tokyoClock from "./img/tokyo-12-hour.png";
<img src={tokyoClock} alt="clock format-12-hours timezone-tokyo" width={256} height={128} />
</div>
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
+22 -35
View File
@@ -20,6 +20,18 @@ export const clockWidget: WidgetDefinition = {
values: { type: "string" },
defaultValue: "-",
},
{
name: "24-hour format",
description: "Use 24-hour format instead of 12-hour format.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Display seconds",
description: "Whether to show seconds in the clock.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Use fixed timezone",
description: "Select custom timezone to display instead of the client timezone.",
@@ -40,46 +52,21 @@ export const clockWidget: WidgetDefinition = {
},
{
name: "Date format",
description: "Choose a common Day.js date format with a live example and its tokens.",
values: "YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY, or common long and short date formats",
description: "Format of the date displayed below the clock.",
values: "A collection of date formats, e.g. 'DD/MM/YYYY', 'MM/DD/YYYY', etc.",
defaultValue: "dddd, MMMM D",
},
{
name: "Time format",
description:
"Choose a common Day.js time format with a live example. The format controls 12/24-hour display and seconds.",
values: "HH:mm, HH:mm:ss, h:mm A, or h:mm:ss A",
defaultValue: "HH:mm",
name: "Custom time format",
description: "Format of the time displayed in the clock.",
values: 'ISO 8601 format, e.g. "HH:mm:ss", "hh:mm A", etc.',
defaultValue: "-",
},
{
name: "Show weather",
description: "Show a compact weather summary from an independently configured location.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Weather location",
description: 'Location used by the weather summary. Only shown if "Show weather" is enabled.',
values: "A searched city or custom latitude and longitude",
defaultValue: "Brisbane",
},
{
name: "Use Fahrenheit",
description: 'Use Fahrenheit in the weather summary. Only shown if "Show weather" is enabled.',
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Animate weather icon",
description: "Animate the weather icon unless the device requests reduced motion.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "World clock cities",
description: "Add, label, and order up to six IANA timezones for advanced view.",
values: "Up to six labeled IANA timezones",
defaultValue: "New York, Paris, and Tokyo",
name: "Custom date format",
description: "Format of the date displayed below the clock.",
values: 'ISO 8601 format, e.g. "YYYY-MM-DD", "DD/MM/YYYY", etc.',
defaultValue: "-",
},
],
},
@@ -1,44 +0,0 @@
---
title: "Countdowns"
description: "Tracks upcoming events and recurring anniversaries."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
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 { countdownWidget } from ".";
<WidgetHeader widget={countdownWidget} categories={["Time"]} />
The Countdowns widget tracks up to 20 events without an integration. Add each event in widget options, choose its local
date and time, and select an IANA timezone. Events can be reordered by dragging them.
The compact widget shows the first configured event, so dragging an event to the top selects the countdown displayed on
the board. Advanced view uses that same event for its larger summary and preserves the configured order within its
upcoming and completed groups. List cards keep their occurrence date at the bottom, below the optional progress bar.
One-time events remain completed after their target. A yearly event keeps the same local date and wall-clock time in its
configured timezone, including when daylight-saving offsets change. A February 29 event waits until the next leap year.
If a configured local time falls in a daylight-saving gap, the first valid time afterward is used; if it occurs twice,
the earlier occurrence is used.
Progress is optional. One-time events need a start date before a percentage can be calculated. Yearly events show
progress from the previous occurrence to the next. When enabled, seconds remain visible for every upcoming event.
Browser notifications are an optional local preference in advanced view and are disabled by default. Permission is
requested only after you enable notifications, and alerts can fire once per occurrence while Homarr is open. Browser
storage uses the board and widget identifiers to keep preferences separate and prevent duplicate alerts; clearing site
data resets them. Widget previews do not persist alert preferences.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={countdownWidget.configuration} />
-31
View File
@@ -1,31 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconHourglass } from "@tabler/icons-react";
export const countdownWidget: WidgetDefinition = {
icon: IconHourglass,
name: "Countdowns",
description: "Tracks upcoming events and recurring anniversaries.",
path: "../../widgets/countdown",
configuration: {
items: [
{
name: "Events",
description: "Add, edit, and order one-time or yearly events with a date, time, and IANA timezone.",
values: "Up to 20 events",
defaultValue: "No events",
},
{
name: "Show progress",
description: "Show progress from an optional start date, or between yearly occurrences.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show seconds",
description: "Keep seconds visible for every upcoming event, including events more than a day away.",
values: { type: "boolean" },
defaultValue: "no",
},
],
},
};
@@ -7,15 +7,20 @@ hide_title: true
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { IconAd } from "@tabler/icons-react";
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
import { dockerContainersWidget } from ".";
import { dockerIntegration } from "@site/docs/integrations/docker";
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
This widget displays the resource usage of your Docker containers and allows you to manage them.
:::info
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges.
This widget can only be used with administrator privileges.
:::
### Screenshots
@@ -24,6 +29,16 @@ import dockerTable from "./img/table.png";
<img src={dockerTable} alt="docker-stats table docker-containers" width={128 * 3} height={128 * 2} />
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: dockerIntegration,
},
]}
/>
### Interactions
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
@@ -8,12 +8,6 @@ export const dockerContainersWidget: WidgetDefinition = {
path: "../../widgets/docker-containers",
configuration: {
items: [
{
name: "Docker environments",
description: "Select one or more automatically discovered Docker environments, or leave empty to show all",
values: "Any Docker socket or host configured for Homarr",
defaultValue: "All Docker environments",
},
{
name: "Columns to show",
description: "Select which columns are visible in the table",
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
The media server widget shows active sessions across your media servers.
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
-54
View File
@@ -1,54 +0,0 @@
---
title: "Timer / Pomodoro"
description: "Runs a simple timer or a configurable Pomodoro sequence."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
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 { timerWidget } from ".";
<WidgetHeader widget={timerWidget} categories={["Productivity", "Time"]} />
The Timer / Pomodoro widget runs entirely in your browser and does not require an integration. The compact widget
always keeps its start or pause button and reset button visible. A running timer uses a saved deadline, so reopening
the board or returning to a background tab restores the correct remaining time instead of restarting the countdown.
Timer mode uses one configured duration. Pomodoro mode starts with a focus session, follows most completed focus
sessions with a short break, and uses a long break after the configured number of focus sessions. Automatic break and
focus starts are separate options and are disabled by default. When automatic starts are enabled, elapsed phases are
reconciled from their original deadlines after the board returns to the foreground.
Advanced view shows the current sequence, configured durations, and local alert preferences. Browser notifications are
requested only when enabled. Notification and sound preferences, live progress, deadlines, and cycle position are
stored locally using the board and widget identifiers. They are separate for each browser or device, are available to
guest viewers, and are removed by clearing site data. Widget previews are ephemeral and do not write local timer state.
Timer sounds are enabled by default and use the same low-volume Tiks sound system as onboarding: start and pause use
rising and falling soft-theme cues, reset uses a short click, and phase completion uses the glass theme's resonant
notification ding. You can disable them in advanced view. The cues respect the device's reduced-motion preference and
supplement the visible timer state rather than replacing it.
When automatic start is disabled, a completed phase labels the next step as ready and flashes the widget edges with the
board's secondary color. The border glow and completion chime run together as a five-beat alarm burst at
300-millisecond intervals, then stop. Reduced-motion and forced-colors modes replace the flash with a static border.
The current phase stays pinned to the compact widget's top-left corner and wraps when needed. Timer text and controls
use the dashboard theme's responsive size scale, with secondary details hidden when the widget preview is narrow or
short.
Alerts can fire while Homarr is open. Browser and operating-system notification settings may still suppress them.
The timer's mode, durations, session count, and automatic-start behavior are permanent board settings and are edited
from the widget context menu.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={timerWidget.configuration} />
-61
View File
@@ -1,61 +0,0 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconClockPlay } from "@tabler/icons-react";
export const timerWidget: WidgetDefinition = {
icon: IconClockPlay,
name: "Timer / Pomodoro",
description: "Runs a simple timer or a configurable Pomodoro sequence.",
path: "../../widgets/timer",
configuration: {
items: [
{
name: "Mode",
description: "Run one reusable timer or a repeating focus and break sequence",
values: { type: "select", options: ["Timer", "Pomodoro"] },
defaultValue: "Pomodoro",
},
{
name: "Timer minutes",
description: "Duration of the reusable timer",
values: "1-1440 minutes",
defaultValue: "10",
},
{
name: "Focus minutes",
description: "Duration of each Pomodoro focus session",
values: "1-180 minutes",
defaultValue: "25",
},
{
name: "Short break minutes",
description: "Duration of breaks between most focus sessions",
values: "1-60 minutes",
defaultValue: "5",
},
{
name: "Long break minutes",
description: "Duration of the break at the end of a Pomodoro cycle",
values: "1-180 minutes",
defaultValue: "15",
},
{
name: "Focus sessions before a long break",
description: "Number of completed focus sessions in one Pomodoro cycle",
values: "1-12 sessions",
defaultValue: "4",
},
{
name: "Automatically start breaks",
description: "Start the next short or long break when a focus session completes",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Automatically start focus sessions",
description: "Start the next focus session when a break completes",
values: { type: "boolean" },
defaultValue: "no",
},
],
},
};
-34
View File
@@ -1,34 +0,0 @@
---
title: "Today"
description: "Displays the current date and progress through the calendar."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
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 { todayWidget } from ".";
<WidgetHeader widget={todayWidget} categories={["Date", "Time"]} />
The Today widget presents the current local date without duplicating a live clock. It progressively adds the week
number, day of year, quarter, remaining days, and calendar progress as more space becomes available.
The advanced view shows week, month, and year progress together with a twelve-month overview. Week numbers can follow
your locale or the ISO 8601 convention. “Days after today” excludes the current day to keep the remaining-day count
unambiguous.
The widget runs entirely in your browser and does not send date or location data to an external service. It follows
the timezone and locale of the device viewing the board, so viewers in different regions can see their own local date.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={todayWidget.configuration} />
-43
View File
@@ -1,43 +0,0 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconCalendarWeek } from "@tabler/icons-react";
export const todayWidget: WidgetDefinition = {
icon: IconCalendarWeek,
name: "Today",
description: "Displays the current date and progress through the calendar.",
path: "../../widgets/today",
configuration: {
items: [
{
name: "Week numbering",
description: "Use locale-specific week numbering or ISO 8601 week numbers",
values: { type: "select", options: ["Use locale rules", "ISO 8601"] },
defaultValue: "Use locale rules",
},
{
name: "Show week number",
description: "Display the current week number when the widget has room",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show day of year",
description: "Display the current day number within the year",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show quarter",
description: "Display the current calendar quarter",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show year progress",
description: "Display progress through the current year",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+15 -18
View File
@@ -13,21 +13,23 @@ import { weatherWidget } from ".";
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
The Weather Widget displays current conditions and a responsive forecast for a selected location. New widgets start
with the city and a three-day forecast visible. As the widget gets smaller, it keeps the current condition and
temperature readable before removing secondary details or forecast days.
The Weather Widget uses your location to display the current, highest and lowest temperature.
In the compact view, select a condition icon or forecast day to open details with a mouse, keyboard or touch.
The compact forecast shows up to the configured number of days. The advanced view combines current conditions with the
available daily forecast, including humidity, wind, gusts, sunrise and sunset. Both views reuse the same cached Open
Meteo response.
Select a forecast day to inspect precipitation, apparent temperature, humidity, wind, UV index, sunrise, sunset and
daylight duration with a mouse, keyboard or touch. Open the advanced view for a current-condition summary, a 24-hour
temperature and precipitation chart, and the complete seven-day forecast. The chart has an equivalent data table for
assistive technologies.
### Screenshots
The **Show humidity** option controls humidity across the compact current-conditions view, advanced summary, hourly
details, and daily forecast details. Forecast cards use a roomier adaptive grid in advanced view, with four columns on
the standard desktop surface and fewer columns as the surface narrows.
import forecastWeather from "./img/forecast.png";
import currentWeather from "./img/current.png";
import advancedWeather from "./img/advanced-view.png";
Weather icons are static by default. Animation can be enabled in the widget configuration, but the operating system's
reduced-motion preference always disables it.
<div className="flex flex-col gap-1">
<img src={forecastWeather} alt="Weather compact forecast" width={238} height={112} />
<img src={currentWeather} alt="Weather compact current conditions" width={198} height={93} />
<img src={advancedWeather} alt="Weather advanced view with seven-day forecast details" width={800} height={484} />
</div>
<WidgetInteractionGuide />
@@ -41,10 +43,5 @@ reduced-motion preference always disables it.
### Data source
We use the Open Meteo API to fetch the weather and locations. Active weather data refreshes every ten minutes, while
identical location requests can reuse a server response for five minutes. A manual refresh may therefore return the
same still-current server response. If a refresh fails, Homarr keeps the last successful data visible and shows an
error indicator.
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests.
We use the Open Meteo API to fetch the weather and locations.
If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
+3 -19
View File
@@ -28,24 +28,8 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
defaultValue: "no",
},
{
name: "Show humidity",
description: "Display humidity in current conditions, hourly details, and daily forecasts",
values: {
type: "boolean",
},
defaultValue: "yes",
},
{
name: "Animate weather icons",
description: "Animate condition icons unless the operating system requests reduced motion",
values: {
type: "boolean",
},
defaultValue: "no",
},
{
name: "Use imperial speed units",
description: "Display wind speed in mph instead of km/h",
@@ -75,7 +59,7 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
defaultValue: "yes",
defaultValue: "no",
},
{
name: "Has forecast",
@@ -83,13 +67,13 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
defaultValue: "yes",
defaultValue: "no",
},
{
name: "Amount of forecast days",
description: "Number of days to show in the forecast",
values: "1-7",
defaultValue: "3",
defaultValue: "5",
},
],
},
@@ -1,4 +1,4 @@
import { IconCloud, IconCloudRain, IconSun } from "@tabler/icons-react";
import { IconCloud, IconWind, IconArrowUpRight, IconArrowDownRight } from "@tabler/icons-react";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
@@ -6,27 +6,21 @@ export const WeatherWidget = ({ className }: CommonWidgetProps) => {
return (
<WidgetCard width={1} className={clsx("gap-2", className)}>
<div className="flex gap-2 justify-center items-center">
<IconSun size={22} />
<div className="flex flex-col leading-none">
<span className="text-xl font-bold">18.4°C</span>
<span className="text-xs text-muted-foreground">Paris</span>
</div>
<IconCloud size={20} />
<span className="text-xl font-bold">10.8°C</span>
</div>
<div className="flex justify-evenly items-center w-full">
<div className="flex flex-col items-center text-xs">
<span>Mon</span>
<IconSun size={16} />
<span>21°</span>
<div className="flex gap-2 justify-center items-center">
<IconWind size={14} />
<span className="text-sm">5 km/h</span>
</div>
<div className="flex gap-1 justify-center items-center">
<div className="flex justify-center items-center">
<IconArrowUpRight size={14} />
<span className="text-sm">13.5°C</span>
</div>
<div className="flex flex-col items-center text-xs">
<span>Tue</span>
<IconCloud size={16} />
<span>19°</span>
</div>
<div className="flex flex-col items-center text-xs">
<span>Wed</span>
<IconCloudRain size={16} />
<span>16°</span>
<div className="flex justify-center items-center">
<IconArrowDownRight size={14} />
<span className="text-sm">6.4°C</span>
</div>
</div>
</WidgetCard>
-4
View File
@@ -2106,10 +2106,6 @@
"enum": [
"clock",
"weather",
"today",
"airQuality",
"countdown",
"timer",
"app",
"iframe",
"video",
@@ -1,35 +0,0 @@
import type { Session } from "@homarr/auth";
import { api } from "@homarr/api/server";
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
/**
* Session-aware wrappers around the shared management access rules in `@homarr/definitions`, so the
* navigation and the pages resolve access exactly the same way the API does.
*/
export const getAppsSectionAccess = (session: Session | null) =>
getAppManagementAccess(session?.user.permissions ?? []);
/**
* Pure variant for callers that already fetched the integrations the user can see, so the list is
* only queried once per request.
*/
export const getIntegrationsSectionAccess = (
session: Session | null,
visibleIntegrations: { permissions: { hasFullAccess: boolean } }[],
) =>
getIntegrationManagementAccess(
session?.user.permissions ?? [],
visibleIntegrations.some((integration) => integration.permissions.hasFullAccess),
);
export const getIntegrationsSectionAccessAsync = async (session: Session | null) => {
const permissions = session?.user.permissions ?? [];
// Only pay for the lookup when the answer is not already known from the global permissions.
const needsDelegatedLookup =
session !== null && !permissions.includes("integration-full-all") && !permissions.includes("integration-create");
return getIntegrationsSectionAccess(session, needsDelegatedLookup ? await api.integration.all() : []);
};
@@ -1,5 +1,5 @@
import { Fragment } from "react";
import { notFound, redirect } from "next/navigation";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
@@ -16,7 +16,6 @@ import { TourTarget } from "~/components/layout/header/tour-target";
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results";
import { getAppsSectionAccess } from "../_access";
import { AppDeleteButton } from "./_app-delete-button";
const searchParamsSchema = z.object({
@@ -42,27 +41,16 @@ interface AppsPageProps {
export default async function AppsPage(props: AppsPageProps) {
const session = await auth();
if (!session) redirect("/auth/login");
const { canManageAll, canCreate, canAccess } = getAppsSectionAccess(session);
if (!canAccess) notFound();
const canDelete = session.user.permissions.includes("app-full-all");
const searchParams = searchParamsSchema.parse(await props.searchParams);
// Without app-modify-all the user may add apps but not browse the ones they cannot manage,
// so the list stays empty instead of leaking every app name and URL.
const { items: apps, totalCount } = canManageAll
? await api.app.getPaginated(searchParams)
: { items: [], totalCount: 0 };
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
const t = await getScopedI18n("app");
const hasSearch = canManageAll && Boolean(searchParams.search?.trim());
const canCreate = session.user.permissions.includes("app-create");
const canModify = session.user.permissions.includes("app-modify-all");
const canDelete = session.user.permissions.includes("app-full-all");
const hasSearch = Boolean(searchParams.search?.trim());
const emptyState = !canManageAll ? (
<NoResults
icon={IconBox}
title={t("page.list.noResults.createOnlyTitle")}
description={t("page.list.noResults.createOnlyDescription")}
action={{ label: t("page.list.noResults.action"), href: "/manage/apps/new" }}
/>
) : hasSearch ? (
const emptyState = hasSearch ? (
<NoResults
icon={IconBox}
title={t("page.list.noResults.filteredTitle")}
@@ -94,14 +82,12 @@ export default async function AppsPage(props: AppsPageProps) {
) : undefined
}
toolbar={
canManageAll ? (
<SearchInput
placeholder={`${t("search")}...`}
ariaLabel={t("search")}
defaultValue={searchParams.search}
flexExpand
/>
) : undefined
<SearchInput
placeholder={`${t("search")}...`}
ariaLabel={t("search")}
defaultValue={searchParams.search}
flexExpand
/>
}
footer={
totalCount > searchParams.pageSize ? (
@@ -114,6 +100,7 @@ export default async function AppsPage(props: AppsPageProps) {
<AppItem
key={app.id}
app={app}
canModify={canModify}
canDelete={canDelete}
editLabel={t("page.list.action.edit", { name: app.name })}
/>
@@ -126,11 +113,12 @@ export default async function AppsPage(props: AppsPageProps) {
interface AppItemProps {
app: RouterOutputs["app"]["getPaginated"]["items"][number];
canModify: boolean;
canDelete: boolean;
editLabel: string;
}
const AppItem = ({ app, canDelete, editLabel }: AppItemProps) => {
const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
const descriptionLines = app.description?.split("\n");
const safeHref = getSafeAppHref(app.href);
@@ -169,19 +157,23 @@ const AppItem = ({ app, canDelete, editLabel }: AppItemProps) => {
) : undefined
}
actions={
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
size={44}
aria-label={editLabel}
>
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
{canDelete && <AppDeleteButton app={app} />}
</ActionIconGroup>
canModify || canDelete ? (
<ActionIconGroup>
{canModify && (
<ActionIcon
component={Link}
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
size={44}
aria-label={editLabel}
>
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
)}
{canDelete && <AppDeleteButton app={app} />}
</ActionIconGroup>
) : undefined
}
/>
);
@@ -1,14 +1,12 @@
"use client";
import { Avatar, Badge, Button, Group, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { Avatar, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
import { NoResults } from "~/components/no-results";
import { CustomWidgetRowActions } from "./_custom-widget-actions";
@@ -43,92 +41,75 @@ type WidgetDef = RouterOutputs["customWidget"]["list"][number];
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
const t = useScopedI18n("customWidget");
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
const origins = [...new Set(widget.sources.map((source) => source.origin))];
return (
<>
<ManageCollectionItem
leading={
<Avatar
size={40}
radius="sm"
src={widget.iconUrl}
color={widget.valid ? "pink" : "red"}
styles={{ image: { objectFit: "contain" } }}
alt=""
>
<IconApi size={20} stroke={1.5} />
</Avatar>
}
title={
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
{widget.name}
<ManageCollectionItem
leading={
<Avatar
size={40}
radius="sm"
src={widget.iconUrl}
color={widget.valid ? "pink" : "red"}
styles={{ image: { objectFit: "contain" } }}
alt=""
>
<IconApi size={20} stroke={1.5} />
</Avatar>
}
title={
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
{widget.name}
</Text>
}
badges={<CustomWidgetStatusBadges widget={widget} />}
description={
<Text size="sm" c="dimmed" lineClamp={2}>
{widget.description || t("page.list.noDescription")}
</Text>
}
metadata={
<Group gap={6} wrap="wrap">
<Text size="xs" c="dimmed">
{t("page.list.requestCount", { count: widget.requestCount })}
</Text>
}
badges={<CustomWidgetStatusBadges widget={widget} onConfigureSources={sourceSetupControls.open} />}
description={
<Text size="sm" c="dimmed" lineClamp={2}>
{widget.description || t("page.list.noDescription")}
</Text>
}
metadata={
<Group gap={6} wrap="wrap">
<Text size="xs" c="dimmed">
{t("page.list.requestCount", { count: widget.requestCount })}
{origins.map((origin) => (
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
· {origin}
</Text>
{origins.map((origin) => (
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
· {origin}
</Text>
))}
</Group>
}
actions={
<Group gap="xs" wrap="nowrap">
{widget.valid && (
<Button
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
variant="default"
size="sm"
leftSection={<IconPencil size={16} stroke={1.5} />}
>
{t("action.edit")}
</Button>
)}
<CustomWidgetRowActions
widget={{
id: widget.id,
name: widget.name,
enabled: widget.enabled,
valid: widget.valid,
migrationRequired: widget.migrationRequired,
}}
/>
</Group>
}
actionsAlignment="center"
/>
{widget.valid && (
<CustomWidgetSourceSetupDialog
definitionId={widget.id}
opened={sourceSetupOpened}
onClose={sourceSetupControls.close}
/>
)}
</>
))}
</Group>
}
actions={
<Group gap="xs" wrap="nowrap">
{widget.valid && (
<Button
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
variant="default"
size="sm"
leftSection={<IconPencil size={16} stroke={1.5} />}
>
{t("action.edit")}
</Button>
)}
<CustomWidgetRowActions
widget={{
id: widget.id,
name: widget.name,
enabled: widget.enabled,
valid: widget.valid,
migrationRequired: widget.migrationRequired,
}}
/>
</Group>
}
/>
);
}
/** Everything that decides whether this widget can actually run right now. */
function CustomWidgetStatusBadges({
widget,
onConfigureSources,
}: {
widget: WidgetDef;
onConfigureSources(): void;
}) {
function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) {
const t = useScopedI18n("customWidget");
if (widget.migrationRequired) {
@@ -176,17 +157,9 @@ function CustomWidgetStatusBadges({
multiline
maw={320}
>
<UnstyledButton onClick={onConfigureSources} aria-label={t("action.configureSources")}>
<Badge
color="yellow"
size="sm"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
style={{ cursor: "pointer" }}
>
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
</Badge>
</UnstyledButton>
<Badge color="yellow" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
</Badge>
</Tooltip>
)}
</Group>
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useImperativeHandle, useRef, 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";
@@ -27,14 +27,8 @@ import type { AnyMappedTestConnectionError } from "../../_components/test-connec
interface EditIntegrationFormProps {
integration: RouterOutputs["integration"]["byId"];
hideButtons?: boolean;
embedded?: boolean;
onSuccess?: () => void;
formRef?: React.Ref<EditIntegrationFormHandle>;
}
export interface EditIntegrationFormHandle {
submit: () => Promise<boolean>;
isDirty: () => boolean;
}
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
@@ -50,12 +44,7 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
}),
);
export const EditIntegrationForm = ({
integration,
hideButtons = false,
onSuccess,
formRef,
}: EditIntegrationFormProps) => {
export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }: EditIntegrationFormProps) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const allSecretKinds = getAllSecretKindOptions(integration.kind);
@@ -93,172 +82,147 @@ export const EditIntegrationForm = ({
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = useCallback(
async ({ app, ...values }: FormType) => {
setError(null);
let url: string;
try {
url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
} catch {
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
return false;
}
const data = await mutateAsync({
id: integration.id,
...values,
url,
secrets: values.secrets.map((secret) => ({
kind: secret.kind,
value: secret.value === "" ? null : secret.value,
})),
appId: app?.id ?? null,
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
setError(null);
let url: string;
try {
url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
} catch {
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
return;
}
// We do it this way as we are unable to send a typesafe error through onError
if (data?.error) {
setError(data.error);
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
requestAnimationFrame(() => errorRef.current?.focus());
return false;
}
const data = await mutateAsync({
id: integration.id,
...values,
url,
secrets: values.secrets.map((secret) => ({
kind: secret.kind,
value: secret.value === "" ? null : secret.value,
})),
appId: app?.id ?? null,
});
showSuccessNotification({
title: t("integration.page.edit.notification.success.title"),
message: t("integration.page.edit.notification.success.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"),
});
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
onSuccess?.();
if (!hideButtons) {
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
}
return true;
},
[hasUrlSecret, hideButtons, integration.id, mutateAsync, onSuccess, router, t, utils.integration, utils.widget],
);
requestAnimationFrame(() => errorRef.current?.focus());
return;
}
useImperativeHandle(
formRef,
() => ({
submit: () =>
new Promise<boolean>((resolve) => {
form.onSubmit(
async (values) => {
try {
resolve(await handleSubmitAsync(values));
} catch {
resolve(false);
}
},
() => resolve(false),
)();
}),
isDirty: () => form.isDirty(),
}),
[form, handleSubmitAsync],
);
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"));
}
};
const isInitialSecretKinds =
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
form.values.secrets.length === initialSecretsKinds.length;
const formFields = (
<Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
return (
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
<Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
{hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)}
{hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)}
<Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm">
{allSecretKinds.length > 1 && (
<SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
)}
{!isInitialSecretKinds
? null
: form.values.secrets.map((secret, index) => (
<SecretCard
key={secret.kind}
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
onCancel={() =>
new Promise((resolve) => {
// When nothing changed, just close the secret card
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
return resolve(true);
}
openConfirmModal({
title: t("integration.secrets.reset.title"),
children: t("integration.secrets.reset.message"),
onCancel: () => resolve(false),
onConfirm: () => {
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
resolve(true);
},
});
})
}
>
<IntegrationSecretInput
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
<Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm">
{allSecretKinds.length > 1 && (
<SecretKindsSegmentedControl
defaultKinds={initialSecretsKinds}
secretKinds={allSecretKinds}
form={form}
/>
)}
{!isInitialSecretKinds
? null
: form.values.secrets.map((secret, index) => (
<SecretCard
key={secret.kind}
kind={secret.kind}
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
onCancel={() =>
new Promise((resolve) => {
// When nothing changed, just close the secret card
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
return resolve(true);
}
openConfirmModal({
title: t("integration.secrets.reset.title"),
children: t("integration.secrets.reset.message"),
onCancel: () => resolve(false),
onConfirm: () => {
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
resolve(true);
},
});
})
}
>
<IntegrationSecretInput
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
key={secret.kind}
kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
</SecretCard>
))}
{isInitialSecretKinds
? null
: form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput
withAsterisk
key={kind}
kind={kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
</SecretCard>
))}
{isInitialSecretKinds
? null
: form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput
withAsterisk
key={kind}
kind={kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
))}
{form.values.secrets.length === 0 && (
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
</Alert>
)}
</Stack>
</Fieldset>
))}
{form.values.secrets.length === 0 && (
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
</Alert>
)}
</Stack>
</Fieldset>
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
{error !== null && (
<div ref={errorRef} role="alert" tabIndex={-1}>
<IntegrationTestConnectionError error={error} url={form.values.url} />
</div>
)}
{error !== null && (
<div ref={errorRef} role="alert" tabIndex={-1}>
<IntegrationTestConnectionError error={error} url={form.values.url} />
</div>
)}
{!hideButtons && (
<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>
</Group>
)}
</Stack>
</Stack>
</form>
);
if (hideButtons) {
return formFields;
}
return <form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>{formFields}</form>;
};
type FormType = z.infer<typeof formSchema>;
@@ -1,5 +1,5 @@
import { notFound, redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Badge, Stack, Text } from "@mantine/core";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Badge, Text } from "@mantine/core";
import { IconPencil, IconPlugX } from "@tabler/icons-react";
import { z } from "zod/v4";
@@ -15,7 +15,6 @@ import { TourTarget } from "~/components/layout/header/tour-target";
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results";
import { getIntegrationsSectionAccess } from "../_access";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
const searchParamsSchema = z.object({ search: z.string().optional() });
@@ -30,22 +29,11 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
const searchParams = searchParamsSchema.parse(await props.searchParams);
const integrations = await api.integration.all();
const {
canManageAll: hasGlobalFullAccess,
canCreate,
canAccess,
} = getIntegrationsSectionAccess(session, integrations);
if (!canAccess) notFound();
const t = await getScopedI18n("integration");
// Without integration-full-all only the integrations that were explicitly delegated to the user
// are manageable, so the list never shows an integration they cannot open.
const manageableIntegrations = integrations.filter(
(integration) => hasGlobalFullAccess || integration.permissions.hasFullAccess,
);
const canCreate = session.user.permissions.includes("integration-create");
const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all");
const query = searchParams.search?.trim().toLocaleLowerCase() ?? "";
const filteredIntegrations = manageableIntegrations
const filteredIntegrations = integrations
.filter((integration) => {
if (!query) return true;
return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) =>
@@ -65,14 +53,7 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
ariaLabel={t("page.list.ariaLabel")}
itemCount={filteredIntegrations.length}
emptyState={
manageableIntegrations.length === 0 && !hasGlobalFullAccess ? (
<NoResults
icon={IconPlugX}
title={t("page.list.noResults.createOnlyTitle")}
description={t("page.list.noResults.createOnlyDescription")}
action={{ label: t("page.list.noResults.action"), href: "/manage/integrations/new" }}
/>
) : hasSearch ? (
hasSearch ? (
<NoResults
icon={IconPlugX}
title={t("page.list.noResults.filteredTitle")}
@@ -112,33 +93,24 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
floatingPrimaryAction={canCreate}
>
{filteredIntegrations.map((integration) => (
<IntegrationItem key={integration.id} integration={integration} />
<IntegrationItem
key={integration.id}
integration={integration}
canManage={hasGlobalFullAccess || integration.permissions.hasFullAccess}
/>
))}
</ManageCollectionPage>
);
const pageContent =
filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
if (hasGlobalFullAccess) {
return pageContent;
}
return (
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("page.list.scopedNote")}
</Text>
{pageContent}
</Stack>
);
return filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
}
interface IntegrationItemProps {
integration: RouterOutputs["integration"]["all"][number];
canManage: boolean;
}
const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps) => {
const t = await getScopedI18n("integration");
const kindName = getIntegrationName(integration.kind);
const safeUrl = getSafeApplicationUrl(integration.url);
@@ -171,19 +143,21 @@ const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
) : undefined
}
actions={
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
size={44}
aria-label={t("page.list.action.edit", { name: integration.name })}
>
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} />
</ActionIconGroup>
canManage ? (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
size={44}
aria-label={t("page.list.action.edit", { name: integration.name })}
>
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} />
</ActionIconGroup>
) : undefined
}
/>
);
@@ -44,7 +44,6 @@ import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
import { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
import { env as nextEnv } from "~/env";
import { getAppsSectionAccess, getIntegrationsSectionAccessAsync } from "./_access";
const logger = createLogger({ module: "manageLayout" });
@@ -66,8 +65,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
sessionPromise,
shouldRunManageTourPromise,
]);
const appsAccess = getAppsSectionAccess(session);
const integrationsAccess = await getIntegrationsSectionAccessAsync(session);
const navigationLinks: NavigationLink[] = [
{
label: t("items.home"),
@@ -85,7 +82,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox,
href: "/manage/apps",
label: t("items.apps"),
hidden: !appsAccess.canAccess,
hidden: !session?.user.permissions.includes("app-create"),
iconProps: {
strokeWidth: 2.5,
},
@@ -95,7 +92,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconAffiliateFilled,
href: "/manage/integrations",
label: t("items.integrations"),
hidden: !integrationsAccess.canAccess,
hidden: !session?.user.permissions.includes("integration-create"),
"data-onboarding-tour-id": "manage-integrations",
},
{
@@ -1,7 +1,6 @@
import { Anchor, Center, Stack, Text, Title } from "@mantine/core";
import { Center, Stack, Text, Title } from "@mantine/core";
import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
export default async function NotFound() {
const t = await getScopedI18n("management.notFound");
@@ -10,9 +9,6 @@ export default async function NotFound() {
<Stack align="center">
<Title order={1}>{t("title")}</Title>
<Text>{t("text")}</Text>
<Anchor component={Link} href="/manage">
{t("backToHome")}
</Anchor>
</Stack>
</Center>
);
+2 -7
View File
@@ -29,12 +29,7 @@ export default async function ManagementPage() {
<Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{statistics.map((statistic) => (
<Card
component={Link}
href={statistic.path}
key={statistic.path}
aria-label={`${statistic.count} ${t(`statistic.${statistic.titleKey}`)}`}
>
<Card component={Link} href={statistic.path} key={statistic.path}>
<Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Text size="xl" fw={700}>
@@ -47,7 +42,7 @@ export default async function ManagementPage() {
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
</Stack>
</Group>
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" aria-hidden="true" />
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />
</Group>
</Card>
))}
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
import {
dismissDockerReconciliationCandidate,
filterDockerReconciliationInbox,
getTemplateUrl,
getValidDockerServiceUrl,
} from "./docker-reconciliation-inbox";
const candidates = [
@@ -37,44 +37,9 @@ describe("Docker reconciliation inbox", () => {
expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed);
});
test("builds service URLs from the server origin and published ports", () => {
expect(
getTemplateUrl(
{
container: { name: "Custom service", ports: [{ PrivatePort: 3000, Type: "tcp" }] },
match: null,
},
"home.lan",
"hostPort",
),
).toBe("http://home.lan");
expect(
getTemplateUrl(
{
container: {
name: "Sonarr",
ports: [
{ PrivatePort: 3000, PublicPort: 3000, Type: "tcp" },
{ PrivatePort: 8989, PublicPort: 9999, Type: "tcp" },
],
},
match: { kind: "sonarr" },
},
"home.lan",
"hostPort",
),
).toBe("http://home.lan:9999");
expect(
getTemplateUrl(
{
container: { name: "Sonarr", ports: [{ PrivatePort: 8989, PublicPort: 9999, Type: "tcp" }] },
match: { kind: "sonarr" },
},
"",
"hostPort",
),
).toBe("");
test("accepts only HTTP service URLs", () => {
expect(getValidDockerServiceUrl("https://service.example.com")).toBe("https://service.example.com/");
expect(getValidDockerServiceUrl("ftp://service.example.com")).toBeNull();
expect(getValidDockerServiceUrl("service.example.com")).toBeNull();
});
});
@@ -1,6 +1,3 @@
import type { DockerServiceMatch, DockerServicePort, UrlTemplateMode } from "@homarr/definitions";
import { buildAppUrl, buildIntegrationUrl, getIntegrationDefaultPort } from "@homarr/definitions";
export type DockerReconciliationInboxFilter = "attention" | "represented" | "all";
interface DockerReconciliationInboxCandidate {
@@ -27,28 +24,11 @@ export const filterDockerReconciliationInbox = <Candidate extends DockerReconcil
export const dismissDockerReconciliationCandidate = (dismissedCandidateKeys: string[], candidateKey: string) =>
dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey];
interface DockerTemplateCandidate {
container: {
name: string;
ports?: DockerServicePort[];
};
match: Pick<DockerServiceMatch, "kind"> | null;
}
export const getTemplateUrl = (candidate: DockerTemplateCandidate, serverOrigin: string, urlMode: UrlTemplateMode) => {
if (!serverOrigin.trim()) return "";
const tcpPorts = candidate.container.ports?.filter(({ Type }) => Type.toLowerCase() === "tcp") ?? [];
let publishedPort = tcpPorts.find(({ PublicPort }) => PublicPort)?.PublicPort;
if (candidate.match) {
const defaultPort = getIntegrationDefaultPort(candidate.match.kind);
const preferredPort = tcpPorts.find(
({ PrivatePort, PublicPort }) => PrivatePort === defaultPort && PublicPort !== undefined,
);
publishedPort = preferredPort?.PublicPort ?? publishedPort;
return buildIntegrationUrl(candidate.match.kind, serverOrigin, urlMode, publishedPort);
export const getValidDockerServiceUrl = (value: string) => {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
} catch {
return null;
}
return buildAppUrl(candidate.container.name, serverOrigin, urlMode, publishedPort);
};
@@ -1,72 +1,46 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useEffect, useState } from "react";
import {
Accordion,
ActionIcon,
Alert,
Autocomplete,
Avatar,
Badge,
Button,
Collapse,
Divider,
Card,
Group,
Paper,
SegmentedControl,
Select,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Tooltip,
TextInput,
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import {
IconAlertTriangle,
IconApps,
IconCheck,
IconChevronDown,
IconEye,
IconEyeOff,
IconBrandDocker,
IconMinus,
IconPlugConnected,
IconRefresh,
IconSettings,
} from "@tabler/icons-react";
import { IconAlertTriangle, IconArrowRight, IconEyeOff, IconPlugConnected, IconRefresh } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { UrlTemplateMode } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification } from "@homarr/notifications";
import { normalizeServiceUrl, ServiceUrlTemplate } from "@homarr/onboarding";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox";
import {
dismissDockerReconciliationCandidate,
filterDockerReconciliationInbox,
getTemplateUrl,
getValidDockerServiceUrl,
} from "./docker-reconciliation-inbox";
type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number];
type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number];
export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOrigin: string }) => {
export const DockerReconciliation = () => {
const t = useScopedI18n("docker.reconciliation");
const tGlobal = useI18n();
const utils = clientApi.useUtils();
const panelId = useId();
const [isOpen, setIsOpen] = useLocalStorage({
key: "homarr-docker-reconciliation-open",
defaultValue: false,
});
const reconciliation = clientApi.docker.reconcileServices.useQuery();
const health = clientApi.docker.getServiceHealth.useQuery(undefined, { enabled: isOpen });
const health = clientApi.docker.getServiceHealth.useQuery();
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
async onSuccess() {
await Promise.all([
@@ -87,14 +61,6 @@ export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOri
key: "homarr-docker-reconciliation-dismissed",
defaultValue: [],
});
const [serverOrigin, setServerOrigin] = useLocalStorage({
key: "homarr-docker-service-origin",
defaultValue: defaultServerOrigin,
});
const [urlMode, setUrlMode] = useLocalStorage<UrlTemplateMode>({
key: "homarr-docker-service-url-mode",
defaultValue: "hostPort",
});
if (reconciliation.isError) {
return (
@@ -102,7 +68,7 @@ export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOri
<Stack gap="sm">
<Text size="sm">{t("loadError.message")}</Text>
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void reconciliation.refetch()}>
{tGlobal("common.action.tryAgain")}
{t("action.retry")}
</Button>
</Stack>
</Alert>
@@ -113,274 +79,231 @@ export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOri
const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable");
const candidates = filterDockerReconciliationInbox(reconciliation.data.candidates, filter, dismissedCandidateKeys);
const attentionCount = reconciliation.data.candidates.filter(
({ candidateKey, state }) =>
["newRecognized", "newApp", "moved"].includes(state) && !dismissedCandidateKeys.includes(candidateKey),
).length;
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;
const toggleLabel = isOpen ? tGlobal("common.action.hide") : t("action.review");
return (
<Paper withBorder p="sm">
<Stack gap={isOpen ? "sm" : 0}>
<Card withBorder>
<Stack>
<Group justify="space-between" align="start">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" size="lg" radius="md">
<IconBrandDocker size={18} />
</ThemeIcon>
<div>
<Group gap="xs">
<Text fw={600}>{t("title")}</Text>
<Badge variant="light" color={attentionCount > 0 ? "blue" : "gray"} aria-live="polite">
{t("suggestions", { count: String(attentionCount) })}
</Badge>
</Group>
</div>
</Group>
<Button
variant="subtle"
size="compact-sm"
rightSection={
<IconChevronDown
size={15}
style={{ transform: isOpen ? "rotate(180deg)" : undefined, transition: "transform 150ms ease" }}
/>
}
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setIsOpen((current) => !current)}
>
{toggleLabel}
</Button>
<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>
<Collapse id={panelId} expanded={isOpen}>
<Divider mb="sm" />
<Stack gap="sm">
<ServiceUrlTemplate
serverOrigin={serverOrigin}
onServerOriginChange={setServerOrigin}
mode={urlMode}
onModeChange={setUrlMode}
/>
<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>
<Group justify="space-between">
<SegmentedControl
size="xs"
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") },
]}
{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),
)
}
/>
<Group gap="xs">
{dismissedCandidateKeys.length > 0 && (
<Button variant="subtle" size="compact-xs" onClick={() => setDismissedCandidateKeys([])}>
{t("action.restoreDismissed", { count: String(dismissedCandidateKeys.length) })}
</Button>
)}
<Button
variant="light"
size="compact-xs"
loading={isRefreshing}
leftSection={<IconRefresh size={14} />}
onClick={() => refreshInventory.mutate()}
>
{tGlobal("docker.action.refresh.label")}
</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>
) : (
<Accordion multiple variant="separated" radius="sm" chevron={null}>
{candidates.map((candidate) => (
<DockerReconciliationCandidate
key={candidate.candidateKey}
candidate={candidate}
health={health.data?.services.find(({ key }) => key === candidate.candidateKey)}
serverOrigin={serverOrigin}
urlMode={urlMode}
onDismiss={() =>
setDismissedCandidateKeys((current) =>
dismissDockerReconciliationCandidate(current, candidate.candidateKey),
)
}
/>
))}
</Accordion>
)}
</Stack>
</Collapse>
))}
</SimpleGrid>
)}
</Stack>
</Paper>
</Card>
);
};
const DockerReconciliationCandidate = ({
candidate,
health,
serverOrigin,
urlMode,
onDismiss,
}: {
candidate: ReconciliationCandidate;
health: ServiceHealth | undefined;
serverOrigin: string;
urlMode: UrlTemplateMode;
onDismiss: () => void;
}) => {
const t = useScopedI18n("docker.reconciliation");
const tGlobal = useI18n();
const { openModal: openAppModal } = useModalAction(AddDockerAppToHomarr);
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const templateUrl = getTemplateUrl(candidate, serverOrigin, urlMode);
const urlSuggestions = Array.from(
new Set([templateUrl, ...candidate.urlCandidates.map(({ url }) => url)].filter((url) => url.length > 0)),
);
const initialUrl = urlSuggestions[0] ?? "";
const [url, setUrl] = useState(initialUrl);
const isUrlEdited = useRef(false);
const target = getCandidateTarget(candidate);
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 visibleHealthLayers =
health?.layers.filter(({ status }) => status !== "notApplicable" && status !== "notObserved") ?? [];
const isActionDisabled = actionNeedsUrl && validatedUrl === null;
useEffect(() => {
if (isUrlEdited.current) return;
setUrl(initialUrl);
}, [initialUrl]);
setSelectedCandidateId(initialUrlCandidate?.id ?? null);
setUrl(initialUrlCandidate?.url ?? "");
}, [initialUrlCandidate?.id, initialUrlCandidate?.url]);
return (
<Accordion.Item value={candidate.candidateKey}>
<Group wrap="nowrap" gap={4} pr="sm">
<Accordion.Control
style={{ flex: 1 }}
icon={
<Avatar src={candidate.container.iconUrl} radius="sm" size="sm">
<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 style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text fw={600} size="sm" truncate>
<div>
<Text fw={600} lineClamp={1}>
{candidate.container.name}
</Text>
<Badge color={stateColor(candidate.state)} variant="light" size="xs">
{t(`state.${candidate.state}`)}
</Badge>
</Group>
<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">
{candidate.endpointName}
{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>
</Accordion.Control>
<Group gap={4} wrap="nowrap">
{target.kind === "createApp" && (
)}
<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"
size="compact-xs"
leftSection={<IconApps size={14} />}
rightSection={<IconArrowRight size={16} />}
disabled={isActionDisabled}
onClick={() =>
openAppModal({
openModal({
selectedContainers: [candidate.container],
initialUrls: [(normalizeServiceUrl(url) ?? url) || null],
initialUrls: validatedUrl ? [validatedUrl] : undefined,
})
}
>
{tGlobal("integration.field.createApp.label")}
{t("action.createApp")}
</Button>
)}
{target.kind === "setupIntegration" && (
<Button
variant="light"
size="compact-xs"
leftSection={<IconPlugConnected size={14} />}
onClick={() =>
openIntegrationModal({
initialKind: target.integrationKind,
initialName: candidate.container.name,
initialUrl: normalizeServiceUrl(url) ?? url,
})
}
>
{t("action.setupIntegration")}
</Button>
)}
{(target.kind === "reviewIntegration" || target.kind === "viewRepresentation") && (
) : (
<Button
component={Link}
href={target.href}
variant="light"
size="compact-xs"
leftSection={target.kind === "reviewIntegration" ? <IconSettings size={14} /> : <IconEye size={14} />}
rightSection={<IconArrowRight size={16} />}
disabled={isActionDisabled}
onClick={(event) => {
if (isActionDisabled) event.preventDefault();
}}
>
{t(`action.${target.kind}`)}
</Button>
)}
<Tooltip label={t("action.dismiss")} events={{ hover: true, focus: true, touch: false }}>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("action.dismiss")} onClick={onDismiss}>
<IconEyeOff size={15} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Accordion.Panel>
<Stack gap="sm">
{candidate.representation.signals.ambiguous && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("ambiguous")}
</Alert>
)}
{visibleHealthLayers.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xs">
{visibleHealthLayers.map((layer) => (
<Group key={layer.layer} gap="xs" wrap="nowrap">
<HealthStatusIcon status={layer.status} />
<Text size="xs">
{t(`health.layer.${layer.layer}`)} ·{" "}
<Text component="span" c="dimmed" inherit>
{t(`health.status.${layer.status}`)}
</Text>
</Text>
</Group>
))}
</SimpleGrid>
)}
{actionNeedsUrl && (
<Autocomplete
label={tGlobal("integration.field.url.label")}
value={url}
data={urlSuggestions}
placeholder={t("url.manual")}
onChange={(value) => {
isUrlEdited.current = true;
setUrl(value);
}}
/>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Stack>
</Card>
);
};
const getCandidateTarget = (candidate: ReconciliationCandidate) => {
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) });
@@ -389,7 +312,9 @@ const getCandidateTarget = (candidate: ReconciliationCandidate) => {
return { kind: "viewRepresentation" as const, href: "/manage/apps" };
}
if (candidate.state === "newRecognized" && candidate.match) {
return { kind: "setupIntegration" as const, integrationKind: candidate.match.kind };
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 };
@@ -412,21 +337,8 @@ const stateColor = (state: ReconciliationCandidate["state"]) => {
return "blue";
};
const HealthStatusIcon = ({ status }: { status: ServiceHealth["layers"][number]["status"] }) => {
let color = "gray";
let icon = <IconMinus size={12} />;
if (["available", "configured", "linked"].includes(status)) {
color = "green";
icon = <IconCheck size={12} />;
} else if (["missing", "changed", "unused"].includes(status)) {
color = "yellow";
icon = <IconAlertTriangle size={12} />;
}
return (
<ThemeIcon color={color} variant="light" radius="xl" size="sm">
{icon}
</ThemeIcon>
);
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,8 @@
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
@@ -18,10 +16,7 @@ export default async function DockerPage() {
notFound();
}
const [{ containers, endpoints, timestamp }, requestHeaders] = await Promise.all([
api.docker.getContainers(),
headers(),
]);
const { containers, endpoints, timestamp } = await api.docker.getContainers();
const tDocker = await getScopedI18n("docker");
return (
@@ -29,7 +24,7 @@ export default async function DockerPage() {
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tDocker("title")}</Title>
<DockerReconciliation defaultServerOrigin={extractBaseUrlFromHeaders(requestHeaders)} />
<DockerReconciliation />
<DockerTable initialData={{ containers, endpoints, timestamp }} />
</Stack>
</>
@@ -1,43 +1,17 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback, useId } from "react";
import {
Badge,
Box,
Button,
Card,
Checkbox,
Collapse,
Divider,
Group,
SegmentedControl,
Stack,
Text,
Tooltip,
Transition,
UnstyledButton,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconShieldExclamation } from "@tabler/icons-react";
import { useCallback } from "react";
import { Button, Card, Group, Switch, Text, Transition } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { objectEntries, objectKeys } from "@homarr/common";
import type { GroupPermissionKey, PermissionMatrixCategory, PermissionMatrixState } from "@homarr/definitions";
import {
getPermissionsWithChildren,
groupPermissionKeys,
isCapabilityImpliedByLevel,
matrixStateToPermissions,
permissionMatrix,
permissionsToMatrixState,
} from "@homarr/definitions";
import { objectEntries } from "@homarr/common";
import type { GroupPermissionKey } from "@homarr/definitions";
import { groupPermissionKeys } from "@homarr/definitions";
import { createFormContext } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
export type PermissionLabels = Partial<Record<GroupPermissionKey, string>>;
const [FormProvider, useFormContext, useForm] = createFormContext<FormType>();
interface PermissionFormProps {
@@ -45,17 +19,27 @@ interface PermissionFormProps {
}
export const PermissionForm = ({ children, initialPermissions }: PropsWithChildren<PermissionFormProps>) => {
// Mantine compares each field against the values snapshot, so toggling a control back to its
// saved value clears the dirty state on its own - no manual bookkeeping needed here.
const form = useForm({
initialValues: groupPermissionKeys.reduce((acc, key) => {
acc[key] = initialPermissions.includes(key);
return acc;
}, {} as FormType),
onValuesChange(values) {
const currentKeys = objectEntries(values)
.filter(([_key, value]) => Boolean(value))
.map(([key]) => key);
if (
currentKeys.every((key) => initialPermissions.includes(key)) &&
initialPermissions.every((key) => currentKeys.includes(key))
) {
form.resetDirty(); // Reset dirty state if all keys are the same as initial
}
},
});
return (
<form onSubmit={(event) => event.preventDefault()}>
<form>
<FormProvider form={form}>{children}</FormProvider>
</form>
);
@@ -63,329 +47,15 @@ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildr
type FormType = Record<GroupPermissionKey, boolean>;
type MatrixForm = ReturnType<typeof useFormContext>;
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => {
const form = useFormContext();
const emptyMatrixState = (): PermissionMatrixState =>
objectKeys(permissionMatrix).reduce((acc, category) => {
acc[category] = { level: 0, capabilities: [] };
return acc;
}, {} as PermissionMatrixState);
const matrixStateOf = (values: FormType) => permissionsToMatrixState(groupPermissionKeys.filter((key) => values[key]));
export const getMatrixLevel = (values: FormType, category: PermissionMatrixCategory): number =>
matrixStateOf(values)[category].level;
export const hasMatrixCapability = (
values: FormType,
category: PermissionMatrixCategory,
capability: GroupPermissionKey,
): boolean => matrixStateOf(values)[category].capabilities.includes(capability);
export const applyMatrixLevel = (form: MatrixForm, category: PermissionMatrixCategory, level: number) => {
const targetKeys = new Set(
matrixStateToPermissions({ ...emptyMatrixState(), [category]: { level, capabilities: [] } }),
);
permissionMatrix[category].levels.forEach((key) => {
form.setFieldValue(key, targetKeys.has(key));
const props = form.getInputProps(name, {
withError: false,
type: "checkbox",
});
// Capability keys are deliberately left untouched: a level that implies one already covers it,
// and an explicitly granted capability must survive lowering the level again.
};
export const applyMatrixCapability = (form: MatrixForm, capability: GroupPermissionKey, granted: boolean) => {
form.setFieldValue(capability, granted);
};
const presets = {
viewer: {
board: { level: 1, capabilities: [] },
app: { level: 0, capabilities: ["app-use-all"] },
integration: { level: 1, capabilities: [] },
media: { level: 1, capabilities: [] },
},
editor: {
board: { level: 2, capabilities: ["board-create"] },
app: { level: 1, capabilities: ["app-use-all"] },
integration: { level: 2, capabilities: ["integration-create"] },
"search-engine": { level: 1, capabilities: [] },
media: { level: 1, capabilities: ["media-upload"] },
},
admin: { admin: { level: 1, capabilities: [] } },
} satisfies Record<string, Partial<PermissionMatrixState>>;
export type PermissionPreset = keyof typeof presets;
const presetToFormState = (preset: PermissionPreset): FormType => {
const presetState: Partial<PermissionMatrixState> = presets[preset];
const state = objectKeys(permissionMatrix).reduce((acc, category) => {
acc[category] = presetState[category] ?? { level: 0, capabilities: [] };
return acc;
}, {} as PermissionMatrixState);
const enabledKeys = new Set(matrixStateToPermissions(state));
return groupPermissionKeys.reduce((acc, key) => {
acc[key] = enabledKeys.has(key);
return acc;
}, {} as FormType);
};
export const applyPreset = (form: MatrixForm, preset: PermissionPreset) => {
form.setValues(presetToFormState(preset));
};
export const getActivePreset = (values: FormType): PermissionPreset | null => {
return (
objectKeys(presets).find((preset) => {
const presetValues = presetToFormState(preset);
return groupPermissionKeys.every((key) => presetValues[key] === values[key]);
}) ?? null
);
};
/**
* Short, scannable label for a level. Categories with a single level use their permission label
* instead, so the control reads "No access | View logs" rather than "No access | Enabled".
*/
const levelLabelKey = (key: GroupPermissionKey) => {
if (key.endsWith("-view-all")) return "view" as const;
if (key.endsWith("-use-all")) return "use" as const;
if (key.endsWith("-interact-all")) return "interact" as const;
if (key.endsWith("-modify-all")) return "modify" as const;
if (key.endsWith("-full-all")) return "full" as const;
return null;
};
interface MatrixImpliedHintProps {
category: PermissionMatrixCategory;
permissionLabels: PermissionLabels;
}
export const MatrixImpliedHint = ({ category, permissionLabels }: MatrixImpliedHintProps) => {
const form = useFormContext();
const tPermissions = useScopedI18n("group.permission");
const level = getMatrixLevel(form.getValues(), category);
if (level === 0) {
return null;
}
const checkedKeys: GroupPermissionKey[] = permissionMatrix[category].levels.slice(0, level);
const implied = getPermissionsWithChildren(checkedKeys).filter((key) => !checkedKeys.includes(key));
if (implied.length === 0) {
return null;
}
const labels = implied.map((key) => permissionLabels[key] ?? key);
return (
<Text size="xs" c="dimmed">
{tPermissions("matrix.impliedHint", { permissions: labels.join(", ") })}
</Text>
);
};
interface MatrixRowProps {
category: PermissionMatrixCategory;
permissionLabels: PermissionLabels;
permissionDescriptions: PermissionLabels;
}
export const MatrixRow = ({ category, permissionLabels, permissionDescriptions }: MatrixRowProps) => {
const form = useFormContext();
const tPermissions = useScopedI18n("group.permission");
const values = form.getValues();
const level = getMatrixLevel(values, category);
const isDanger = category === "admin";
const disabledByAdmin = !isDanger && getMatrixLevel(values, "admin") > 0;
const { levels, capabilities } = permissionMatrix[category];
const hasSingleLevel = levels.length === 1;
const data = [
{ value: "0", label: tPermissions("matrix.none") },
...levels.map((key, index) => {
const shortKey = hasSingleLevel ? null : levelLabelKey(key);
return {
value: String(index + 1),
label: shortKey === null ? (permissionLabels[key] ?? key) : tPermissions(`matrix.level.${shortKey}`),
};
}),
];
const capabilityStates = capabilities.map((capability) => ({
capability,
checked: hasMatrixCapability(values, category, capability),
implied: isCapabilityImpliedByLevel(category, capability, level),
}));
const selectedKey = level === 0 ? undefined : levels[level - 1];
// With no access level but a capability granted, describing the row as "no access" would
// contradict the checkbox next to it, so the granted capability describes the row instead.
const describedKey = selectedKey ?? capabilityStates.find(({ checked }) => checked)?.capability;
const description =
describedKey === undefined ? tPermissions("matrix.noneDescription") : (permissionDescriptions[describedKey] ?? "");
return (
<Stack gap={6}>
<Group gap="sm" wrap="wrap" align="center" justify="space-between">
<Group gap={6} wrap="nowrap">
{isDanger && <IconShieldExclamation size={18} color="var(--mantine-color-red-8)" />}
<Text fw={500} c={isDanger ? "red.8" : undefined}>
{tPermissions(`${category}.title`)}
</Text>
</Group>
<Group gap="md" wrap="wrap" align="center">
<SegmentedControl
size="sm"
data={data}
value={String(level)}
disabled={disabledByAdmin}
aria-label={tPermissions(`${category}.title`)}
onChange={(value) => applyMatrixLevel(form, category, Number(value))}
/>
{/* Fixed-width column so every level control lines up, whatever capabilities a row has. */}
<Box w={{ base: "auto", sm: 230 }}>
<Stack gap={4}>
{capabilityStates.map(({ capability, checked, implied }) => (
<Tooltip
key={capability}
label={tPermissions("matrix.capabilityIncluded", { level: data[level]?.label ?? "" })}
disabled={!implied}
>
<Checkbox
size="sm"
label={permissionLabels[capability] ?? capability}
checked={checked}
disabled={disabledByAdmin || implied}
onChange={(event) => applyMatrixCapability(form, capability, event.currentTarget.checked)}
/>
</Tooltip>
))}
</Stack>
</Box>
</Group>
</Group>
<Text size="xs" c="dimmed">
{description}
</Text>
{disabledByAdmin && (
<Text size="xs" c="dimmed">
{tPermissions("matrix.grantedViaAdmin")}
</Text>
)}
<MatrixImpliedHint category={category} permissionLabels={permissionLabels} />
</Stack>
);
};
interface PermissionMatrixProps {
permissionLabels: PermissionLabels;
permissionDescriptions: PermissionLabels;
}
export const PermissionMatrix = ({ permissionLabels, permissionDescriptions }: PermissionMatrixProps) => (
<Card withBorder p="md">
<Stack gap="md">
{objectKeys(permissionMatrix).map((category, index) => (
<Stack key={category} gap="md">
{index > 0 && <Divider />}
<MatrixRow
category={category}
permissionLabels={permissionLabels}
permissionDescriptions={permissionDescriptions}
/>
</Stack>
))}
</Stack>
</Card>
);
export const PresetButtons = () => {
const form = useFormContext();
const tPermissions = useScopedI18n("group.permission");
const activePreset = getActivePreset(form.getValues());
return (
<Group gap="xs" wrap="wrap">
<Text size="sm" fw={500}>
{tPermissions("matrix.preset.label")}
</Text>
{(["viewer", "editor", "admin"] as const).map((preset) => (
<Tooltip key={preset} label={tPermissions(`matrix.preset.${preset}Description`)}>
<Button
size="xs"
variant={activePreset === preset ? "filled" : "default"}
onClick={() => applyPreset(form, preset)}
>
{tPermissions(`matrix.preset.${preset}`)}
</Button>
</Tooltip>
))}
{activePreset === null && <Badge variant="light">{tPermissions("matrix.preset.custom")}</Badge>}
</Group>
);
};
interface EffectivePermissionPreviewProps {
permissionLabels: PermissionLabels;
}
export const EffectivePermissionPreview = ({ permissionLabels }: EffectivePermissionPreviewProps) => {
const form = useFormContext();
const tPermissions = useScopedI18n("group.permission");
const [opened, { toggle }] = useDisclosure(false);
const panelId = useId();
const values = form.getValues();
const checkedKeys = groupPermissionKeys.filter((key) => values[key]);
const effective = getPermissionsWithChildren(checkedKeys);
const grouped = objectKeys(permissionMatrix)
.map((category) => ({
category,
keys: effective.filter((key) => key === category || key.startsWith(`${category}-`)),
}))
.filter(({ keys }) => keys.length > 0);
return (
<Card withBorder p="md">
<UnstyledButton onClick={toggle} w="100%" aria-expanded={opened} aria-controls={panelId}>
<Group justify="space-between" wrap="nowrap">
<Stack gap={0}>
<Text fw={500}>{tPermissions("matrix.effective.title")}</Text>
<Text size="xs" c="dimmed">
{tPermissions("matrix.effective.description")}
</Text>
</Stack>
<Badge variant="light">{effective.length}</Badge>
</Group>
</UnstyledButton>
<Collapse id={panelId} expanded={opened}>
<Stack gap="xs" mt="sm">
{grouped.length === 0 ? (
<Text size="sm" c="dimmed">
{tPermissions("matrix.effective.empty")}
</Text>
) : (
grouped.map(({ category, keys }) => (
<Group key={category} gap="xs" wrap="wrap" align="flex-start">
<Text size="sm" fw={500}>
{tPermissions(`${category}.title`)}
</Text>
<Badge size="sm" variant="light">
{keys.length}
</Badge>
<Text size="sm" c="dimmed">
{keys.map((key) => permissionLabels[key] ?? key).join(", ")}
</Text>
</Group>
))
)}
</Stack>
</Collapse>
</Card>
);
return <Switch {...props} />;
};
interface SaveAffixProps {
@@ -410,10 +80,8 @@ export const SaveAffix = ({ groupId }: SaveAffixProps) => {
},
{
onSuccess: () => {
// setInitialValues moves the snapshot so Discard returns here, but only resetDirty clears
// the dirty flags that keep the unsaved-changes bar on screen.
// Set new initial values for discard and reset dirty state
form.setInitialValues(values);
form.resetDirty(values);
showSuccessNotification({
title: tNotification("success.title"),
message: tNotification("success.message"),
@@ -1,5 +1,6 @@
import React from "react";
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
@@ -8,14 +9,7 @@ import type { GroupPermissionKey } from "@homarr/definitions";
import { groupPermissions } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import type { PermissionLabels } from "./_group-permission-form";
import {
EffectivePermissionPreview,
PermissionForm,
PermissionMatrix,
PresetButtons,
SaveAffix,
} from "./_group-permission-form";
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
interface GroupPermissionsPageProps {
params: Promise<{
@@ -32,21 +26,27 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
}
const group = await api.group.getById({ id: params.id });
const tPermissions = await getScopedI18n("group.permission");
const t = await getI18n();
const { permissionLabels, permissionDescriptions } = await buildPermissionTextsAsync();
return (
<Stack>
<Title>{t("management.page.group.setting.permissions.title")}</Title>
<PermissionForm initialPermissions={group.permissions}>
<Stack pos="relative">
<PresetButtons />
{objectKeys(groupPermissions).map((group) => {
const isDanger = group === "admin";
<PermissionMatrix permissionLabels={permissionLabels} permissionDescriptions={permissionDescriptions} />
<EffectivePermissionPreview permissionLabels={permissionLabels} />
return (
<Stack key={group} gap="sm">
<Title order={2} c={isDanger ? "red.8" : undefined}>
{tPermissions(`${group}.title`)}
</Title>
<PermissionCard isDanger={isDanger} group={group} />
</Stack>
);
})}
<SaveAffix groupId={group.id} />
</Stack>
@@ -55,21 +55,66 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
);
}
const buildPermissionTextsAsync = async () => {
const permissionLabels: PermissionLabels = {};
const permissionDescriptions: PermissionLabels = {};
interface PermissionCardProps {
group: keyof typeof groupPermissions;
isDanger: boolean;
}
for (const category of objectKeys(groupPermissions)) {
const tItem = await getScopedI18n(`group.permission.${category}.item`);
const item = groupPermissions[category];
const suffixes = typeof item !== "boolean" ? item : (["admin"] as const);
const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
const t = await getScopedI18n(`group.permission.${group}.item`);
const item = groupPermissions[group];
const permissions = typeof item !== "boolean" ? item : ([group] as "admin"[]);
suffixes.forEach((suffix) => {
const key = (typeof item !== "boolean" ? `${category}-${suffix}` : "admin") as GroupPermissionKey;
permissionLabels[key] = tItem(`${suffix}.label`);
permissionDescriptions[key] = tItem(`${suffix}.description`);
});
return (
<Card
p="md"
style={{
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
}}
>
<Stack gap="sm">
{permissions.map((permission, index) => (
<React.Fragment key={permission}>
<PermissionRow
name={createGroupPermissionKey(group, permission)}
label={t(`${permission}.label`)}
description={t(`${permission}.description`)}
/>
{index < permissions.length - 1 && (
<CardSection>
<Divider />
</CardSection>
)}
</React.Fragment>
))}
</Stack>
</Card>
);
};
const createGroupPermissionKey = (group: keyof typeof groupPermissions, permission: string): GroupPermissionKey => {
if (typeof groupPermissions[group] === "boolean") {
return group as GroupPermissionKey;
}
return { permissionLabels, permissionDescriptions };
return `${group}-${permission}` as GroupPermissionKey;
};
interface PermissionRowProps {
name: GroupPermissionKey;
label: string;
description: string;
}
const PermissionRow = ({ name, label, description }: PermissionRowProps) => {
return (
<Group justify="space-between" align="center" wrap="nowrap">
<Stack gap={0}>
<Text fw={500}>{label}</Text>
<Text c="gray.5">{description}</Text>
</Stack>
<PermissionSwitch name={name} />
</Group>
);
};
@@ -227,9 +227,7 @@ const LoadedBoardItemContent = ({
(host: HTMLDivElement | null) => {
if (!host || !surfacePortalTarget || isAdvanced) return;
cancelPreviewEntrance();
if (host.childNodes.length !== 1 || host.firstChild !== surfacePortalTarget) {
host.replaceChildren(surfacePortalTarget);
}
host.append(surfacePortalTarget);
},
[cancelPreviewEntrance, isAdvanced, surfacePortalTarget],
);
@@ -3,16 +3,16 @@
import { Suspense, use } from "react";
import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary";
import type { FallbackProps } 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";
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
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;
@@ -29,38 +29,42 @@ export const loadWidgetEditModal = () => {
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
let integrationEditFormPromise: Promise<typeof IntegrationEditFormModule> | undefined;
let integrationEditModalPromise: Promise<typeof IntegrationEditModalModule> | undefined;
const loadIntegrationEditForm = () => {
if (integrationEditFormPromise) return integrationEditFormPromise;
const loadIntegrationEditModal = () => {
if (integrationEditModalPromise) return integrationEditModalPromise;
const currentPromise = import("~/components/integration/embedded-integration-edit-form");
integrationEditFormPromise = currentPromise;
const currentPromise = import("~/components/integration/embedded-integration-edit-modal");
integrationEditModalPromise = currentPromise;
void currentPromise.catch(() => {
if (integrationEditFormPromise === currentPromise) integrationEditFormPromise = undefined;
if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined;
});
return currentPromise;
};
const LazyIntegrationEditFormContent = (props: EmbeddedIntegrationEditFormProps) => {
const { EmbeddedIntegrationEditForm } = use(loadIntegrationEditForm());
return <EmbeddedIntegrationEditForm {...props} />;
const LazyIntegrationEditModalContent = (props: {
actions: { closeModal: () => void };
innerProps: EmbeddedIntegrationEditModalProps;
}) => {
const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal());
const Component = EmbeddedIntegrationEditModal.component;
return <Component {...props} />;
};
const IntegrationEditErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => (
<Center py="xl">
<WidgetError
error={error}
resetErrorBoundary={() => {
integrationEditFormPromise = undefined;
resetErrorBoundary();
}}
/>
</Center>
);
const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => (
<ErrorBoundary FallbackComponent={IntegrationEditErrorFallback}>
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">
@@ -68,10 +72,15 @@ const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => (
</Center>
}
>
<LazyIntegrationEditFormContent {...props} />
<LazyIntegrationEditModalContent {...props} />
</Suspense>
</ErrorBoundary>
);
)).withOptions({
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: true,
});
interface LazyWidgetEditModalContentProps {
actions: { closeModal: () => void };
@@ -81,12 +90,17 @@ interface LazyWidgetEditModalContentProps {
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal());
const Component = WidgetEditModal.component;
const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal);
return (
<Component
{...props}
innerProps={{
...props.innerProps,
integrationEditForm: LazyIntegrationEditForm,
onEditIntegration: (integrationId) =>
openIntegrationEditModal({
integrationId,
onSuccess: props.innerProps.onIntegrationSaved,
}),
}}
/>
);
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
import { Group, Stack, Text, TextInput, ThemeIcon, UnstyledButton } from "@mantine/core";
import {
IconApps,
IconBox,
@@ -26,7 +26,7 @@ import type {
UniversalCreateActionGroup,
UniversalCreateActionKey,
} from "./universal-create-actions";
import { getUniversalCreateActionDefinitions } from "./universal-create-actions";
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
import type { SetupMetricEntryPoint } from "./setup-analytics";
import { useSetupAnalytics } from "./setup-analytics";
@@ -52,6 +52,7 @@ export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ ac
const router = useRouter();
const t = useScopedI18n("universalCreate");
const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
const [query, setQuery] = useState("");
const trackedOpen = useRef(false);
const boardActions = innerProps.boardActions;
const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header");
@@ -117,43 +118,58 @@ export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ ac
keywords: t(`action.${definition.key}.keywords`).split(" "),
}) satisfies RankedUniversalCreateAction,
);
const orderedActions = resolvedActions.toSorted((left, right) => right.priority - left.priority);
const visibleActions = filterAndRankUniversalCreateActions(resolvedActions, query);
const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
return (
<Stack gap="md">
{groups.map((group) => {
const groupActions = orderedActions.filter((action) => action.group === group);
if (groupActions.length === 0) return null;
<TextInput
data-autofocus
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={t("search.placeholder")}
aria-label={t("search.label")}
/>
return (
<Stack key={group} gap={4}>
<Text c="dimmed" fw={600} size="xs" tt="uppercase" px="sm">
{t(`group.${group}`)}
</Text>
{groupActions.map((action) => {
const Icon = actionIcons[action.key];
return (
<UnstyledButton key={action.key} className={classes.action} onClick={() => invokeAction(action.key)}>
<Group wrap="nowrap" align="flex-start">
<ThemeIcon className={classes.actionIcon} variant="light" radius="md">
<Icon size={18} stroke={1.5} />
</ThemeIcon>
<Stack gap={1}>
<Text fw={600} size="sm">
{action.name}
</Text>
<Text c="dimmed" size="xs">
{action.description}
</Text>
</Stack>
</Group>
</UnstyledButton>
);
})}
</Stack>
);
})}
{visibleActions.length === 0 ? (
<Text c="dimmed" ta="center" py="lg">
{t("search.noResults")}
</Text>
) : (
groups.map((group) => {
const groupActions = visibleActions.filter((action) => action.group === group);
if (groupActions.length === 0) return null;
return (
<Stack key={group} gap={4}>
<Text c="dimmed" fw={600} size="xs" tt="uppercase" px="sm">
{t(`group.${group}`)}
</Text>
{groupActions.map((action) => {
const Icon = actionIcons[action.key];
return (
<UnstyledButton key={action.key} className={classes.action} onClick={() => invokeAction(action.key)}>
<Group wrap="nowrap" align="flex-start">
<ThemeIcon className={classes.actionIcon} variant="light" radius="md">
<Icon size={18} stroke={1.5} />
</ThemeIcon>
<Stack gap={1}>
<Text fw={600} size="sm">
{action.name}
</Text>
<Text c="dimmed" size="xs">
{action.description}
</Text>
</Stack>
</Group>
</UnstyledButton>
);
})}
</Stack>
);
})
)}
</Stack>
);
}).withOptions({
@@ -1,140 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Button, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getCustomWidgetSourceSetups } from "@homarr/custom-widgets/core";
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
import {
createCustomWidgetSourceSetupValues,
CustomWidgetSourceSetupPanel,
isCustomWidgetSourceSetupReady,
} from "@homarr/custom-widgets/workbench";
import type {
CustomWidgetSourceSetupMessages,
CustomWidgetSourceSetupValue,
} from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface CustomWidgetSourceSetupDialogProps {
definitionId: string;
opened: boolean;
onClose(): void;
}
export function CustomWidgetSourceSetupDialog({
definitionId,
opened,
onClose,
}: CustomWidgetSourceSetupDialogProps) {
const t = useScopedI18n("customWidget");
const router = useRouter();
const utils = clientApi.useUtils();
const definitionQuery = clientApi.customWidget.get.useQuery({ id: definitionId }, { enabled: opened });
const configureMutation = clientApi.customWidget.sourceConfigure.useMutation();
const [saving, setSaving] = useState(false);
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
const setups = useMemo(
() => getCustomWidgetSourceSetups(definitionQuery.data?.sources ?? {}, definitionQuery.data?.secrets ?? []),
[definitionQuery.data],
);
useEffect(() => {
if (opened && definitionQuery.data) setValues(createCustomWidgetSourceSetupValues(setups));
}, [definitionQuery.data, opened, setups]);
const messages: CustomWidgetSourceSetupMessages = {
title: t("importReview.sourceSetup.title"),
description: t("sourceSetupDialog.description"),
suggestedUrl: t("importReview.sourceSetup.suggestedUrl"),
baseUrl: t("workbench.sources.baseUrl"),
networkScope: t("workbench.sources.networkScope"),
authentication: t("workbench.sources.authentication"),
confirmUrl: t("importReview.sourceSetup.confirmUrl"),
ready: t("importReview.sourceSetup.ready"),
needsUrl: t("importReview.sourceSetup.needsUrl"),
credentialsMissing: t("importReview.sourceSetup.credentialsMissing"),
credentialsOptional: t("sourceSetupDialog.credentialsRequired"),
configured: t("workbench.sources.configured"),
secret: (kind) => t(`importReview.credentials.field.${kind}`),
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
};
const save = async () => {
if (!isCustomWidgetSourceSetupReady(setups, values) || saving) return;
setSaving(true);
try {
for (const setup of setups) {
const value = values[setup.sourceId];
if (!value) continue;
const secrets = Object.entries(value.secrets).flatMap(([kind, secret]) =>
secret?.trim() ? [{ sourceId: setup.sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
);
await configureMutation.mutateAsync({
definitionId,
sourceId: setup.sourceId,
baseUrl: value.baseUrl,
networkScope: value.networkScope,
secrets,
});
}
showSuccessNotification({
title: t("action.configureSources"),
message: t("notification.sourcesConfigured"),
});
await utils.customWidget.get.invalidate({ id: definitionId });
await utils.customWidget.list.invalidate();
await utils.customWidget.available.invalidate();
await utils.widget.customApi.getData.invalidate();
await revalidatePathActionAsync("/manage/custom-widgets");
router.refresh();
onClose();
} catch (error) {
showErrorNotification({
title: t("action.configureSources"),
message: error instanceof Error ? error.message : t("notification.sourceConfigurationError"),
});
} finally {
setSaving(false);
}
};
return (
<Modal opened={opened} onClose={onClose} title={t("sourceSetupDialog.title")} size="lg">
{definitionQuery.isPending ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : definitionQuery.error ? (
<Text c="red" size="sm">
{definitionQuery.error.message}
</Text>
) : (
<Stack gap="md">
<CustomWidgetSourceSetupPanel
setups={setups}
values={values}
messages={messages}
onChange={(sourceId, value) => setValues((current) => ({ ...current, [sourceId]: value }))}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={saving}>
{t("sourceSetupDialog.cancel")}
</Button>
<Button
onClick={() => void save()}
loading={saving}
disabled={!isCustomWidgetSourceSetupReady(setups, values)}
>
{t("sourceSetupDialog.save")}
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
@@ -1,90 +0,0 @@
"use client";
import { useImperativeHandle, useRef } from "react";
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
import type { EditIntegrationFormHandle } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
export type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
export const EmbeddedIntegrationEditForm = ({
integrationId,
handleRef,
onSuccess,
}: EmbeddedIntegrationEditFormProps) => {
const t = useI18n();
const integrationFormRef = useRef<EditIntegrationFormHandle>(null);
const {
data: integration,
isPending,
isError,
refetch,
} = clientApi.integration.byId.useQuery({
id: integrationId,
});
useImperativeHandle(
handleRef,
() => ({
submitIfDirty: async () => {
if (!integrationFormRef.current?.isDirty()) {
return true;
}
return integrationFormRef.current.submit();
},
}),
[],
);
if (isPending) {
return (
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
<Skeleton height={36} width="60%" />
<Skeleton height={60} />
<Skeleton height={120} />
</Stack>
);
}
if (isError || !integration) {
return (
<Alert color="red" title={t("common.error")}>
<Stack gap="sm">
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
return (
<Stack>
<Group gap="sm" wrap="nowrap">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{getIntegrationName(integration.kind)}
</Text>
</Stack>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.integration.propagationNotice")}
</Alert>
<EditIntegrationForm integration={integration} hideButtons formRef={integrationFormRef} onSuccess={onSuccess} />
</Stack>
);
};
@@ -0,0 +1,86 @@
"use client";
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
export interface EmbeddedIntegrationEditModalProps {
integrationId: string;
onSuccess?: () => void;
}
export const EmbeddedIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
({ actions, innerProps }) => {
const t = useI18n();
const {
data: integration,
isPending,
isError,
refetch,
} = clientApi.integration.byId.useQuery({
id: innerProps.integrationId,
});
if (isPending) {
return (
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
<Skeleton height={36} width="60%" />
<Skeleton height={60} />
<Skeleton height={120} />
</Stack>
);
}
if (isError || !integration) {
return (
<Alert color="red" title={t("common.error")}>
<Stack gap="sm">
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
return (
<Stack>
<Group gap="sm" wrap="nowrap">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{getIntegrationName(integration.kind)}
</Text>
</Stack>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.integration.propagationNotice")}
</Alert>
<EditIntegrationForm
integration={integration}
embedded
onSuccess={() => {
innerProps.onSuccess?.();
actions.closeModal();
}}
/>
</Stack>
);
},
).withOptions({
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: true,
});
@@ -1,13 +1,12 @@
"use client";
import { useState } from "react";
import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
import { IconArrowLeft, IconX } from "@tabler/icons-react";
import { Group, ScrollArea, Title } from "@mantine/core";
import { IconArrowLeft } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
@@ -20,15 +19,11 @@ interface IntegrationSelectModalProps {
enableMockIntegration?: boolean;
allowedKinds?: readonly IntegrationKind[];
completionBoardId?: string;
initialKind?: IntegrationKind;
initialUrl?: string;
initialName?: string;
}
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? null);
const [step, setStep] = useState<"select" | "form">("select");
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(null);
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
const handleSelect = (kind: IntegrationKind) => {
@@ -40,7 +35,6 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
setStep("select");
setSelectedKind(null);
};
const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
const handleSuccess = (result?: CreatedIntegrationResult) => {
actions.closeModal();
@@ -52,29 +46,14 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
};
if (step === "form" && selectedKind) {
let HeaderIcon = IconArrowLeft;
let headerLabel = t("common.action.previous");
if (innerProps.initialKind) {
HeaderIcon = IconX;
headerLabel = t("common.action.close");
}
return (
<ScrollArea.Autosize mah="80vh">
<UnstyledButton mb="md" onClick={handleFormBack} aria-label={headerLabel}>
<Group gap="xs">
<HeaderIcon size={18} />
<IntegrationAvatar kind={selectedKind} size="sm" />
<Title order={4}>{getIntegrationName(selectedKind)}</Title>
</Group>
</UnstyledButton>
<NewIntegrationForm
kind={selectedKind}
initialUrl={innerProps.initialUrl}
initialName={innerProps.initialName}
onSuccess={handleSuccess}
onCancel={handleFormBack}
/>
<Group gap="xs" mb="md" style={{ cursor: "pointer" }} onClick={handleBack}>
<IconArrowLeft size={18} />
<IntegrationAvatar kind={selectedKind} size="sm" />
<Title order={4}>{getIntegrationName(selectedKind)}</Title>
</Group>
<NewIntegrationForm kind={selectedKind} onSuccess={handleSuccess} onCancel={handleBack} />
</ScrollArea.Autosize>
);
}
@@ -37,7 +37,6 @@ export interface ManageCollectionItemProps {
metadata?: ReactNode;
badges?: ReactNode;
actions?: ReactNode;
actionsAlignment?: "flex-start" | "center";
}
export const ManageCollectionItem = ({
@@ -47,7 +46,6 @@ export const ManageCollectionItem = ({
metadata,
badges,
actions,
actionsAlignment = "flex-start",
}: ManageCollectionItemProps) => (
<Paper component="li" p="sm" withBorder radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
@@ -61,7 +59,7 @@ export const ManageCollectionItem = ({
{metadata}
</Stack>
{actions && (
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0, alignSelf: actionsAlignment }}>
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
{actions}
</Group>
)}
-2
View File
@@ -12,7 +12,6 @@ import { iconsRouter } from "./router/icons";
import { infoRouter } from "./router/info";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { airQualityRouter } from "./router/widgets/air-quality";
import { bazarrRouter } from "./router/widgets/bazarr";
import { serverSettingsRouter } from "./router/serverSettings";
import { beszelRouter } from "./router/widgets/beszel";
@@ -36,7 +35,6 @@ export const mcpRouter = createTRPCRouter({
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
airQuality: airQualityRouter,
bazarr: bazarrRouter,
serverSettings: serverSettingsRouter,
beszel: beszelRouter,
+2 -14
View File
@@ -18,8 +18,7 @@ import { AppAccessControl } from "./app/app-access-control";
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
export const appRouter = createTRPCRouter({
getPaginated: permissionRequiredProcedure
.requiresPermission("app-modify-all")
getPaginated: protectedProcedure
.meta({
openapi: {
method: "GET",
@@ -51,8 +50,7 @@ export const appRouter = createTRPCRouter({
totalCount,
};
}),
all: permissionRequiredProcedure
.requiresPermission("app-modify-all")
all: protectedProcedure
.input(z.void())
.output(z.array(selectAppSchema))
.meta({
@@ -90,16 +88,6 @@ export const appRouter = createTRPCRouter({
},
})
.query(({ ctx, input }) => {
if (
!ctx.session.user.permissions.includes("app-modify-all") &&
!ctx.session.user.permissions.includes("board-modify-all")
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Permission denied",
});
}
return ctx.db.query.apps.findMany({
where: like(apps.name, `%${input.query}%`),
orderBy: asc(apps.name),
@@ -23,11 +23,6 @@ const dockerContainerTargetSchema = z.object({
});
const dockerContainerTargetsSchema = z.array(dockerContainerTargetSchema).min(1).max(100);
const normalizeGetContainersInput = (input: { endpointIds?: string[] } | undefined) => {
if (!input?.endpointIds || input.endpointIds.length === 0) return {};
return input;
};
export const dockerRouter = createTRPCRouter({
reconcileServices: permissionRequiredProcedure
.requiresPermission("admin")
@@ -70,14 +65,12 @@ export const dockerRouter = createTRPCRouter({
.meta({
mcp: {
enabled: true,
description:
"List Docker containers with their state, image, CPU/memory usage, and ports. Optionally filter by endpoint IDs; omitted or empty means all endpoints.",
description: "List all Docker containers with their state, image, CPU/memory usage, and ports",
},
})
.concat(dockerMiddleware())
.input(z.object({ endpointIds: z.array(z.string().min(1)).max(100).optional() }).optional())
.query(async ({ input }) => {
const innerHandler = dockerContainersRequestHandler.handler(normalizeGetContainersInput(input));
.query(async () => {
const innerHandler = dockerContainersRequestHandler.handler({});
const result = await innerHandler.getDataAsync();
const { data, timestamp } = result;
+9 -36
View File
@@ -302,43 +302,16 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
// Stored exactly as selected - implied permissions are resolved at read time through
// getPermissionsWithChildren, so nothing is silently escalated here.
const permissions = [...new Set(input.permissions)];
await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId));
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction.delete(schema.groupPermissions).where(eq(schema.groupPermissions.groupId, input.groupId));
if (permissions.length === 0) {
return;
}
await transaction.insert(schema.groupPermissions).values(
permissions.map((permission) => ({
groupId: input.groupId,
permission,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId)).run();
if (permissions.length === 0) {
return;
}
transaction
.insert(groupPermissions)
.values(
permissions.map((permission) => ({
groupId: input.groupId,
permission,
})),
)
.run();
});
},
});
if (input.permissions.length > 0) {
await ctx.db.insert(groupPermissions).values(
input.permissions.map((permission) => ({
groupId: input.groupId,
permission,
})),
);
}
}),
transferOwnership: permissionRequiredProcedure
.requiresPermission("admin")
+4 -46
View File
@@ -1,6 +1,4 @@
import { isProviderEnabled } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import type { Database } from "@homarr/db";
import { db, eq, inArray, or } from "@homarr/db";
import {
apps,
@@ -8,46 +6,16 @@ import {
boardUserPermissions,
groupMembers,
groups,
integrationGroupPermissions,
integrationUserPermissions,
integrations,
invites,
medias,
searchEngines,
users,
} from "@homarr/db/schema";
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
import type { TranslationObject } from "@homarr/translation";
import { createTRPCRouter, publicProcedure } from "../trpc";
const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) => {
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId),
});
const accessibleIntegrations = await db.query.integrations.findMany({
columns: {
id: true,
},
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, userId),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
});
return accessibleIntegrations
.filter((integration) => constructIntegrationPermissions(integration, null).hasFullAccess)
.map((integration) => integration.id);
};
interface HomeStatistic {
titleKey: keyof TranslationObject["management"]["page"]["home"]["statistic"];
subtitleKey: keyof TranslationObject["management"]["page"]["home"]["statisticLabel"];
@@ -124,30 +92,20 @@ export const homeRouter = createTRPCRouter({
});
}
// A card is only shown when the page behind it is reachable, and it counts only what the user is
// allowed to see. The rules come from @homarr/definitions so this cannot drift from the pages.
const permissions = ctx.session?.user.permissions ?? [];
const hasGlobalFullIntegrationAccess = permissions.includes("integration-full-all");
const fullAccessIntegrationIds =
hasGlobalFullIntegrationAccess || !ctx.session?.user
? []
: await getFullAccessIntegrationIdsAsync(ctx.db, ctx.session.user.id);
const integrationAccess = getIntegrationManagementAccess(permissions, fullAccessIntegrationIds.length > 0);
if (integrationAccess.canAccess) {
if (ctx.session?.user.permissions.includes("integration-create")) {
statistics.push({
titleKey: "integration",
subtitleKey: "resources",
count: integrationAccess.canManageAll ? await db.$count(integrations) : fullAccessIntegrationIds.length,
count: await db.$count(integrations),
path: "/manage/integrations",
});
}
const appAccess = getAppManagementAccess(permissions);
if (appAccess.canAccess) {
if (ctx.session?.user.permissions.includes("app-create")) {
statistics.push({
titleKey: "app",
subtitleKey: "resources",
count: appAccess.canManageAll ? await db.$count(apps) : 0,
count: await db.$count(apps),
path: "/manage/apps",
});
}
@@ -1,13 +1,12 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database, SQL } from "@homarr/db";
import type { Database } from "@homarr/db";
import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
import {
apps,
@@ -18,7 +17,7 @@ import {
integrationSecrets,
integrationUserPermissions,
} from "@homarr/db/schema";
import type { GroupPermissionKey, IntegrationPermission, IntegrationSecretKind } from "@homarr/definitions";
import type { IntegrationSecretKind } from "@homarr/definitions";
import {
getIntegrationKindsByCategory,
getPermissionsWithParents,
@@ -72,18 +71,80 @@ export const integrationRouter = createTRPCRouter({
},
})
.query(async ({ ctx }) => {
return await getAccessibleIntegrationsAsync(ctx);
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
});
const integrations = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
});
return integrations
.map((integration) => {
const permissions = constructIntegrationPermissions(integration, ctx.session);
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions,
};
})
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
allThatSupportSearch: protectedProcedure.query(async ({ ctx }) => {
return await getAccessibleIntegrationsAsync(
ctx,
inArray(
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
});
const integrationsFromDb = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
where: inArray(
integrations.kind,
objectEntries(integrationDefs)
.filter(([_, integration]) => [...integration.category].includes("search"))
.map(([kind, _]) => kind),
),
);
});
return integrationsFromDb
.map((integration) => {
const permissions = constructIntegrationPermissions(integration, ctx.session);
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions,
};
})
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
allOfGivenCategory: protectedProcedure
.input(
@@ -92,10 +153,42 @@ export const integrationRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
return await getAccessibleIntegrationsAsync(
ctx,
inArray(integrations.kind, getIntegrationKindsByCategory(input.category)),
);
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
});
const intergrationKinds = getIntegrationKindsByCategory(input.category);
const integrationsFromDb = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
where: inArray(integrations.kind, intergrationKinds),
});
return integrationsFromDb
.map((integration) => {
const permissions = constructIntegrationPermissions(integration, ctx.session);
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions,
};
})
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
search: protectedProcedure
.meta({
@@ -112,29 +205,11 @@ export const integrationRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
});
const integrationsFromDb = await ctx.db.query.integrations.findMany({
return await ctx.db.query.integrations.findMany({
where: like(integrations.name, `%${input.query}%`),
orderBy: asc(integrations.name),
with: getIntegrationAccessRelationsAsync(
ctx.session.user.id,
groupsOfCurrentUser.map((group) => group.groupId),
),
limit: input.limit,
});
return integrationsFromDb
.filter((integration) => hasIntegrationQueryAccess(ctx.session, integration))
.toSorted(
(integrationA, integrationB) =>
integrationA.name.localeCompare(integrationB.name) || integrationA.id.localeCompare(integrationB.id),
)
.slice(0, input.limit)
.map(
({ userPermissions: _userPermissions, groupPermissions: _groupPermissions, ...integration }) => integration,
);
}),
// This is used to get the integrations by their ids it's public because it's needed to get integrations data in the boards
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
@@ -699,66 +774,6 @@ export const integrationRouter = createTRPCRouter({
}),
});
const globalIntegrationPermissions = [
"integration-use-all",
"integration-interact-all",
"integration-full-all",
] satisfies GroupPermissionKey[];
const hasGlobalIntegrationAccess = (session: Session | null) =>
globalIntegrationPermissions.some((permission) => session?.user.permissions.includes(permission) ?? false);
const hasIntegrationQueryAccess = (
session: Session | null,
integration: {
userPermissions: { permission: IntegrationPermission }[];
groupPermissions: { permission: IntegrationPermission }[];
},
) => hasGlobalIntegrationAccess(session) || constructIntegrationPermissions(integration, session).hasUseAccess;
const getIntegrationAccessRelationsAsync = (userId: string, groupIds: string[]) => ({
userPermissions: {
where: eq(integrationUserPermissions.userId, userId),
},
groupPermissions: {
where: inArray(integrationGroupPermissions.groupId, groupIds),
},
});
const getAccessibleIntegrationsAsync = async (ctx: { db: Database; session: Session }, where?: SQL) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
});
const integrationsFromDb = await ctx.db.query.integrations.findMany({
where,
with: getIntegrationAccessRelationsAsync(
ctx.session.user.id,
groupsOfCurrentUser.map((group) => group.groupId),
),
});
return integrationsFromDb
.filter((integration) => hasIntegrationQueryAccess(ctx.session, integration))
.map((integration) => {
const permissions = constructIntegrationPermissions(integration, ctx.session);
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions,
};
})
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind) ||
integrationA.name.localeCompare(integrationB.name) ||
integrationA.id.localeCompare(integrationB.id),
);
};
interface IntegrationRouterContext {
db: Database;
session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[2];
+3 -54
View File
@@ -33,7 +33,7 @@ describe("all should return all apps", () => {
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-modify-all"]),
session: createDefaultSession(),
});
await db.insert(apps).values([
@@ -74,20 +74,6 @@ describe("all should return all apps", () => {
// Assert
await expect(actAsync()).rejects.toThrow("UNAUTHORIZED");
});
test("should throw FORBIDDEN without app-modify-all permission", async () => {
// Arrange
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createDefaultSession(),
});
// Act
const actAsync = async () => await caller.all();
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
});
describe("getPaginated should return apps to authenticated users", () => {
@@ -101,12 +87,12 @@ describe("getPaginated should return apps to authenticated users", () => {
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("UNAUTHORIZED");
});
test("should return apps with app-modify-all permission", async () => {
test("should return apps without board permissions", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-modify-all"]),
session: createDefaultSession(),
});
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
@@ -115,43 +101,6 @@ describe("getPaginated should return apps to authenticated users", () => {
items: [{ id: "1" }],
});
});
test("should throw FORBIDDEN without app-modify-all permission", async () => {
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createDefaultSession(),
});
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
});
});
describe("search should require app-modify-all or board-modify-all permission", () => {
test("should throw FORBIDDEN without app-modify-all or board-modify-all permission", async () => {
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createDefaultSession(),
});
await expect(caller.search({ query: "Homarr" })).rejects.toThrow("Permission denied");
});
test("should return apps with board-modify-all permission", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["board-modify-all"]),
});
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
const result = await caller.search({ query: "Homarr" });
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
});
});
describe("byId should return an app by id", () => {
-159
View File
@@ -449,165 +449,6 @@ describe("savePermissions should save permissions for group", () => {
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-all"]);
});
test("with a standalone create permission it should not escalate to its parent permission", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "Group",
position: 1,
});
// Act
await caller.savePermissions({
groupId,
permissions: ["board-create"],
});
// Assert
const permissions = await db.query.groupPermissions.findMany({
where: eq(groupPermissions.groupId, groupId),
});
expect(permissions.map(({ permission }) => permission)).toEqual(["board-create"]);
});
test.each([["app-create"], ["integration-create"], ["search-engine-create"], ["media-upload"]] as const)(
"with %s alone it should persist exactly that permission",
async (permission) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "Group",
position: 1,
});
// Act
await caller.savePermissions({ groupId, permissions: [permission] });
// Assert
const permissions = await db.query.groupPermissions.findMany({
where: eq(groupPermissions.groupId, groupId),
});
expect(permissions.map(({ permission: key }) => key)).toEqual([permission]);
},
);
test("with a create permission and its parent it should persist exactly two rows", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "Group",
position: 1,
});
// Act
await caller.savePermissions({
groupId,
permissions: ["app-create", "app-modify-all"],
});
// Assert
const permissions = await db.query.groupPermissions.findMany({
where: eq(groupPermissions.groupId, groupId),
});
expect(permissions.length).toBe(2);
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["app-create", "app-modify-all"]);
});
test("with duplicate permissions it should persist each permission only once", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "Group",
position: 1,
});
// Act
await caller.savePermissions({
groupId,
permissions: ["board-view-all", "board-view-all", "board-modify-all"],
});
// Assert
const permissions = await db.query.groupPermissions.findMany({
where: eq(groupPermissions.groupId, groupId),
});
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["board-modify-all", "board-view-all"]);
});
test("saving the same permissions twice should be idempotent", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "Group",
position: 1,
});
// Act
await caller.savePermissions({ groupId, permissions: ["board-create", "media-upload"] });
await caller.savePermissions({ groupId, permissions: ["board-create", "media-upload"] });
// Assert
const permissions = await db.query.groupPermissions.findMany({
where: eq(groupPermissions.groupId, groupId),
});
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["board-create", "media-upload"]);
});
test("with an empty permission list it should clear all existing permissions", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "Group",
position: 1,
});
await db.insert(groupPermissions).values([
{ groupId, permission: "admin" },
{ groupId, permission: "board-full-all" },
]);
// Act
await caller.savePermissions({
groupId,
permissions: [],
});
// Assert
const permissions = await db.query.groupPermissions.findMany({
where: eq(groupPermissions.groupId, groupId),
});
expect(permissions.length).toBe(0);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
@@ -58,171 +58,6 @@ describe("all should return all integrations", () => {
expect(result[1]!.kind).toBe("homeAssistant");
expect(result.every(({ permissions }) => permissions.hasFullAccess && permissions.hasUseAccess)).toBe(true);
});
test("with integration full access and no per-integration rows should return all integrations with full access", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
{
id: "2",
name: "Home plex server",
kind: "plex",
url: "http://plex.local",
},
]);
const result = await caller.all();
expect(result).toHaveLength(2);
for (const integration of result) {
expect(integration.permissions.hasFullAccess).toBe(true);
}
});
test("with integration use access should return all integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-use-all"]),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
{
id: "2",
name: "Home plex server",
kind: "plex",
url: "http://plex.local",
},
]);
const result = await caller.all();
expect(result.length).toBe(2);
});
test("without global integration access should only return integrations with use access", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
{
id: "2",
name: "Home plex server",
kind: "plex",
url: "http://plex.local",
},
]);
await db.insert(users).values({ id: defaultUserId });
await db.insert(integrationUserPermissions).values({
integrationId: "1",
userId: defaultUserId,
permission: "use",
});
const result = await caller.all();
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
expect(result[0]!.permissions.hasUseAccess).toBe(true);
});
test("with a per-integration interact grant should return the integration with use access", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
{
id: "2",
name: "Home plex server",
kind: "plex",
url: "http://plex.local",
},
]);
await db.insert(users).values({ id: defaultUserId });
await db.insert(integrationUserPermissions).values({
integrationId: "1",
userId: defaultUserId,
permission: "interact",
});
const result = await caller.all();
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
expect(result[0]!.permissions.hasUseAccess).toBe(true);
});
});
describe("search should return accessible integrations", () => {
test("without global integration access should only return integrations with use access as plain rows", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
{
id: "2",
name: "Home plex server",
kind: "plex",
url: "http://plex.local",
},
]);
await db.insert(users).values({ id: defaultUserId });
await db.insert(integrationUserPermissions).values({
integrationId: "1",
userId: defaultUserId,
permission: "use",
});
const result = await caller.search({ query: "Home" });
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
expect(result[0]).not.toHaveProperty("userPermissions");
expect(result[0]).not.toHaveProperty("groupPermissions");
});
});
describe("mediaRequestSearchTargets should return accessible media search integrations", () => {
@@ -1,29 +0,0 @@
import { z } from "zod/v4";
import { env } from "@homarr/common/env";
import { airQualityRequestHandler } from "@homarr/request-handler/air-quality";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const atLocationInput = z.object({
longitude: z.number().min(-180).max(180),
latitude: z.number().min(-90).max(90),
});
export const airQualityRouter = createTRPCRouter({
atLocation: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get current and forecast air quality, UV, pollutant, and pollen data for geographic coordinates. REQUIRED: latitude (-90 to 90) and longitude (-180 to 180).",
},
})
.input(atLocationInput)
.query(async ({ input }) => {
if (env.NO_EXTERNAL_CONNECTION) return null;
const handler = airQualityRequestHandler.handler(input);
return await handler.getDataAsync().then((result) => result.data);
}),
});
-1
View File
@@ -13,7 +13,6 @@ export const widgetRouter = createTRPCRouter({
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
airQuality: lazy(() => import("./air-quality").then((mod) => mod.airQualityRouter)),
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)),
smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)),
+2 -2
View File
@@ -6,8 +6,8 @@ import { weatherRequestHandler } from "@homarr/request-handler/weather";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const atLocationInput = z.object({
longitude: z.number().min(-180).max(180),
latitude: z.number().min(-90).max(90),
longitude: z.number(),
latitude: z.number(),
});
export const weatherRouter = createTRPCRouter({
-1
View File
@@ -9,7 +9,6 @@ vi.mock("@homarr/auth", () => ({}));
const MCP_TOOL_ALLOWLIST = {
query: [
"airQuality_atLocation",
"apiKeys_getAll",
"app_all",
"app_byId",
@@ -336,10 +336,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/whats-up-docker"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/today"
| "/docs/widgets/air-quality"
| "/docs/widgets/countdown"
| "/docs/widgets/timer"
| "/docs/widgets/app"
| "/docs/widgets/iframe"
| "/docs/widgets/video"
@@ -3,10 +3,6 @@ import type { WidgetKind } from "../widget";
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
clock: "clock",
weather: "weather",
today: "today",
airQuality: "air-quality",
countdown: "countdown",
timer: "timer",
app: "app",
iframe: "iframe",
video: "video",
+1 -1
View File
@@ -124,7 +124,7 @@ export const integrationDefs = {
readarr: {
name: "Readarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/png/readarr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg",
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/readarr"),
defaultPort: 8787,
-136
View File
@@ -117,139 +117,3 @@ export const groupPermissionKeys = objectKeys(groupPermissions).reduce((acc, key
}
return acc;
}, [] as GroupPermissionKey[]);
/**
* Describes how each permission category is presented in the group permission matrix UI.
*
* `levels` is an ordered escalation of access: every level implies the one below it through
* {@link groupPermissionParents}, so picking level N grants everything up to N.
*
* `capabilities` are the keys that are NOT part of that ladder - "may add new resources", "may
* upload", "may put any app on a board". They can be granted on their own (e.g. "members may create
* their own boards but not see anyone else's") and some levels already imply them; use
* {@link isCapabilityImpliedByLevel} to detect that instead of hard-coding it.
*
* Only keys that genuinely imply each other may share a ladder. `app-modify-all` implies
* `app-create` but NOT `app-use-all` (only `app-full-all` does), so `app-use-all` is a capability -
* putting it in the ladder would make picking "Modify" silently grant use of every app.
*
* Together, `levels` and `capabilities` cover every key in {@link groupPermissionKeys}, so the
* matrix is a lossless representation of a group's permissions.
*/
export const permissionMatrix = {
board: { levels: ["board-view-all", "board-modify-all", "board-full-all"], capabilities: ["board-create"] },
app: { levels: ["app-modify-all", "app-full-all"], capabilities: ["app-use-all", "app-create"] },
integration: {
levels: ["integration-use-all", "integration-interact-all", "integration-full-all"],
capabilities: ["integration-create"],
},
"search-engine": {
levels: ["search-engine-modify-all", "search-engine-full-all"],
capabilities: ["search-engine-create"],
},
media: { levels: ["media-view-all", "media-full-all"], capabilities: ["media-upload"] },
other: { levels: ["other-view-logs"], capabilities: [] },
admin: { levels: ["admin"], capabilities: [] },
} as const satisfies Record<
string,
{ levels: readonly GroupPermissionKey[]; capabilities: readonly GroupPermissionKey[] }
>;
export type PermissionMatrixCategory = keyof typeof permissionMatrix;
export interface PermissionMatrixCategoryState {
/** 0 = no access, N = every key up to `permissionMatrix[category].levels[N - 1]` */
level: number;
/** The category's granted capability keys, including the ones the level already implies */
capabilities: readonly GroupPermissionKey[];
}
export type PermissionMatrixState = Record<PermissionMatrixCategory, PermissionMatrixCategoryState>;
/**
* Whether the given level already grants a capability through permission inheritance.
* For example board level 3 (`board-full-all`) implies `board-create`.
*/
export const isCapabilityImpliedByLevel = (
category: PermissionMatrixCategory,
capability: GroupPermissionKey,
level: number,
) => {
if (level <= 0) {
return false;
}
return getPermissionsWithChildren([...permissionMatrix[category].levels.slice(0, level)]).includes(capability);
};
export const permissionsToMatrixState = (permissions: GroupPermissionKey[]): PermissionMatrixState => {
const permissionSet = new Set(permissions);
return objectKeys(permissionMatrix).reduce((acc, category) => {
let level = 0;
permissionMatrix[category].levels.forEach((key, index) => {
if (permissionSet.has(key)) {
level = index + 1;
}
});
acc[category] = {
level,
capabilities: permissionMatrix[category].capabilities.filter(
(capability) => permissionSet.has(capability) || isCapabilityImpliedByLevel(category, capability, level),
),
};
return acc;
}, {} as PermissionMatrixState);
};
export const matrixStateToPermissions = (state: PermissionMatrixState): GroupPermissionKey[] => {
return objectKeys(permissionMatrix).reduce((acc, category) => {
const { level, capabilities } = state[category];
acc.push(...permissionMatrix[category].levels.slice(0, level));
// Skip capabilities the chosen level already implies, so the stored permissions stay minimal
// and a state -> permissions -> state round-trip is exact.
acc.push(...capabilities.filter((capability) => !isCapabilityImpliedByLevel(category, capability, level)));
return acc;
}, [] as GroupPermissionKey[]);
};
export interface ManagementSectionAccess {
/** May see and manage every resource of the section */
canManageAll: boolean;
/** May add new resources to the section */
canCreate: boolean;
/** May open the section at all */
canAccess: boolean;
}
/**
* A management section is reachable when the user can do at least one thing in it. Both rules live
* here so the navigation, the pages themselves and the management home statistics cannot drift
* apart - each of them consumes the same function.
*/
export const getAppManagementAccess = (permissions: readonly GroupPermissionKey[]): ManagementSectionAccess => {
const canManageAll = permissions.includes("app-modify-all");
const canCreate = permissions.includes("app-create");
return { canManageAll, canCreate, canAccess: canManageAll || canCreate };
};
/**
* `hasDelegatedFullAccess` is whether the user was granted full access to at least one specific
* integration; it has to be looked up per request, so the caller resolves it.
*/
export const getIntegrationManagementAccess = (
permissions: readonly GroupPermissionKey[],
hasDelegatedFullAccess: boolean,
): ManagementSectionAccess & { hasDelegatedFullAccess: boolean } => {
const canManageAll = permissions.includes("integration-full-all");
const canCreate = permissions.includes("integration-create");
return {
canManageAll,
canCreate,
hasDelegatedFullAccess,
canAccess: canManageAll || canCreate || hasDelegatedFullAccess,
};
};
@@ -1,28 +1,7 @@
import { describe, expect, test } from "vitest";
import type { GroupPermissionKey, PermissionMatrixState } from "../permissions";
import {
getAppManagementAccess,
getIntegrationManagementAccess,
getPermissionsWithChildren,
getPermissionsWithParents,
groupPermissionKeys,
isCapabilityImpliedByLevel,
matrixStateToPermissions,
permissionMatrix,
permissionsToMatrixState,
} from "../permissions";
const emptyState = (overrides: Partial<PermissionMatrixState> = {}): PermissionMatrixState => ({
board: { level: 0, capabilities: [] },
app: { level: 0, capabilities: [] },
integration: { level: 0, capabilities: [] },
"search-engine": { level: 0, capabilities: [] },
media: { level: 0, capabilities: [] },
other: { level: 0, capabilities: [] },
admin: { level: 0, capabilities: [] },
...overrides,
});
import type { GroupPermissionKey } from "../permissions";
import { getPermissionsWithChildren, getPermissionsWithParents } from "../permissions";
describe("getPermissionsWithParents should return the correct permissions", () => {
test.each([
@@ -66,204 +45,3 @@ describe("getPermissionsWithChildren should return the correct permissions", ()
expect(getPermissionsWithChildren(input)).toEqual(expect.arrayContaining(expectedOutput));
});
});
describe("the matrix must be able to represent every permission", () => {
test("every group permission key is covered by exactly one category", () => {
const covered = Object.values(permissionMatrix).flatMap(({ levels, capabilities }) => [...levels, ...capabilities]);
expect(covered.toSorted()).toEqual(groupPermissionKeys.toSorted());
});
test.each(groupPermissionKeys.map((key) => [key]))("expect %s to survive a round-trip on its own", (key) => {
expect(matrixStateToPermissions(permissionsToMatrixState([key]))).toContain(key);
});
test("every ladder is a real chain, so no level can fabricate a sibling permission", () => {
Object.values(permissionMatrix).forEach(({ levels }) => {
levels.forEach((key, index) => {
// Selecting a level must never grant more than that level's key already implies.
const implied = getPermissionsWithChildren([key]);
expect(levels.slice(0, index + 1).every((lower) => implied.includes(lower))).toBe(true);
});
});
});
});
describe("permissionsToMatrixState should resolve the highest present level per category", () => {
test("resolves the highest present level key", () => {
expect(permissionsToMatrixState(["board-view-all", "board-modify-all"]).board.level).toBe(2);
});
test("resolves the admin category", () => {
expect(permissionsToMatrixState(["admin"]).admin.level).toBe(1);
});
test("resolves an empty selection to all zero", () => {
const state = permissionsToMatrixState([]);
expect(Object.values(state).every(({ level, capabilities }) => level === 0 && capabilities.length === 0)).toBe(
true,
);
});
test("resolves a standalone create key without granting any level", () => {
expect(permissionsToMatrixState(["board-create"]).board).toEqual({ level: 0, capabilities: ["board-create"] });
});
test("marks a capability as granted when the level implies it", () => {
expect(permissionsToMatrixState(["board-full-all"]).board.capabilities).toEqual(["board-create"]);
expect(permissionsToMatrixState(["app-modify-all"]).app.capabilities).toEqual(["app-create"]);
expect(permissionsToMatrixState(["media-full-all"]).media.capabilities).toEqual(["media-upload"]);
});
test("does not mark a capability as granted for a level that does not imply it", () => {
expect(permissionsToMatrixState(["board-modify-all"]).board.capabilities).toEqual([]);
expect(permissionsToMatrixState(["integration-interact-all"]).integration.capabilities).toEqual([]);
});
test("app-use-all is a capability, not a level, because app-modify-all does not imply it", () => {
// Regression: modelling use as the level below modify made picking Modify silently grant the
// use of every app, which the permission graph does not do.
const state = permissionsToMatrixState(["app-modify-all"]);
expect(state.app).toEqual({ level: 1, capabilities: ["app-create"] });
expect(matrixStateToPermissions(state)).toEqual(["app-modify-all"]);
});
test("app-full-all implies both app capabilities", () => {
expect(permissionsToMatrixState(["app-full-all"]).app).toEqual({
level: 2,
capabilities: ["app-use-all", "app-create"],
});
});
});
describe("isCapabilityImpliedByLevel", () => {
test.each([
["board", "board-create", 0, false],
["board", "board-create", 1, false],
["board", "board-create", 2, false],
["board", "board-create", 3, true],
["app", "app-create", 1, true],
["app", "app-use-all", 1, false],
["app", "app-use-all", 2, true],
["integration", "integration-create", 2, false],
["integration", "integration-create", 3, true],
["search-engine", "search-engine-create", 1, true],
["media", "media-upload", 1, false],
["media", "media-upload", 2, true],
] satisfies [keyof typeof permissionMatrix, GroupPermissionKey, number, boolean][])(
"expect %s at level %i to imply %s: %s",
(category, capability, level, expected) => {
expect(isCapabilityImpliedByLevel(category, capability, level)).toBe(expected);
},
);
});
describe("matrixStateToPermissions should expand levels into permission keys", () => {
test("expands a level into the ordered level keys", () => {
expect(matrixStateToPermissions(emptyState({ board: { level: 2, capabilities: [] } }))).toEqual([
"board-view-all",
"board-modify-all",
]);
});
test("expands the admin category into the admin key", () => {
expect(matrixStateToPermissions(emptyState({ admin: { level: 1, capabilities: [] } }))).toEqual(["admin"]);
});
test("emits a standalone capability without any level", () => {
expect(matrixStateToPermissions(emptyState({ board: { level: 0, capabilities: ["board-create"] } }))).toEqual([
"board-create",
]);
});
test("emits media-upload as the capability of the media category", () => {
expect(matrixStateToPermissions(emptyState({ media: { level: 0, capabilities: ["media-upload"] } }))).toEqual([
"media-upload",
]);
});
test("emits app-use-all without granting any app level", () => {
expect(matrixStateToPermissions(emptyState({ app: { level: 0, capabilities: ["app-use-all"] } }))).toEqual([
"app-use-all",
]);
});
test("omits a capability that the chosen level already implies", () => {
expect(matrixStateToPermissions(emptyState({ board: { level: 3, capabilities: ["board-create"] } }))).toEqual([
"board-view-all",
"board-modify-all",
"board-full-all",
]);
});
});
describe("matrix helpers should round-trip stably", () => {
test.each([
[["board-view-all", "board-modify-all"]],
[["integration-use-all", "integration-interact-all", "integration-full-all"]],
[["admin"]],
[[]],
[["search-engine-modify-all", "search-engine-full-all", "media-view-all", "other-view-logs"]],
[["board-create"]],
[["media-upload"]],
[["board-create", "app-create", "integration-create", "search-engine-create", "media-upload"]],
[["board-create", "board-view-all"]],
[["app-modify-all"]],
[["app-use-all"]],
[["app-use-all", "app-modify-all"]],
] satisfies [GroupPermissionKey[]][])("expect %s to round-trip", (permissions) => {
const roundTripped = matrixStateToPermissions(permissionsToMatrixState(permissions));
expect(roundTripped).toEqual(expect.arrayContaining(permissions));
expect(roundTripped).toHaveLength(permissions.length);
});
test("expect a redundant capability key to be normalised away by the level that implies it", () => {
// app-modify-all already grants app-create, so the matrix stores the minimal set. The permission
// is not lost: getPermissionsWithChildren re-derives it whenever permissions are read.
const roundTripped = matrixStateToPermissions(permissionsToMatrixState(["app-create", "app-modify-all"]));
expect(roundTripped).toEqual(["app-modify-all"]);
expect(getPermissionsWithChildren(roundTripped)).toContain("app-create");
});
test("expect a single higher-level key to expand to its full set on round-trip", () => {
expect(matrixStateToPermissions(permissionsToMatrixState(["board-full-all"]))).toEqual([
"board-view-all",
"board-modify-all",
"board-full-all",
]);
});
});
describe("management section access", () => {
test.each([
[[], false, false],
[["app-create"], true, false],
[["app-modify-all"], true, true],
[["app-full-all", "app-modify-all"], true, true],
[["app-use-all"], false, false],
[["board-modify-all"], false, false],
] satisfies [GroupPermissionKey[], boolean, boolean][])(
"expect apps with %s to be reachable: %s, manage-all: %s",
(permissions, canAccess, canManageAll) => {
const access = getAppManagementAccess(permissions);
expect(access.canAccess).toBe(canAccess);
expect(access.canManageAll).toBe(canManageAll);
},
);
test.each([
[[], false, false, false],
[[], true, true, false],
[["integration-create"], false, true, false],
[["integration-full-all"], false, true, true],
[["integration-use-all"], false, false, false],
[["integration-interact-all"], false, false, false],
[["integration-interact-all"], true, true, false],
] satisfies [GroupPermissionKey[], boolean, boolean, boolean][])(
"expect integrations with %s and delegated access %s to be reachable: %s, manage-all: %s",
(permissions, hasDelegatedFullAccess, canAccess, canManageAll) => {
const access = getIntegrationManagementAccess(permissions, hasDelegatedFullAccess);
expect(access.canAccess).toBe(canAccess);
expect(access.canManageAll).toBe(canManageAll);
},
);
});
-8
View File
@@ -1,10 +1,6 @@
export const widgetKinds = [
"clock",
"weather",
"today",
"airQuality",
"countdown",
"timer",
"app",
"iframe",
"video",
@@ -63,10 +59,6 @@ export const widgetKinds = [
export type WidgetKind = (typeof widgetKinds)[number];
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
today: { width: 2, height: 1 },
airQuality: { width: 2, height: 1 },
countdown: { width: 2, height: 1 },
timer: { width: 2, height: 1 },
uptimeKuma: { width: 2, height: 3 },
audioStats: { width: 2, height: 2 },
paperlessNgx: { width: 2, height: 2 },
@@ -20,9 +20,7 @@ const sessionSchema = z.object({
SeriesName: z.string().nullish(),
Name: z.string().nullish(),
SeasonName: z.string().nullish(),
ParentIndexNumber: z.number().nullish(),
EpisodeTitle: z.string().nullish(),
IndexNumber: z.number().nullish(),
Album: z.string().nullish(),
EpisodeCount: z.number().nullish(),
})
@@ -112,15 +110,11 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) {
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
@@ -10,9 +10,7 @@ export interface StreamSession {
type: "audio" | "video" | "tv" | "movie";
name: string;
seasonName: string | undefined;
seasonNumber?: number | null;
episodeName?: string | null;
episodeNumber?: number | null;
albumName?: string | null;
episodeCount?: number | null;
playback: {
@@ -1,65 +0,0 @@
import { describe, expect, test } from "vitest";
import { extractHost, parseLocation } from "./jellyfin-integration";
describe("extractHost", () => {
test("strips the port from an IPv4 endpoint", () => {
expect(extractHost("192.168.1.100:54321")).toBe("192.168.1.100");
});
test("returns an IPv4 address unchanged when there's no port", () => {
expect(extractHost("8.8.8.8")).toBe("8.8.8.8");
});
test("strips brackets and the port from a bracketed IPv6 endpoint", () => {
expect(extractHost("[::1]:8096")).toBe("::1");
});
test("returns a bare IPv6 address unchanged when there's no port", () => {
expect(extractHost("::1")).toBe("::1");
});
});
describe("parseLocation", () => {
test("returns null when there's no endpoint to classify", () => {
expect(parseLocation(null)).toBeNull();
expect(parseLocation(undefined)).toBeNull();
expect(parseLocation("")).toBeNull();
});
test("returns null for a value that isn't an IP", () => {
expect(parseLocation("not-an-ip:1234")).toBeNull();
});
test("classifies private IPv4 ranges as lan", () => {
expect(parseLocation("192.168.1.100:54321")).toBe("lan");
expect(parseLocation("10.0.0.5:1234")).toBe("lan");
expect(parseLocation("172.16.5.5:1234")).toBe("lan");
expect(parseLocation("127.0.0.1:8096")).toBe("lan");
});
test("classifies a public IPv4 address as wan", () => {
expect(parseLocation("8.8.8.8:1234")).toBe("wan");
});
test("classifies IPv6 loopback and unique-local addresses as lan", () => {
expect(parseLocation("[::1]:8096")).toBe("lan");
expect(parseLocation("[fd12:3456:789a::1]:8096")).toBe("lan");
});
test("classifies a public IPv6 address as wan", () => {
expect(parseLocation("[2606:4700:4700::1111]:8096")).toBe("wan");
});
test("unwraps an IPv4-mapped IPv6 address before classifying it", () => {
expect(parseLocation("[::ffff:192.168.1.5]:8096")).toBe("lan");
expect(parseLocation("[::ffff:8.8.8.8]:8096")).toBe("wan");
});
test("classifies link-local addresses as lan", () => {
// Link-local traffic can't traverse a router, so it's always LAN even though
// it isn't a private range in the traditional (RFC 1918 / ULA) sense.
expect(parseLocation("169.254.1.5:8096")).toBe("lan");
expect(parseLocation("[fe80::1]:8096")).toBe("lan");
});
});
@@ -1,4 +1,3 @@
import { BlockList, isIP } from "node:net";
import { Jellyfin } from "@jellyfin/sdk";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
@@ -22,42 +21,6 @@ function ticksToMs(ticks: number | null | undefined): number | null {
return ticks ? Math.round(ticks / 10_000) : null;
}
// Unlike Plex, Jellyfin doesn't report a session's network location directly - only the
// client's RemoteEndPoint (an IP, optionally with a port). Classify it ourselves.
const privateAddresses = new BlockList();
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
privateAddresses.addSubnet("127.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("169.254.0.0", 16, "ipv4");
privateAddresses.addSubnet("fc00::", 7, "ipv6");
privateAddresses.addSubnet("fe80::", 10, "ipv6");
privateAddresses.addAddress("::1", "ipv6");
export function extractHost(remoteEndPoint: string): string {
if (remoteEndPoint.startsWith("[")) {
const end = remoteEndPoint.indexOf("]");
return end === -1 ? remoteEndPoint : remoteEndPoint.slice(1, end);
}
// A bare IPv6 address (no brackets, no port) has more than one colon.
const isIPv4WithPort = remoteEndPoint.split(":").length === 2;
return isIPv4WithPort ? remoteEndPoint.slice(0, remoteEndPoint.lastIndexOf(":")) : remoteEndPoint;
}
export function parseLocation(remoteEndPoint: string | null | undefined): "lan" | "wan" | null {
if (!remoteEndPoint) return null;
let host = extractHost(remoteEndPoint);
let family = isIP(host);
// A dual-stack server can report an IPv4 client as an IPv4-mapped IPv6 address
// (e.g. "::ffff:192.168.1.5") - unwrap it so the IPv4 private ranges still apply.
if (family === 6 && host.toLowerCase().startsWith("::ffff:") && isIP(host.slice(7)) === 4) {
host = host.slice(7);
family = 4;
}
if (!family) return null;
return privateAddresses.check(host, family === 4 ? "ipv4" : "ipv6") ? "lan" : "wan";
}
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({
@@ -93,27 +56,15 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
// Jellyfin only reliably fills in MediaSources[].Bitrate for a transcoding session -
// /Sessions has no `fields` selector to force it, so fall back to summing the individual
// media streams' own bitrates (populated from the source file's metadata) for direct play.
const mediaStreamsBitrateBps = sessionInfo.NowPlayingItem.MediaStreams?.reduce(
(sum, stream) => sum + (stream.BitRate ?? 0),
0,
);
const bitrateBps =
sessionInfo.TranscodingInfo?.Bitrate ??
sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ??
(mediaStreamsBitrateBps || null);
sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null;
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
@@ -121,7 +72,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
positionMs,
durationMs,
},
location: parseLocation(sessionInfo.RemoteEndPoint),
location: null,
metadata: {
video: {
resolution:
@@ -26,9 +26,7 @@ const sessions: StreamSession[] = [
type: "video",
name: "Breaking Bad",
seasonName: "Season 5",
seasonNumber: 5,
episodeName: "Ozymandias",
episodeNumber: 14,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
@@ -60,9 +58,7 @@ const sessions: StreamSession[] = [
type: "video",
name: "Severance",
seasonName: "Season 2",
seasonNumber: 2,
episodeName: "The We We Are",
episodeNumber: 10,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
+1 -2
View File
@@ -80,9 +80,8 @@ interface Session {
$: {
grandparentTitle?: string;
parentTitle?: string;
parentIndex?: string;
title?: string;
index?: string;
index?: number;
type: string;
live?: string;
viewOffset?: string;
@@ -115,11 +115,9 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type),
name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown",
seasonName: mediaElement.$.parentTitle,
seasonNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.parentIndex) : null,
episodeName: mediaElement.$.type === "episode" ? (mediaElement.$.title ?? null) : null,
episodeNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.index) : null,
episodeName: mediaElement.$.title ?? null,
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
episodeCount: parseOptionalNumber(mediaElement.$.index),
episodeCount: mediaElement.$.index ?? null,
playback: {
state: playbackState,
positionMs,
@@ -8,35 +8,35 @@ import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { appHrefSchema } from "@homarr/validation/app";
interface AddDockerAppToHomarrProps {
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
initialUrls?: (string | null)[];
initialUrls?: string[];
}
const formSchema = z.object({
containerUrls: z.array(appHrefSchema),
});
export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(({ actions, innerProps }) => {
const t = useI18n();
const form = useZodForm(formSchema, {
initialValues: {
containerUrls: innerProps.selectedContainers.map((container, index) => {
const initialUrl = innerProps.initialUrls?.[index];
if (initialUrl !== undefined) return initialUrl;
const form = useZodForm(
z.object({
containerUrls: z.array(z.string().url().nullable()),
}),
{
initialValues: {
containerUrls: innerProps.selectedContainers.map((container, index) => {
const initialUrl = innerProps.initialUrls?.[index];
if (initialUrl) return initialUrl;
const candidates = buildDockerServiceUrlCandidates({
containerName: container.name,
endpointHost: container.host,
ports: container.ports,
}).filter(({ url }) => url.length > 0);
const candidates = buildDockerServiceUrlCandidates({
containerName: container.name,
endpointHost: container.host,
ports: container.ports,
}).filter(({ url }) => url.length > 0);
return candidates.find(({ scopes }) => scopes.includes("browser"))?.url ?? candidates[0]?.url ?? null;
}),
return candidates.find(({ scopes }) => scopes.includes("browser"))?.url ?? candidates[0]?.url ?? null;
}),
},
},
});
);
const { mutate, isPending } = clientApi.app.createMany.useMutation({
onSuccess() {
actions.closeModal();
@@ -52,13 +52,13 @@ export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(
});
},
});
const handleSubmit = ({ containerUrls }: z.infer<typeof formSchema>) => {
const handleSubmit = () => {
mutate(
innerProps.selectedContainers.map((container, index) => ({
name: container.name,
iconUrl: container.iconUrl,
description: null,
href: containerUrls[index] || null,
href: form.values.containerUrls[index] ?? null,
pingUrl: null,
})),
);
@@ -68,31 +68,28 @@ export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(
<LoadingOverlay visible={isPending} />
<Stack>
<List spacing={"xs"}>
{innerProps.selectedContainers.map((container, index) => {
const inputProps = form.getInputProps(`containerUrls.${index}`);
return (
<List.Item
styles={{ itemWrapper: { width: "100%" }, itemLabel: { flex: 1 } }}
icon={
<Avatar
variant="outline"
radius={container.iconUrl ? "sm" : "md"}
size={30}
styles={{ image: { objectFit: "contain" } }}
src={container.iconUrl}
>
{container.name.at(0)?.toUpperCase()}
</Avatar>
}
key={container.id}
>
<Group justify="space-between" wrap={"nowrap"}>
<Text lineClamp={1}>{container.name}</Text>
<TextInput {...inputProps} value={inputProps.value ?? ""} />
</Group>
</List.Item>
);
})}
{innerProps.selectedContainers.map((container, index) => (
<List.Item
styles={{ itemWrapper: { width: "100%" }, itemLabel: { flex: 1 } }}
icon={
<Avatar
variant="outline"
radius={container.iconUrl ? "sm" : "md"}
size={30}
styles={{ image: { objectFit: "contain" } }}
src={container.iconUrl}
>
{container.name.at(0)?.toUpperCase()}
</Avatar>
}
key={container.id}
>
<Group justify="space-between" wrap={"nowrap"}>
<Text lineClamp={1}>{container.name}</Text>
<TextInput {...form.getInputProps(`containerUrls.${index}`)} />
</Group>
</List.Item>
))}
</List>
<Group justify="end">
<Button onClick={actions.closeModal} variant="light" px={"xl"}>
-2
View File
@@ -1,5 +1,3 @@
export { OnboardingAuthShell } from "./onboarding-auth-shell";
export { OnboardingStudio } from "./onboarding-studio";
export { normalizeServiceUrl } from "./discovery-selection";
export { ServiceUrlTemplate } from "./service-url-template";
export type { OnboardingEnvironment, OnboardingStudioProps } from "./types";
@@ -358,7 +358,6 @@
display: flex;
width: max-content;
min-width: 100%;
margin: 0;
padding: 0.2rem;
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-default);
@@ -1,106 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useRef, useState } from "react";
import { FloatingIndicator, Stack, TextInput, UnstyledButton } from "@mantine/core";
import type { UrlTemplateMode } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useOnboardingSounds } from "./use-onboarding-sounds";
import classes from "./onboarding-studio.module.css";
interface FloatingControlProps<T extends string> {
value: T;
onChange: (value: T) => void;
options: readonly { value: T; label: ReactNode }[];
ariaLabel: string;
}
export const OnboardingFloatingControl = <T extends string>({
value,
onChange,
options,
ariaLabel,
}: FloatingControlProps<T>) => {
const [rootRef, setRootRef] = useState<HTMLFieldSetElement | null>(null);
const controlRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const sounds = useOnboardingSounds();
return (
<div className={classes.floatingControlScroller}>
<fieldset ref={setRootRef} className={classes.floatingControlRoot} aria-label={ariaLabel}>
<FloatingIndicator
target={controlRefs.current[value] ?? null}
parent={rootRef}
className={classes.floatingControlIndicator}
/>
{options.map((option) => {
const selected = value === option.value;
return (
<UnstyledButton
key={option.value}
ref={(node) => {
controlRefs.current[option.value] = node;
}}
type="button"
className={classes.floatingControl}
data-active={selected}
aria-pressed={selected}
onClick={() => {
sounds.click();
onChange(option.value);
}}
>
{option.label}
</UnstyledButton>
);
})}
</fieldset>
</div>
);
};
interface ServiceUrlTemplateProps {
serverOrigin: string;
onServerOriginChange: (value: string) => void;
mode: UrlTemplateMode;
onModeChange: (value: UrlTemplateMode) => void;
readOnly?: boolean;
required?: boolean;
}
export const ServiceUrlTemplate = ({
serverOrigin,
onServerOriginChange,
mode,
onModeChange,
readOnly,
required,
}: ServiceUrlTemplateProps) => {
const t = useScopedI18n("common.serviceUrlTemplate");
return (
<Stack gap="sm">
<TextInput
label={t("serverTitle")}
placeholder="home.lan · 192.168.1.10 · https://homarr.example.com"
value={serverOrigin}
onChange={(event) => onServerOriginChange(event.currentTarget.value)}
readOnly={readOnly}
required={required}
withAsterisk={required}
/>
<OnboardingFloatingControl
ariaLabel={t("urlModeLabel")}
value={mode}
onChange={onModeChange}
options={[
{ value: "hostPort", label: t("hostPort") },
{ value: "subdomain", label: t("subdomain") },
{ value: "path", label: t("path") },
]}
/>
</Stack>
);
};
+68 -6
View File
@@ -15,6 +15,7 @@ import {
ColorSwatch,
CopyButton,
Fieldset,
FloatingIndicator,
Group,
Paper,
PasswordInput,
@@ -98,7 +99,6 @@ import type { OnboardingStudioProps } from "./types";
import { getBoardValidationErrors } from "./board-validation";
import { OnboardingBackdrop } from "./onboarding-backdrop";
import { OnboardingWordmark } from "./onboarding-wordmark";
import { OnboardingFloatingControl, ServiceUrlTemplate } from "./service-url-template";
import { useOnboardingSounds } from "./use-onboarding-sounds";
import {
normalizeServiceUrl,
@@ -152,6 +152,57 @@ const initialSecondaryColor = "#fd7e14";
const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = [];
const emptyDiscoveredApps: DockerDiscoveryData["apps"] = [];
interface OnboardingFloatingControlProps<T extends string> {
value: T;
onChange: (value: T) => void;
options: readonly { value: T; label: ReactNode }[];
ariaLabel: string;
}
const OnboardingFloatingControl = <T extends string>({
value,
onChange,
options,
ariaLabel,
}: OnboardingFloatingControlProps<T>) => {
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const controlRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const sounds = useOnboardingSounds();
return (
<div className={classes.floatingControlScroller}>
<div ref={setRootRef} className={classes.floatingControlRoot} role="group" aria-label={ariaLabel}>
<FloatingIndicator
target={controlRefs.current[value] ?? null}
parent={rootRef}
className={classes.floatingControlIndicator}
/>
{options.map((option) => {
const selected = value === option.value;
return (
<UnstyledButton
key={option.value}
ref={(node) => {
controlRefs.current[option.value] = node;
}}
type="button"
className={classes.floatingControl}
data-active={selected}
aria-pressed={selected}
onClick={() => {
sounds.click();
onChange(option.value);
}}
>
{option.label}
</UnstyledButton>
);
})}
</div>
</div>
);
};
export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingStudioProps) => {
const t = useScopedI18n("init.studio");
const tCommon = useScopedI18n("common.action");
@@ -873,13 +924,24 @@ const Essentials = (props: StudioSectionProps) => {
<Alert variant="light" icon={<IconInfoCircle size={18} />} my="xs">
{t("serverOriginHelp")}
</Alert>
<ServiceUrlTemplate
serverOrigin={props.serverOrigin}
onServerOriginChange={props.setServerOrigin}
mode={props.urlMode}
onModeChange={props.setUrlMode}
<TextInput
label={t("serverTitle")}
placeholder="home.lan · 192.168.1.10 · https://homarr.example.com"
value={props.serverOrigin}
onChange={(event) => props.setServerOrigin(event.currentTarget.value)}
readOnly={!isHydrated}
required
withAsterisk
/>
<OnboardingFloatingControl
ariaLabel={t("urlModeLabel")}
value={props.urlMode}
onChange={props.setUrlMode}
options={[
{ value: "hostPort", label: t("hostPort") },
{ value: "subdomain", label: t("subdomain") },
{ value: "path", label: t("path") },
]}
/>
{props.serverOrigin ? (
<Code block>{buildIntegrationUrl("sonarr", props.serverOrigin, props.urlMode)}</Code>
@@ -1,101 +0,0 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { airQualityRequestHandler } from "./air-quality";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const makeAirQualityResponse = () => {
const times = ["2026-07-01T00:00", "2026-07-01T01:00", "2026-07-02T00:00"];
const values = [10, 20, 30];
const fields = [
"european_aqi",
"us_aqi",
"uv_index",
"pm10",
"pm2_5",
"carbon_monoxide",
"nitrogen_dioxide",
"sulphur_dioxide",
"ozone",
"european_aqi_pm2_5",
"european_aqi_pm10",
"european_aqi_nitrogen_dioxide",
"european_aqi_ozone",
"european_aqi_sulphur_dioxide",
"us_aqi_pm2_5",
"us_aqi_pm10",
"us_aqi_nitrogen_dioxide",
"us_aqi_carbon_monoxide",
"us_aqi_ozone",
"us_aqi_sulphur_dioxide",
"alder_pollen",
"birch_pollen",
"grass_pollen",
"mugwort_pollen",
"olive_pollen",
"ragweed_pollen",
] as const;
return {
timezone: "Europe/Berlin",
utc_offset_seconds: 7200,
current: {
time: "2026-07-01T12:00",
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? null : 12])),
},
hourly: {
time: times,
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? [null, null, null] : values])),
},
fields,
};
};
beforeEach(() => {
vi.clearAllMocks();
airQualityRequestHandler.invalidateCache();
});
describe("air quality request handler", () => {
it("requests and normalizes the full useful forecast field set", async () => {
const fixture = makeAirQualityResponse();
mockFetch.mockResolvedValue(new Response(JSON.stringify(fixture)));
const first = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
const second = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
expect(first.data.current.pollutants.pm2_5).toBe(12);
expect(first.data.current.pollen.ragweed).toBeNull();
expect(first.data.hourly[0]?.observedAt).toBe("2026-06-30T22:00:00.000Z");
expect(first.data.daily).toEqual([
{ date: "2026-07-01", europeanAqiMax: 20, usAqiMax: 20, uvIndexMax: 20, pm2_5Max: 20, pm10Max: 20 },
{ date: "2026-07-02", europeanAqiMax: 30, usAqiMax: 30, uvIndexMax: 30, pm2_5Max: 30, pm10Max: 30 },
]);
expect(second.data).toStrictEqual(first.data);
expect(mockFetch).toHaveBeenCalledOnce();
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
expect(requestUrl.origin).toBe("https://air-quality-api.open-meteo.com");
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
expect(requestUrl.searchParams.get("current")).toContain("european_aqi_pm2_5");
expect(requestUrl.searchParams.get("hourly")).toContain("us_aqi_carbon_monoxide");
expect(requestUrl.searchParams.get("hourly")).toContain("ragweed_pollen");
});
it("rejects unsuccessful Open-Meteo responses", async () => {
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
await expect(
airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync(),
).rejects.toThrow();
});
});
-283
View File
@@ -1,283 +0,0 @@
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { z } from "zod";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
dayjs.extend(utc);
dayjs.extend(timezone);
const FORECAST_DAYS = 7;
const requestedVariables = [
"european_aqi",
"us_aqi",
"uv_index",
"pm10",
"pm2_5",
"carbon_monoxide",
"nitrogen_dioxide",
"sulphur_dioxide",
"ozone",
"european_aqi_pm2_5",
"european_aqi_pm10",
"european_aqi_nitrogen_dioxide",
"european_aqi_ozone",
"european_aqi_sulphur_dioxide",
"us_aqi_pm2_5",
"us_aqi_pm10",
"us_aqi_nitrogen_dioxide",
"us_aqi_carbon_monoxide",
"us_aqi_ozone",
"us_aqi_sulphur_dioxide",
"alder_pollen",
"birch_pollen",
"grass_pollen",
"mugwort_pollen",
"olive_pollen",
"ragweed_pollen",
] as const;
export const airQualityRequestHandler = createWidgetRequestHandler({
cacheTtlMs: 15 * 60 * 1000,
fallbackToStaleOnError: true,
staleIfErrorTtlMs: 24 * 60 * 60 * 1000,
async requestAsync(input: { latitude: number; longitude: number }) {
const url = new URL("https://air-quality-api.open-meteo.com/v1/air-quality");
url.searchParams.set("latitude", input.latitude.toString());
url.searchParams.set("longitude", input.longitude.toString());
url.searchParams.set("current", requestedVariables.join(","));
url.searchParams.set("hourly", requestedVariables.join(","));
url.searchParams.set("forecast_days", FORECAST_DAYS.toString());
url.searchParams.set("timezone", "auto");
const response = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
});
if (!response.ok) throw new ResponseError(response);
const json: unknown = await response.json();
const airQuality = await atLocationOutput.parseAsync(json);
const hourly = airQuality.hourly.time.map((value, index) => {
return normalizePoint(airQuality.hourly, index, toUtcInstant(value, airQuality.timezone));
});
return {
timezone: airQuality.timezone,
utcOffsetSeconds: airQuality.utc_offset_seconds,
current: normalizePoint(
airQuality.current,
undefined,
toUtcInstant(airQuality.current.time, airQuality.timezone),
),
hourly,
daily: getDailyMaximums(airQuality.hourly),
} satisfies AirQuality;
},
});
const nullableNumber = z.number().nullable();
const nullableNumberArray = z.array(nullableNumber);
const currentValues = z.object({
time: z.string(),
european_aqi: nullableNumber,
us_aqi: nullableNumber,
uv_index: nullableNumber,
pm10: nullableNumber,
pm2_5: nullableNumber,
carbon_monoxide: nullableNumber,
nitrogen_dioxide: nullableNumber,
sulphur_dioxide: nullableNumber,
ozone: nullableNumber,
european_aqi_pm2_5: nullableNumber,
european_aqi_pm10: nullableNumber,
european_aqi_nitrogen_dioxide: nullableNumber,
european_aqi_ozone: nullableNumber,
european_aqi_sulphur_dioxide: nullableNumber,
us_aqi_pm2_5: nullableNumber,
us_aqi_pm10: nullableNumber,
us_aqi_nitrogen_dioxide: nullableNumber,
us_aqi_carbon_monoxide: nullableNumber,
us_aqi_ozone: nullableNumber,
us_aqi_sulphur_dioxide: nullableNumber,
alder_pollen: nullableNumber,
birch_pollen: nullableNumber,
grass_pollen: nullableNumber,
mugwort_pollen: nullableNumber,
olive_pollen: nullableNumber,
ragweed_pollen: nullableNumber,
});
const hourlyValues = z.object({
time: z.array(z.string()),
european_aqi: nullableNumberArray,
us_aqi: nullableNumberArray,
uv_index: nullableNumberArray,
pm10: nullableNumberArray,
pm2_5: nullableNumberArray,
carbon_monoxide: nullableNumberArray,
nitrogen_dioxide: nullableNumberArray,
sulphur_dioxide: nullableNumberArray,
ozone: nullableNumberArray,
european_aqi_pm2_5: nullableNumberArray,
european_aqi_pm10: nullableNumberArray,
european_aqi_nitrogen_dioxide: nullableNumberArray,
european_aqi_ozone: nullableNumberArray,
european_aqi_sulphur_dioxide: nullableNumberArray,
us_aqi_pm2_5: nullableNumberArray,
us_aqi_pm10: nullableNumberArray,
us_aqi_nitrogen_dioxide: nullableNumberArray,
us_aqi_carbon_monoxide: nullableNumberArray,
us_aqi_ozone: nullableNumberArray,
us_aqi_sulphur_dioxide: nullableNumberArray,
alder_pollen: nullableNumberArray,
birch_pollen: nullableNumberArray,
grass_pollen: nullableNumberArray,
mugwort_pollen: nullableNumberArray,
olive_pollen: nullableNumberArray,
ragweed_pollen: nullableNumberArray,
});
const atLocationOutput = z.object({
timezone: z.string().min(1),
utc_offset_seconds: z.number(),
current: currentValues,
hourly: hourlyValues,
});
type CurrentValues = z.infer<typeof currentValues>;
type HourlyValues = z.infer<typeof hourlyValues>;
const getValue = (values: CurrentValues | HourlyValues, field: keyof Omit<CurrentValues, "time">, index?: number) => {
const value = values[field];
if (Array.isArray(value)) return value[index ?? 0] ?? null;
return value;
};
const normalizePoint = (
values: CurrentValues | HourlyValues,
index: number | undefined,
observedAt: string,
): AirQualityPoint => ({
observedAt,
europeanAqi: getValue(values, "european_aqi", index),
usAqi: getValue(values, "us_aqi", index),
uvIndex: getValue(values, "uv_index", index),
pollutants: {
pm2_5: getValue(values, "pm2_5", index),
pm10: getValue(values, "pm10", index),
ozone: getValue(values, "ozone", index),
nitrogenDioxide: getValue(values, "nitrogen_dioxide", index),
sulphurDioxide: getValue(values, "sulphur_dioxide", index),
carbonMonoxide: getValue(values, "carbon_monoxide", index),
},
europeanSubIndices: {
pm2_5: getValue(values, "european_aqi_pm2_5", index),
pm10: getValue(values, "european_aqi_pm10", index),
ozone: getValue(values, "european_aqi_ozone", index),
nitrogenDioxide: getValue(values, "european_aqi_nitrogen_dioxide", index),
sulphurDioxide: getValue(values, "european_aqi_sulphur_dioxide", index),
carbonMonoxide: null,
},
usSubIndices: {
pm2_5: getValue(values, "us_aqi_pm2_5", index),
pm10: getValue(values, "us_aqi_pm10", index),
ozone: getValue(values, "us_aqi_ozone", index),
nitrogenDioxide: getValue(values, "us_aqi_nitrogen_dioxide", index),
sulphurDioxide: getValue(values, "us_aqi_sulphur_dioxide", index),
carbonMonoxide: getValue(values, "us_aqi_carbon_monoxide", index),
},
pollen: {
alder: getValue(values, "alder_pollen", index),
birch: getValue(values, "birch_pollen", index),
grass: getValue(values, "grass_pollen", index),
mugwort: getValue(values, "mugwort_pollen", index),
olive: getValue(values, "olive_pollen", index),
ragweed: getValue(values, "ragweed_pollen", index),
},
});
const getDailyMaximums = (hourly: HourlyValues): AirQuality["daily"] => {
const grouped = new Map<string, AirQuality["daily"][number]>();
hourly.time.forEach((time, index) => {
const date = time.slice(0, 10);
const existing = grouped.get(date) ?? {
date,
europeanAqiMax: null,
usAqiMax: null,
uvIndexMax: null,
pm2_5Max: null,
pm10Max: null,
};
existing.europeanAqiMax = maxNullable(existing.europeanAqiMax, hourly.european_aqi[index]);
existing.usAqiMax = maxNullable(existing.usAqiMax, hourly.us_aqi[index]);
existing.uvIndexMax = maxNullable(existing.uvIndexMax, hourly.uv_index[index]);
existing.pm2_5Max = maxNullable(existing.pm2_5Max, hourly.pm2_5[index]);
existing.pm10Max = maxNullable(existing.pm10Max, hourly.pm10[index]);
grouped.set(date, existing);
});
return Array.from(grouped.values()).slice(0, FORECAST_DAYS);
};
const maxNullable = (current: number | null, candidate: number | null | undefined): number | null => {
if (candidate === null || candidate === undefined) return current;
if (current === null) return candidate;
return Math.max(current, candidate);
};
const toUtcInstant = (value: string, timeZone: string): string => {
const parsed = dayjs.tz(value, timeZone);
if (!parsed.isValid()) throw new Error(`Invalid air quality timestamp: ${value}`);
return parsed.utc().toISOString();
};
export interface AirQualityPollutants {
pm2_5: number | null;
pm10: number | null;
ozone: number | null;
nitrogenDioxide: number | null;
sulphurDioxide: number | null;
carbonMonoxide: number | null;
}
export interface AirQualityPoint {
observedAt: string;
europeanAqi: number | null;
usAqi: number | null;
uvIndex: number | null;
pollutants: AirQualityPollutants;
europeanSubIndices: AirQualityPollutants;
usSubIndices: AirQualityPollutants;
pollen: {
alder: number | null;
birch: number | null;
grass: number | null;
mugwort: number | null;
olive: number | null;
ragweed: number | null;
};
}
export interface AirQuality {
timezone: string;
utcOffsetSeconds: number;
current: AirQualityPoint;
hourly: AirQualityPoint[];
daily: {
date: string;
europeanAqiMax: number | null;
usAqiMax: number | null;
uvIndexMax: number | null;
pm2_5Max: number | null;
pm10Max: number | null;
}[];
}
+17 -32
View File
@@ -195,24 +195,9 @@ const mockContainers: {
},
];
const demoDockerEndpoint = {
id: "demo",
name: "Demo Docker",
status: "available",
kind: "docker",
transport: "socket",
capabilities: ["inventory"],
source: "default",
scope: "admin",
} satisfies DockerEndpointStatus;
export const dockerContainersRequestHandler = createWidgetRequestHandler({
async requestAsync({ endpointIds }: { endpointIds?: string[] }) {
async requestAsync() {
if (isDemoMode()) {
const includesDemoEndpoint =
endpointIds === undefined || endpointIds.length === 0 || endpointIds.includes("demo");
if (!includesDemoEndpoint) return { containers: [], endpoints: [] };
return {
containers: mockContainers.map((container) => ({
...container,
@@ -220,10 +205,21 @@ export const dockerContainersRequestHandler = createWidgetRequestHandler({
endpointName: "Demo Docker",
resourceId: `demo:${container.id}`,
})),
endpoints: [demoDockerEndpoint],
endpoints: [
{
id: "demo",
name: "Demo Docker",
status: "available",
kind: "docker",
transport: "socket",
capabilities: ["inventory"],
source: "default",
scope: "admin",
},
] satisfies DockerEndpointStatus[],
};
}
return await getContainersWithStatsAsync(dockerWidgetEndpointTimeoutMs, endpointIds);
return await getContainersWithStatsAsync();
},
});
@@ -308,8 +304,6 @@ export const streamContainerLogsAsync = async (
};
export const getDockerEndpointsAsync = (): DockerEndpointStatus[] => {
if (isDemoMode()) return [demoDockerEndpoint];
const dockerInstances = DockerSingleton.getInstances();
const initializationFailures = DockerSingleton.getInitializationFailures();
@@ -337,18 +331,9 @@ export const getDockerEndpointsAsync = (): DockerEndpointStatus[] => {
];
};
export async function getContainersWithStatsAsync(
timeoutMs = dockerWidgetEndpointTimeoutMs,
endpointIds: string[] = [],
) {
const selectedEndpointIds = new Set(endpointIds);
const includesAllEndpoints = selectedEndpointIds.size === 0;
const dockerInstances = DockerSingleton.getInstances().filter(
({ endpointId }) => includesAllEndpoints || selectedEndpointIds.has(endpointId),
);
const initializationFailures = DockerSingleton.getInitializationFailures().filter(
({ descriptor }) => includesAllEndpoints || selectedEndpointIds.has(descriptor.id),
);
export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpointTimeoutMs) {
const dockerInstances = DockerSingleton.getInstances();
const initializationFailures = DockerSingleton.getInitializationFailures();
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host, endpointId, endpointName }) => {
const instanceContainers = await withDockerTimeoutAsync(
@@ -2,14 +2,11 @@ import { createRequestHandler } from "./request-handler";
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
}
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => {
const inner = createRequestHandler<TData, TInput>(options);
const inner = createRequestHandler<TData, TInput>({ requestAsync: options.requestAsync });
return inner;
};
@@ -7,8 +7,6 @@ import { DockerSingleton } from "@homarr/docker";
import {
calculateCpuUsage,
calculateMemoryUsage,
dockerContainersRequestHandler,
getDockerEndpointsAsync,
getContainersWithStatsAsync,
hasDockerEndpointCapability,
} from "../docker";
@@ -129,31 +127,6 @@ describe("calculateMemoryUsage", () => {
});
describe("getContainersWithStatsAsync", () => {
test("queries only selected Docker endpoints and leaves an empty selection as all", async () => {
const firstListContainers = vi.fn(async () => []);
const secondListContainers = vi.fn(async () => []);
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([
createDockerInstance("first", firstListContainers),
createDockerInstance("second", secondListContainers),
] as never);
vi.spyOn(DockerSingleton, "getInitializationFailures").mockReturnValue([]);
try {
const selectedResult = await getContainersWithStatsAsync(50, ["second"]);
expect(firstListContainers).not.toHaveBeenCalled();
expect(secondListContainers).toHaveBeenCalledOnce();
expect(selectedResult.endpoints.map(({ id }) => id)).toEqual(["second"]);
await getContainersWithStatsAsync(50, []);
expect(firstListContainers).toHaveBeenCalledOnce();
expect(secondListContainers).toHaveBeenCalledTimes(2);
} finally {
vi.restoreAllMocks();
}
});
test("marks a timed-out endpoint unavailable without blocking healthy endpoints", async () => {
vi.useFakeTimers();
try {
@@ -238,26 +211,14 @@ describe("getContainersWithStatsAsync", () => {
});
});
test("demo mode advertises and filters the same inventory-only endpoint", async () => {
test("demo capabilities match the advertised inventory-only endpoint", () => {
const previousDemoMode = process.env.DEMO_MODE;
process.env.DEMO_MODE = "true";
try {
expect(getDockerEndpointsAsync()).toEqual([
expect.objectContaining({ id: "demo", name: "Demo Docker", capabilities: ["inventory"] }),
]);
const selected = await dockerContainersRequestHandler.handler({ endpointIds: ["demo"] }).getDataAsync();
expect(selected.data.endpoints).toEqual([expect.objectContaining({ id: "demo" })]);
expect(selected.data.containers.length).toBeGreaterThan(0);
const excluded = await dockerContainersRequestHandler.handler({ endpointIds: ["other"] }).getDataAsync();
expect(excluded.data).toEqual({ containers: [], endpoints: [] });
expect(hasDockerEndpointCapability("demo", "inventory")).toBe(true);
expect(hasDockerEndpointCapability("demo", "logs")).toBe(false);
expect(hasDockerEndpointCapability("other", "inventory")).toBe(false);
} finally {
dockerContainersRequestHandler.invalidateCache();
if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
else process.env.DEMO_MODE = previousDemoMode;
}
@@ -1,112 +0,0 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { weatherRequestHandler } from "./weather";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const makeWeatherResponse = () => {
const hours = Array.from({ length: 25 }, (_, index) => `2026-07-01T${String(index % 24).padStart(2, "0")}:00`);
const days = [
"2026-06-30",
...Array.from({ length: 7 }, (_, index) => `2026-07-${String(index + 1).padStart(2, "0")}`),
];
const hourlyValues = hours.map<number | null>((_, index) => index);
const dailyValues = days.map<number | null>((_, index) => index);
const sunrise = days.map<string | null>((day) => `${day}T05:00`);
return {
timezone: "Europe/Berlin",
utc_offset_seconds: 7200,
current: {
time: "2026-07-01T12:00",
temperature_2m: 24,
relative_humidity_2m: 55,
apparent_temperature: 25,
is_day: 1,
precipitation: 0,
weather_code: 1,
wind_speed_10m: 12,
wind_gusts_10m: 20,
},
hourly: {
time: hours,
temperature_2m: hourlyValues,
apparent_temperature: hourlyValues,
precipitation_probability: hourlyValues,
precipitation: hourlyValues,
weather_code: hourlyValues,
relative_humidity_2m: hourlyValues,
wind_speed_10m: hourlyValues,
wind_gusts_10m: hourlyValues,
},
daily: {
time: days,
weather_code: dailyValues,
temperature_2m_max: dailyValues,
temperature_2m_min: dailyValues,
apparent_temperature_max: dailyValues,
apparent_temperature_min: dailyValues,
precipitation_probability_max: dailyValues,
precipitation_sum: dailyValues,
relative_humidity_2m_mean: dailyValues,
wind_speed_10m_max: dailyValues,
wind_gusts_10m_max: dailyValues,
uv_index_max: dailyValues,
sunrise,
sunset: days.map((day) => `${day}T21:00`),
daylight_duration: dailyValues,
},
};
};
beforeEach(() => {
vi.clearAllMocks();
weatherRequestHandler.invalidateCache();
});
describe("weather request handler", () => {
it("requests bounded useful forecasts and normalizes location times", async () => {
const response = makeWeatherResponse();
response.hourly.temperature_2m = [null];
Reflect.deleteProperty(response.hourly, "wind_gusts_10m");
response.daily.sunrise[1] = null;
mockFetch.mockResolvedValue(new Response(JSON.stringify(response)));
const first = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
const second = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
expect(first.data.hourly).toHaveLength(24);
expect(first.data.daily).toHaveLength(7);
expect(first.data.daily[0]?.date).toBe("2026-07-01");
expect(first.data.hourly[0]?.temperature).toBeNull();
expect(first.data.hourly[1]?.temperature).toBeNull();
expect(first.data.hourly[0]?.windGusts).toBeNull();
expect(first.data.daily[0]?.sunriseAt).toBeNull();
expect(first.data.daily[1]?.sunriseAt).toBe("2026-07-02T03:00:00.000Z");
expect(second.data).toStrictEqual(first.data);
expect(mockFetch).toHaveBeenCalledOnce();
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
expect(requestUrl.searchParams.get("forecast_hours")).toBe("24");
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
expect(requestUrl.searchParams.has("past_days")).toBe(false);
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
expect(requestUrl.searchParams.get("current")).toContain("apparent_temperature");
expect(requestUrl.searchParams.get("hourly")).toContain("precipitation_probability");
expect(requestUrl.searchParams.get("daily")).toContain("uv_index_max");
});
it("rejects unsuccessful Open-Meteo responses", async () => {
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
await expect(weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync()).rejects.toThrow();
});
});
+42 -224
View File
@@ -1,255 +1,73 @@
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { z } from "zod";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
dayjs.extend(utc);
dayjs.extend(timezone);
const HOURLY_FORECAST_LENGTH = 24;
const DAILY_FORECAST_LENGTH = 7;
const requestWeatherAsync = async (input: { latitude: number; longitude: number }): Promise<Weather> => {
const url = new URL("https://api.open-meteo.com/v1/forecast");
url.searchParams.set("latitude", input.latitude.toString());
url.searchParams.set("longitude", input.longitude.toString());
url.searchParams.set(
"current",
[
"temperature_2m",
"relative_humidity_2m",
"apparent_temperature",
"is_day",
"precipitation",
"weather_code",
"wind_speed_10m",
"wind_gusts_10m",
].join(","),
);
url.searchParams.set(
"hourly",
[
"temperature_2m",
"apparent_temperature",
"precipitation_probability",
"precipitation",
"weather_code",
"relative_humidity_2m",
"wind_speed_10m",
"wind_gusts_10m",
].join(","),
);
url.searchParams.set(
"daily",
[
"weather_code",
"temperature_2m_max",
"temperature_2m_min",
"apparent_temperature_max",
"apparent_temperature_min",
"precipitation_probability_max",
"precipitation_sum",
"relative_humidity_2m_mean",
"wind_speed_10m_max",
"wind_gusts_10m_max",
"uv_index_max",
"sunrise",
"sunset",
"daylight_duration",
].join(","),
);
url.searchParams.set("timezone", "auto");
url.searchParams.set("forecast_hours", HOURLY_FORECAST_LENGTH.toString());
url.searchParams.set("forecast_days", DAILY_FORECAST_LENGTH.toString());
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
});
if (!res.ok) throw new ResponseError(res);
const json: unknown = await res.json();
const weather = await atLocationOutput.parseAsync(json);
const currentLocalDate = weather.current.time.slice(0, 10);
const matchedCurrentDayIndex = weather.daily.time.indexOf(currentLocalDate);
const currentDayIndex = matchedCurrentDayIndex < 0 ? 0 : matchedCurrentDayIndex;
const dailyIndexes = weather.daily.time
.map((_, index) => index)
.slice(currentDayIndex, currentDayIndex + DAILY_FORECAST_LENGTH);
return {
timezone: weather.timezone,
utcOffsetSeconds: weather.utc_offset_seconds,
current: {
observedAt: toUtcInstant(weather.current.time, weather.timezone),
temperature: weather.current.temperature_2m,
apparentTemperature: weather.current.apparent_temperature,
relativeHumidity: weather.current.relative_humidity_2m,
precipitation: weather.current.precipitation,
weatherCode: weather.current.weather_code,
weathercode: weather.current.weather_code,
windSpeed: weather.current.wind_speed_10m,
windspeed: weather.current.wind_speed_10m,
windGusts: weather.current.wind_gusts_10m,
isDay: weather.current.is_day === 1,
},
hourly: weather.hourly.time.slice(0, HOURLY_FORECAST_LENGTH).map((value, index) => {
return {
observedAt: toUtcInstant(value, weather.timezone),
temperature: weather.hourly.temperature_2m[index] ?? null,
apparentTemperature: weather.hourly.apparent_temperature[index] ?? null,
precipitationProbability: weather.hourly.precipitation_probability[index] ?? null,
precipitation: weather.hourly.precipitation[index] ?? null,
weatherCode: weather.hourly.weather_code[index] ?? null,
relativeHumidity: weather.hourly.relative_humidity_2m[index] ?? null,
windSpeed: weather.hourly.wind_speed_10m[index] ?? null,
windGusts: weather.hourly.wind_gusts_10m[index] ?? null,
};
}),
daily: dailyIndexes.map((index) => {
const value = weather.daily.time[index] ?? currentLocalDate;
return {
date: value,
time: value,
weatherCode: weather.daily.weather_code[index] ?? 404,
maxTemperature: weather.daily.temperature_2m_max[index] ?? null,
maxTemp: weather.daily.temperature_2m_max[index] ?? undefined,
minTemperature: weather.daily.temperature_2m_min[index] ?? null,
minTemp: weather.daily.temperature_2m_min[index] ?? undefined,
maxApparentTemperature: weather.daily.apparent_temperature_max[index] ?? null,
minApparentTemperature: weather.daily.apparent_temperature_min[index] ?? null,
precipitationProbability: weather.daily.precipitation_probability_max[index] ?? null,
precipitation: weather.daily.precipitation_sum[index] ?? null,
averageHumidity: weather.daily.relative_humidity_2m_mean[index] ?? null,
humidity: weather.daily.relative_humidity_2m_mean[index] ?? undefined,
maxWindSpeed: weather.daily.wind_speed_10m_max[index] ?? null,
maxWindGusts: weather.daily.wind_gusts_10m_max[index] ?? null,
uvIndex: weather.daily.uv_index_max[index] ?? null,
sunriseAt: toOptionalUtcInstant(weather.daily.sunrise[index], weather.timezone),
sunrise: weather.daily.sunrise[index] ?? undefined,
sunsetAt: toOptionalUtcInstant(weather.daily.sunset[index], weather.timezone),
sunset: weather.daily.sunset[index] ?? undefined,
daylightDuration: weather.daily.daylight_duration[index] ?? null,
};
}),
} satisfies Weather;
};
export const weatherRequestHandler = createWidgetRequestHandler({
cacheTtlMs: 5 * 60 * 1000,
requestAsync: requestWeatherAsync,
async requestAsync(input: { latitude: number; longitude: number }) {
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean&current_weather=true&timezone=auto`,
{ signal },
);
});
const json: unknown = await res.json();
const weather = await atLocationOutput.parseAsync(json);
return {
current: weather.current_weather,
daily: weather.daily.time.map((value, index) => {
return {
time: value,
weatherCode: weather.daily.weathercode[index] ?? 404,
maxTemp: weather.daily.temperature_2m_max[index],
minTemp: weather.daily.temperature_2m_min[index],
sunrise: weather.daily.sunrise[index],
sunset: weather.daily.sunset[index],
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
humidity: weather.daily.relative_humidity_2m_mean[index],
};
}),
} satisfies Weather;
},
});
const nullableNumberArray = z.array(z.number().nullable()).default([]);
const nullableStringArray = z.array(z.string().nullable()).default([]);
const atLocationOutput = z.object({
timezone: z.string().min(1),
utc_offset_seconds: z.number(),
current: z.object({
time: z.string(),
temperature_2m: z.number(),
relative_humidity_2m: z.number(),
apparent_temperature: z.number(),
is_day: z.number(),
precipitation: z.number(),
weather_code: z.number(),
wind_speed_10m: z.number(),
wind_gusts_10m: z.number(),
}),
hourly: z.object({
time: z.array(z.string()),
temperature_2m: nullableNumberArray,
apparent_temperature: nullableNumberArray,
precipitation_probability: nullableNumberArray,
precipitation: nullableNumberArray,
weather_code: nullableNumberArray,
relative_humidity_2m: nullableNumberArray,
wind_speed_10m: nullableNumberArray,
wind_gusts_10m: nullableNumberArray,
current_weather: z.object({
weathercode: z.number(),
temperature: z.number(),
windspeed: z.number(),
}),
daily: z.object({
time: z.array(z.string()),
weather_code: nullableNumberArray,
temperature_2m_max: nullableNumberArray,
temperature_2m_min: nullableNumberArray,
apparent_temperature_max: nullableNumberArray,
apparent_temperature_min: nullableNumberArray,
precipitation_probability_max: nullableNumberArray,
precipitation_sum: nullableNumberArray,
relative_humidity_2m_mean: nullableNumberArray,
wind_speed_10m_max: nullableNumberArray,
wind_gusts_10m_max: nullableNumberArray,
uv_index_max: nullableNumberArray,
sunrise: nullableStringArray,
sunset: nullableStringArray,
daylight_duration: nullableNumberArray,
weathercode: z.array(z.number()),
temperature_2m_max: z.array(z.number()),
temperature_2m_min: z.array(z.number()),
sunrise: z.array(z.string()),
sunset: z.array(z.string()),
wind_speed_10m_max: z.array(z.number()),
wind_gusts_10m_max: z.array(z.number()),
relative_humidity_2m_mean: z.array(z.number()),
}),
});
const toUtcInstant = (value: string, timeZone: string): string => {
const parsed = dayjs.tz(value, timeZone);
if (!parsed.isValid()) throw new Error(`Invalid weather timestamp: ${value}`);
return parsed.utc().toISOString();
};
const toOptionalUtcInstant = (value: string | null | undefined, timeZone: string): string | null => {
if (!value) return null;
return toUtcInstant(value, timeZone);
};
export interface Weather {
timezone: string;
utcOffsetSeconds: number;
current: {
temperature: number;
apparentTemperature: number;
relativeHumidity: number;
precipitation: number;
weatherCode: number;
weathercode: number;
windSpeed: number;
temperature: number;
windspeed: number;
windGusts: number;
isDay: boolean;
observedAt: string;
};
hourly: {
observedAt: string;
temperature: number | null;
apparentTemperature: number | null;
precipitationProbability: number | null;
precipitation: number | null;
weatherCode: number | null;
relativeHumidity: number | null;
windSpeed: number | null;
windGusts: number | null;
}[];
daily: {
date: string;
time: string;
weatherCode: number;
maxTemperature: number | null;
maxTemp: number | undefined;
minTemperature: number | null;
minTemp: number | undefined;
maxApparentTemperature: number | null;
minApparentTemperature: number | null;
precipitationProbability: number | null;
precipitation: number | null;
averageHumidity: number | null;
humidity: number | undefined;
maxWindSpeed: number | null;
maxWindGusts: number | null;
uvIndex: number | null;
sunriseAt: string | null;
sunrise: string | undefined;
sunsetAt: string | null;
sunset: string | undefined;
daylightDuration: number | null;
maxWindSpeed: number | undefined;
maxWindGusts: number | undefined;
humidity: number | undefined;
}[];
}
@@ -17,11 +17,10 @@ export const appIntegrationBoardMode = {
return groups;
}
// Mirrors the gate on app.search: either permission may browse the app catalogue, so an app
// administrator without board access still gets the group instead of a FORBIDDEN response.
const canSearchApps =
session.user.permissions.includes("board-modify-all") || session.user.permissions.includes("app-modify-all");
return groups.concat(canSearchApps ? [appsSearchGroup, integrationsSearchGroup] : [integrationsSearchGroup]);
return groups.concat(
session.user.permissions.includes("board-modify-all")
? [appsSearchGroup, integrationsSearchGroup]
: [integrationsSearchGroup],
);
},
} satisfies SearchMode;
+43 -423
View File
@@ -66,10 +66,15 @@
"themeAuto": "System",
"themeLight": "Light",
"themeDark": "Dark",
"serverTitle": "Usual server address",
"serverOriginHelp": "Homarr uses this address to prefill detected service URLs. You can still edit every service individually.",
"serverDescription": "This is a shortcut, not a restriction. Every detected service remains editable.",
"serverOrigin": "Domain, IP, or reverse-proxy path",
"serverOriginDescription": "Examples: home.lan, 192.168.1.10, or https://home.example.com/services. Choose how each service is appended below.",
"urlModeLabel": "Service address pattern",
"hostPort": "Same host, different ports",
"subdomain": "One subdomain per service",
"path": "One path per service",
"analytics": "Share anonymous product analytics",
"analyticsDescription": "Helps Homarr improve feature reliability. No service credentials or dashboard content are sent."
},
@@ -648,35 +653,6 @@
"description": "Allow members to manage and delete any search engine"
}
}
},
"matrix": {
"none": "No access",
"impliedHint": "Also grants: {permissions}",
"grantedViaAdmin": "Administrators already have this. Set Admin to “No access” to configure it separately.",
"preset": {
"label": "Presets",
"viewer": "Viewer",
"viewerDescription": "Read-only: view boards and medias, use apps and integrations that already exist.",
"editor": "Editor",
"editorDescription": "Day-to-day management: create and edit boards, apps, integrations and search engines.",
"admin": "Admin",
"adminDescription": "Unrestricted access to every feature and setting, including user management.",
"custom": "Custom"
},
"effective": {
"title": "Effective permissions",
"description": "The full, de-duplicated set of actions members of this group can perform, including permissions granted indirectly.",
"empty": "No permissions selected"
},
"noneDescription": "Members get no access to this area.",
"level": {
"view": "View",
"use": "Use",
"interact": "Interact",
"modify": "Modify",
"full": "Full"
},
"capabilityIncluded": "Already included in “{level}”"
}
},
"memberNotice": {
@@ -803,9 +779,7 @@
"action": "Create your first app",
"filteredTitle": "No apps match your search",
"filteredDescription": "No apps match \"{search}\". Try another name or URL.",
"clearSearch": "Clear search",
"createOnlyTitle": "You can only add apps",
"createOnlyDescription": "Your permissions let you create apps but not browse existing ones. Ask an administrator for app management access to see the full list."
"clearSearch": "Clear search"
}
},
"create": {
@@ -927,7 +901,7 @@
"migrationDescription": "This v1 custom widget is preserved but cannot run until it is migrated to Custom JSX v2.",
"migrationInstructions": "Open Actions to copy or export the original v1 definition for manual recreation. You can also use the migration prompt with an LLM. Existing board placements keep working after migration.",
"missingCredentials": "{count, plural, one {Credential missing} other {# credentials missing}}",
"missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Select this badge to configure the URL and encrypted values."
"missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Edit it to configure the encrypted values."
},
"create": {
"title": "Create Custom Widget",
@@ -960,7 +934,6 @@
"downloadSkill": "View Custom Widget Skill",
"publishWorkshop": "Publish to Workshop",
"migrate": "Migrate Custom Widget",
"configureSources": "Configure sources",
"copyLegacyDefinition": "Copy original widget content",
"exportLegacyDefinition": "Export original widget content",
"copyMigrationPrompt": "Copy LLM migration prompt",
@@ -974,13 +947,6 @@
"removeItem": "Remove item",
"addBar": "Add Bar"
},
"sourceSetupDialog": {
"title": "Configure widget sources",
"description": "Review where this widget connects and add or replace its encrypted credentials.",
"credentialsRequired": "Requests will fail until the required credentials are added.",
"cancel": "Cancel",
"save": "Save configuration"
},
"field": {
"name": "Name",
"description": "Description",
@@ -1332,8 +1298,6 @@
"legacyDefinitionCopyError": "Failed to copy the original widget content.",
"migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, copy its single JSON response, then choose Paste migrated widget here.",
"migrationPromptCopyError": "Failed to copy the migration prompt.",
"sourcesConfigured": "Widget sources and credentials saved.",
"sourceConfigurationError": "Failed to configure widget sources.",
"aiPromptCopied": "AI prompt copied to clipboard",
"aiPromptCopiedWithCount": "AI prompt copied to clipboard ({count, number} characters).",
"aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.",
@@ -1607,7 +1571,6 @@
"title": "Integrations",
"ariaLabel": "Configured integrations",
"search": "Search integrations",
"scopedNote": "Showing only the integrations you are allowed to manage.",
"action": {
"edit": "Edit {name}",
"delete": "Delete {name}"
@@ -1618,9 +1581,7 @@
"action": "Create your first integration",
"filteredTitle": "No integrations match your search",
"filteredDescription": "No integrations match \"{search}\". Try another name, type, or URL.",
"clearSearch": "Clear search",
"createOnlyTitle": "You can only add integrations",
"createOnlyDescription": "Your permissions let you create integrations but not manage existing ones. Ask an administrator for access to the ones you need."
"clearSearch": "Clear search"
}
},
"create": {
@@ -2166,13 +2127,6 @@
"undo": "Undo"
},
"here": "here",
"serviceUrlTemplate": {
"serverTitle": "Usual server address",
"urlModeLabel": "Service address pattern",
"hostPort": "Same host, different ports",
"subdomain": "One subdomain per service",
"path": "One path per service"
},
"iconPicker": {
"label": "Icon URL",
"description": "Search the library, paste an image URL, or upload a file.",
@@ -3153,6 +3107,9 @@
"label": "24-hour format",
"description": "Use 24-hour format instead of 12-hour format"
},
"showSeconds": {
"label": "Display seconds"
},
"useCustomTimezone": {
"label": "Use a fixed timezone"
},
@@ -3164,12 +3121,16 @@
"label": "Show the date"
},
"dateFormat": {
"label": "Date format",
"description": "Choose a Day.js preset. Each option shows a live example and its format tokens."
"label": "Date Format",
"description": "How the date should look like"
},
"customTimeFormat": {
"label": "Time format",
"description": "Choose a Day.js preset. The selected format controls 12/24-hour display and whether the clock updates every second."
"label": "Custom time format",
"description": "Use ISO 8601 to format time (this will override other options)"
},
"customDateFormat": {
"label": "Custom date format",
"description": "Use ISO 8601 to format date (this will override other options)"
},
"showWeather": {
"label": "Show weather",
@@ -3180,317 +3141,7 @@
},
"isWeatherFormatFahrenheit": {
"label": "Use Fahrenheit"
},
"animateWeatherIcon": {
"label": "Animate weather icon",
"description": "Animate the weather icon. Disabled by default and always disabled when reduced motion is requested."
},
"worldClockCities": {
"label": "World clock cities",
"description": "Add and order up to six cities for advanced view."
}
},
"timezoneList": {
"timezone": "Timezone",
"cityLabel": "City label",
"searchPlaceholder": "Search timezones",
"noResults": "No timezones found",
"unsupportedOption": "Unsupported: {timeZone}",
"add": "Add city",
"remove": "Remove {city}",
"reorder": "Reorder {city}",
"limit": "{count} of {maximum} cities configured"
},
"worldClock": {
"primary": "Primary clock",
"local": "Local time",
"today": "Today",
"tomorrow": "Tomorrow",
"yesterday": "Yesterday",
"noSecondaryCities": "Add cities in widget options to compare them here.",
"unsupportedPrimaryTitle": "Primary timezone is unavailable",
"unsupportedPrimary": "{timeZone} is not supported in this browser. Showing UTC as a clearly marked fallback.",
"unsupportedTimeZone": "Unsupported timezone: {timeZone}",
"accessibleTime": "{city}: {time}, {date}, {timeZone}",
"advancedLabel": "Advanced world clock",
"cities": "World clocks",
"cityCount": "{count} timezones",
"timeBarLabel": "24-hour time-of-day position for {city}",
"dayProgress": "{percent}% elapsed · {hours}h {minutes}m remaining",
"phaseLegend": "Time-of-day legend",
"tick": {
"midnight": "00",
"morning": "06",
"noon": "12",
"evening": "18",
"end": "24"
},
"phaseRange": {
"night": "00–05 · 21–24",
"dawn": "05–08",
"day": "08–18",
"dusk": "18–21"
},
"phase": {
"night": "Night",
"dawn": "Dawn",
"day": "Day",
"dusk": "Dusk"
}
}
},
"airQuality": {
"name": "Air Quality & UV",
"description": "Displays air quality, ultraviolet index, pollutants and pollen for a location.",
"option": {
"location": {
"label": "Air quality location"
},
"aqiStandard": {
"label": "AQI standard",
"options": {
"auto": "Automatic for locale",
"european": "European AQI",
"us": "US AQI"
}
},
"showUv": {
"label": "Show UV index"
},
"showPollutants": {
"label": "Show pollutant concentrations"
},
"showPollen": {
"label": "Show pollen concentrations"
}
},
"category": {
"good": "Good",
"fair": "Fair",
"moderate": "Moderate",
"unhealthySensitive": "Unhealthy for sensitive groups",
"unhealthy": "Unhealthy",
"poor": "Poor",
"veryPoor": "Very poor",
"veryUnhealthy": "Very unhealthy",
"extremelyPoor": "Extremely poor",
"hazardous": "Hazardous",
"unknown": "No AQI reading"
},
"uv": {
"label": "UV index",
"category": {
"low": "Low",
"moderate": "Moderate",
"high": "High",
"veryHigh": "Very high",
"extreme": "Extreme",
"unknown": "No reading"
}
},
"dominantPollutant": "Dominant pollutant",
"aqiLabel": "AQI",
"strongestPollen": "Highest pollen",
"unit": {
"concentration": "{value} µg/m³",
"pollen": "{value} grains/m³"
},
"pollutant": {
"pm2_5": "PM2.5",
"pm10": "PM10",
"ozone": "Ozone",
"nitrogenDioxide": "Nitrogen dioxide",
"sulphurDioxide": "Sulphur dioxide",
"carbonMonoxide": "Carbon monoxide"
},
"pollen": {
"alder": "Alder",
"birch": "Birch",
"grass": "Grass",
"mugwort": "Mugwort",
"olive": "Olive",
"ragweed": "Ragweed"
},
"compact": {
"sixHourTrend": "AQI trend for the next six hours"
},
"advanced": {
"observedAt": "Updated at {time}",
"next24Hours": "Next 24 hours",
"pollutants": "Pollutant concentrations",
"pollen": "Pollen concentrations",
"sevenDayForecast": "7-day maximum forecast",
"aqiValue": "AQI {value}",
"uvValue": "UV {value}",
"uvShort": "UV {value}",
"noForecast": "No forecast data available"
}
},
"countdown": {
"name": "Countdowns",
"description": "Tracks upcoming events and recurring anniversaries.",
"option": {
"events": {
"label": "Events",
"description": "Add and order up to 20 one-time or yearly countdowns. Dates are entered in the selected timezone."
},
"showProgress": {
"label": "Show progress"
},
"showSeconds": {
"label": "Show seconds",
"description": "Keep seconds visible for every upcoming event."
}
},
"dateTimeEventList": {
"newEvent": "New event",
"add": "Add event",
"remove": "Remove {event}",
"reorder": "Reorder {event}",
"eventLabel": "Event label",
"timezone": "Timezone",
"unsupportedOption": "Unsupported: {timeZone}",
"recurrence": "Repeats",
"recurrenceOption": {
"none": "Does not repeat",
"yearly": "Every year"
},
"target": "Event date and time",
"trackProgress": "Track progress from a start date",
"start": "Progress start date and time",
"confirmDateTime": "Confirm date and time",
"limit": "{count} of {maximum} events configured"
},
"empty": {
"title": "No countdowns yet",
"description": "Add an event in widget options to start counting down."
},
"completed": "Completed",
"yearly": "Yearly",
"progress": "Event progress",
"upcoming": "Upcoming",
"completedEvents": "Completed events",
"invalid": {
"title": "Some events cannot be displayed",
"description": "{count, plural, one {One event has an invalid date or unsupported timezone.} other {# events have an invalid date or unsupported timezone.}}",
"value": "Unavailable"
},
"alert": {
"notifications": "Browser notifications",
"permissionDenied": "Notifications are blocked in this browser.",
"reached": "The countdown has reached its target."
}
},
"timer": {
"name": "Timer / Pomodoro",
"description": "Runs a simple timer or a configurable Pomodoro sequence.",
"option": {
"mode": {
"label": "Mode",
"options": {
"timer": "Timer",
"pomodoro": "Pomodoro"
}
},
"timerMinutes": {
"label": "Timer minutes"
},
"focusMinutes": {
"label": "Focus minutes"
},
"shortBreakMinutes": {
"label": "Short break minutes"
},
"longBreakMinutes": {
"label": "Long break minutes"
},
"sessionsBeforeLongBreak": {
"label": "Focus sessions before a long break"
},
"autoStartBreaks": {
"label": "Automatically start breaks"
},
"autoStartFocus": {
"label": "Automatically start focus sessions"
}
},
"phase": {
"timer": "Timer",
"focus": "Focus",
"shortBreak": "Short break",
"longBreak": "Long break"
},
"action": {
"start": "Start",
"pause": "Pause",
"reset": "Reset"
},
"attention": {
"timerComplete": "Timer complete",
"phaseReady": "{phase} ready"
},
"progress": "Timer progress: {progress}%",
"compact": {
"completedFocus": "{count, plural, =0 {No focus sessions completed} one {# focus session completed} other {# focus sessions completed}}"
},
"alert": {
"title": "Timer complete",
"completed": "{phase} completed."
},
"advanced": {
"sequence": "Pomodoro sequence",
"focusSession": "Focus {session}",
"settings": "Permanent settings",
"settingsHint": "Use the widget context menu to edit these board settings.",
"localAlerts": "Local alerts",
"notifications": "Browser notifications",
"sound": "Timer sounds",
"localAlertsHint": "Timer sounds are enabled by default. Sounds and notifications apply only to this widget in this browser.",
"notificationsDenied": "Notifications are blocked in this browser."
}
},
"today": {
"name": "Today",
"description": "Displays the current date and progress through the calendar.",
"option": {
"weekConvention": {
"label": "Week numbering",
"options": {
"locale": "Use locale rules",
"iso": "ISO 8601"
}
},
"showWeekNumber": {
"label": "Show week number"
},
"showDayOfYear": {
"label": "Show day of year"
},
"showQuarter": {
"label": "Show quarter"
},
"showYearProgress": {
"label": "Show year progress"
}
},
"weekNumber": "Week {week}",
"dayOfYear": "Day {day} of {total}",
"quarter": "Quarter {quarter}",
"daysAfterToday": "{days, plural, =0 {Last day of the year} one {# day after today} other {# days after today}}",
"weekProgressLabel": "Week progress",
"monthProgressLabel": "Month progress",
"yearProgressLabel": "Year progress",
"yearProgress": "Year progress: {progress}%",
"weekConvention": {
"locale": "Week numbers follow your locale",
"iso": "ISO 8601 week numbers"
},
"advanced": {
"week": "Week number",
"dayOfYear": "Day of year",
"quarter": "Quarter",
"quarterValue": "Q{quarter}",
"daysRemaining": "Days after today"
}
},
"minecraftServerStatus": {
@@ -3836,10 +3487,6 @@
"label": "1 Month"
}
}
},
"showDetails": {
"label": "Show details",
"description": "Show the name, change, and time range in the compact view. Turn off for just the symbol and price."
}
}
},
@@ -3886,13 +3533,6 @@
"disableTemperatureDecimals": {
"label": "Disable temperature decimals"
},
"animateIcons": {
"label": "Animate weather icons"
},
"showHumidity": {
"label": "Show humidity",
"description": "Display humidity in current conditions, hourly details and daily forecasts"
},
"showCurrentWindSpeed": {
"label": "Show current wind speed",
"description": "Only on current weather"
@@ -3946,40 +3586,11 @@
"detailsFor": "Show weather details for {date}",
"advanced": {
"forecast": "7-day forecast",
"hourlyForecast": "Next 24 hours",
"day": "Day",
"time": "Time",
"conditions": "Conditions",
"temperature": "Temperature",
"feelsLike": "Feels like {temperature}",
"feelsLikeLabel": "Feels like",
"feelsLikeRange": "Feels like: {maximum} / {minimum}",
"highLow": "High {maximum} · low {minimum}",
"observedAt": "Updated at {time}",
"temperature": "High / low",
"humidity": "Humidity",
"averageHumidity": "Average humidity: {humidity}%",
"currentHumidity": "Current humidity",
"wind": "Wind / gusts ({unit})",
"currentWind": "Current wind / gusts",
"windValue": "{speed} / {gusts} {unit}",
"windDetail": "Max wind / gusts: {speed} / {gusts} {unit}",
"nextHourPrecipitation": "Precipitation next hour",
"precipitationChance": "Precipitation chance",
"precipitationValue": "{probability}% · {amount} mm",
"precipitationDetail": "Precipitation: {probability}% · {amount} mm",
"todayUv": "Today's max UV index",
"uvIndex": "Max UV index: {value}",
"daylight": "Daylight: {duration}",
"hourlyTooltip": "Feels like {feelsLike}{temperatureUnit} · {amount} mm · {humidity}% humidity · {speed} {speedUnit} wind",
"hourlyTooltipWithoutHumidity": "Feels like {feelsLike}{temperatureUnit} · {amount} mm · {speed} {speedUnit} wind",
"detailLabel": {
"feelsLike": "Feels like",
"precipitation": "Precipitation",
"humidity": "Average humidity",
"wind": "Max wind / gusts",
"uv": "Max UV index",
"daylight": "Daylight"
},
"sun": "Sunrise / sunset",
"noForecast": "No forecast data available"
}
@@ -4116,11 +3727,6 @@
"name": "Docker stats",
"description": "Stats of your containers (This widget can only be added with administrator privileges)",
"option": {
"endpointIds": {
"label": "Docker environments",
"description": "Leave empty to show containers from every Docker environment",
"placeholder": "All Docker environments"
},
"columns": {
"label": "Columns to show"
},
@@ -4514,7 +4120,6 @@
"transcoding": "Transcoding",
"paused": "Paused",
"remaining": "{minutes} min left",
"seasonEpisode": "S{season} E{episode}",
"metadata": {
"title": "Stats for nerds",
"video": {
@@ -6069,7 +5674,6 @@
},
"dynamicSelect": {
"noResults": "No results found",
"loading": "Loading...",
"loadError": "Failed to load options"
},
"assistant": {
@@ -6519,8 +6123,7 @@
},
"notFound": {
"title": "Not Found",
"text": "Could not find requested resource",
"backToHome": "Back to management home"
"text": "Could not find requested resource"
},
"error": {
"title": "This page couldn't load",
@@ -7458,18 +7061,23 @@
"docker": {
"title": "Containers",
"reconciliation": {
"title": "Assisted Docker setup",
"suggestions": "{count, plural, =0 {No suggestions} one {# suggestion} other {# suggestions}}",
"title": "Service setup",
"description": "Docker services are compared with your apps and integrations. Review every suggestion before Homarr changes anything.",
"attention": "{count} need attention",
"endpointUnavailable": "Some services could not be checked because these endpoints are unavailable: {names}",
"empty": "No visible Docker services were found.",
"emptyFilter": "No services match this view.",
"ambiguous": "More than one existing item could match this service. Homarr will not choose one automatically.",
"match": "{confidence} confidence match",
"filter": {
"attention": "Needs attention",
"represented": "Already set up",
"all": "All"
},
"confidence": {
"high": "High",
"medium": "Medium"
},
"state": {
"newRecognized": "Ready to connect",
"newApp": "New service",
@@ -7478,9 +7086,19 @@
"moved": "Address changed"
},
"url": {
"manual": "Enter an address manually"
"label": "Service address",
"inputLabel": "Service URL",
"manual": "Enter an address manually",
"invalid": "Enter a valid HTTP or HTTPS URL"
},
"reason": {
"publishedAddress": "Published address reported by Docker",
"dockerEndpointHost": "Docker endpoint host combined with the published port",
"sharedContainerNetwork": "Server-only address for services sharing the container network",
"manualHostRequired": "Enter the address that your browser or Homarr server can reach"
},
"health": {
"title": "Observed service layers",
"layer": {
"docker": "Docker",
"integrationConfiguration": "Integration",
@@ -7502,15 +7120,17 @@
}
},
"action": {
"review": "Review",
"refresh": "Refresh",
"retry": "Try again",
"dismiss": "Dismiss",
"restoreDismissed": "Restore dismissed ({count})",
"setupIntegration": "Set up integration",
"createApp": "Create app",
"reviewIntegration": "Review integration",
"viewRepresentation": "View setup"
},
"loadError": {
"title": "Docker setup assistant could not be loaded",
"title": "Service setup could not be loaded",
"message": "Docker service information is unavailable. Try again without leaving this page."
},
"refreshError": {
-1
View File
@@ -65,7 +65,6 @@
"@mantine/dates": "catalog:",
"@mantine/form": "catalog:",
"@mantine/hooks": "catalog:",
"@rexa-developer/tiks": "catalog:",
"@tabler/icons-react": "catalog:",
"@tiptap/extension-color": "catalog:",
"@tiptap/extension-highlight": "catalog:",
-6
View File
@@ -3,9 +3,7 @@ import { WidgetAnchorNoteInput } from "./widget-anchor-note-input";
import { WidgetAppInput } from "./widget-app-input";
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-input";
import { WidgetDateTimeEventListInput } from "./widget-date-time-event-list-input";
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
import { WidgetDynamicMultiSelectInput } from "./widget-dynamic-multiselect-input";
import { WidgetLocationInput } from "./widget-location-input";
import { WidgetMultiTextInput } from "./widget-multi-text-input";
import { WidgetMultiReleasesRepositoriesInput } from "./widget-multiReleasesRepositories-input";
@@ -16,7 +14,6 @@ import { WidgetSliderInput } from "./widget-slider-input";
import { WidgetSortedItemListInput } from "./widget-sortable-item-list-input";
import { WidgetSwitchInput } from "./widget-switch-input";
import { WidgetTextInput } from "./widget-text-input";
import { WidgetTimezoneListInput } from "./widget-timezone-list-input";
import { WidgetUmamiEventInput } from "./widget-umami-event-input";
import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
@@ -28,8 +25,6 @@ const mapping = {
internal: WidgetInternalInput,
anchorNote: WidgetAnchorNoteInput,
text: WidgetTextInput,
dateTimeEventList: WidgetDateTimeEventListInput,
timezoneList: WidgetTimezoneListInput,
location: WidgetLocationInput,
multiSelect: WidgetMultiSelectInput,
multiText: WidgetMultiTextInput,
@@ -41,7 +36,6 @@ const mapping = {
sortableItemList: WidgetSortedItemListInput,
multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput,
dynamicSelect: WidgetDynamicSelectInput,
dynamicMultiSelect: WidgetDynamicMultiSelectInput,
umamiEventName: WidgetUmamiEventInput,
umamiEventNames: WidgetUmamiEventNamesInput,
umamiWebsite: WidgetUmamiWebsiteInput,
@@ -314,7 +314,7 @@ export function JsonPathTreePicker({
{hasData && (
<Combobox.Header>
<Group gap="xs" px="xs" pt="xs" pb={4}>
<Badge size="sm" color="teal" variant="light" leftSection={<IconCircleCheck size="var(--mantine-font-size-xs)" />}>
<Badge size="sm" color="teal" variant="light" leftSection={<IconCircleCheck size={12} />}>
{loadedHint}
</Badge>
<Text size="xs" c="dimmed">
@@ -353,7 +353,7 @@ function TreeNodeOption({ node, selected, isSearching }: { node: FlatNode; selec
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
{node.hasChildren && (
<IconChevronDown
size="var(--mantine-font-size-sm)"
size={14}
style={{
flexShrink: 0,
color: "var(--mantine-color-dimmed)",
@@ -362,7 +362,7 @@ function TreeNodeOption({ node, selected, isSearching }: { node: FlatNode; selec
}}
/>
)}
<TypeIcon size="var(--mantine-font-size-sm)" style={{ flexShrink: 0, opacity: 0.5 }} />
<TypeIcon size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
<Text
size="sm"
fw={selected ? 600 : 400}
@@ -76,7 +76,7 @@ export const WidgetCustomWidgetConfigurationInput = ({
return (
<Stack gap="md">
{issues[0] && (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size="var(--mantine-font-size-md)" />}>
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
<Text size="sm">{labels("needsAttention", { message: issues[0].message })}</Text>
</Alert>
)}
@@ -115,7 +115,7 @@ export const WidgetCustomWidgetSelectInput = ({
>
<Group wrap="nowrap" gap="sm">
<Avatar src={def.iconUrl} size={36} radius="md" color="blue">
<IconApi size="var(--mantine-font-size-lg)" />
<IconApi size={18} />
</Avatar>
<Stack gap={1} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
@@ -152,7 +152,7 @@ export const WidgetCustomWidgetSelectInput = ({
<Alert
color="yellow"
variant="light"
icon={<IconAlertTriangle size="var(--mantine-font-size-lg)" />}
icon={<IconAlertTriangle size={18} />}
title={labels("migrationRequired")}
>
<Text size="xs">{labels("migrationDescription")}</Text>
@@ -1,317 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { ActionIcon, Button, Card, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { IconGripVertical, IconPlus, IconTrash } from "@tabler/icons-react";
import dayjs from "dayjs";
import { createId } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { formatWallDateTime, resolveWallDateTimeString } from "../countdown/recurrence";
import type { DateTimeEventRecurrence, OptionDateTimeEvent } from "../options";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetDateTimeEventListInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"dateTimeEventList">) => {
const t = useWidgetInputTranslation(kind, property);
const tEditor = useEventListTranslation(kind);
const form = useFormContext();
const fieldPath = `options.${property}`;
const currentValue = form.values.options[property];
const values = Array.isArray(currentValue) ? (currentValue as OptionDateTimeEvent[]) : options.defaultValue;
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
if (Array.isArray(currentValue)) return;
form.setFieldValue(fieldPath, options.defaultValue);
}, [currentValue, fieldPath, form, options.defaultValue]);
const setValues = (nextValues: OptionDateTimeEvent[]) => form.setFieldValue(fieldPath, nextValues);
const addEvent = () => {
if (values.length >= options.maxValues) return;
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const target = dayjs().add(1, "day").minute(0).second(0).millisecond(0);
setValues([
...values,
{
id: createId(),
label: tEditor("newEvent"),
targetUtc: target.toISOString(),
timeZone: localTimeZone,
recurrence: "none",
},
]);
};
return (
<Fieldset legend={t("label")}>
<Stack gap="sm">
{options.withDescription && (
<Text size="sm" c="dimmed">
{t("description")}
</Text>
)}
<Button
type="button"
variant="light"
leftSection={<IconPlus size="var(--mantine-font-size-md)" />}
onClick={addEvent}
disabled={values.length >= options.maxValues}
>
{tEditor("add")}
</Button>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
if (!over || active.id === over.id) return;
const activeIndex = values.findIndex(({ id }) => id === active.id);
const overIndex = values.findIndex(({ id }) => id === over.id);
if (activeIndex < 0 || overIndex < 0) return;
setValues(arrayMove(values, activeIndex, overIndex));
}}
>
<SortableContext items={values.map(({ id }) => id)} strategy={verticalListSortingStrategy}>
<Stack gap="xs">
{values.map((event) => (
<SortableEvent
key={event.id}
kind={kind}
event={event}
timeZoneOptions={options.timeZoneOptions}
onChange={(nextEvent) =>
setValues(values.map((candidate) => (candidate.id === event.id ? nextEvent : candidate)))
}
onRemove={() => setValues(values.filter((candidate) => candidate.id !== event.id))}
/>
))}
</Stack>
</SortableContext>
</DndContext>
<Text size="xs" c="dimmed">
{tEditor("limit", { count: values.length, maximum: options.maxValues })}
</Text>
</Stack>
</Fieldset>
);
};
interface SortableEventProps {
kind: WidgetKind;
event: OptionDateTimeEvent;
timeZoneOptions: readonly { value: string; label: string }[];
onChange: (event: OptionDateTimeEvent) => void;
onRemove: () => void;
}
const SortableEvent = ({ kind, event, timeZoneOptions, onChange, onRemove }: SortableEventProps) => {
const t = useEventListTranslation(kind);
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: event.id,
});
const style = {
transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` : undefined,
transition,
opacity: isDragging ? 0.6 : undefined,
zIndex: isDragging ? 1 : undefined,
};
const hasCurrentTimeZone = timeZoneOptions.some(({ value }) => value === event.timeZone);
const selectableTimeZones = hasCurrentTimeZone
? timeZoneOptions
: [{ value: event.timeZone, label: t("unsupportedOption", { timeZone: event.timeZone }) }, ...timeZoneOptions];
const targetValue = formatWallDateTime(event.targetUtc, event.timeZone);
const startValue = event.startUtc ? formatWallDateTime(event.startUtc, event.timeZone) : null;
const changeTimeZone = (timeZone: string) => {
const target = resolveWallDateTimeString(targetValue, timeZone);
const start = startValue ? resolveWallDateTimeString(startValue, timeZone) : null;
onChange({
...event,
timeZone,
targetUtc: target?.toISOString() ?? event.targetUtc,
startUtc: event.startUtc ? (start?.toISOString() ?? event.startUtc) : undefined,
});
};
const updateWallDateTime = (value: string | null, field: "targetUtc" | "startUtc") => {
if (!value) return;
const resolved = resolveWallDateTimeString(value, event.timeZone);
if (!resolved) return;
onChange({ ...event, [field]: resolved.toISOString() });
};
return (
<Card ref={setNodeRef} withBorder p="sm" style={style}>
<Stack gap="xs">
<Group gap="xs" wrap="nowrap" align="end">
<ActionIcon
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
variant="subtle"
color="gray"
size="lg"
aria-label={t("reorder", { event: event.label })}
style={{ cursor: "grab" }}
>
<IconGripVertical size="var(--mantine-font-size-lg)" />
</ActionIcon>
<TextInput
flex={1}
label={t("eventLabel")}
value={event.label}
maxLength={64}
onChange={(changeEvent) => onChange({ ...event, label: changeEvent.currentTarget.value })}
/>
<ActionIcon
color="red"
variant="subtle"
size="lg"
aria-label={t("remove", { event: event.label })}
onClick={onRemove}
>
<IconTrash size="var(--mantine-font-size-lg)" />
</ActionIcon>
</Group>
<Group align="end" grow>
<Select
label={t("timezone")}
searchable
data={selectableTimeZones}
value={event.timeZone}
onChange={(timeZone) => {
if (timeZone) changeTimeZone(timeZone);
}}
/>
<Select
label={t("recurrence")}
value={event.recurrence}
data={(["none", "yearly"] as const).map((value) => ({ value, label: t(`recurrenceOption.${value}`) }))}
onChange={(value) => {
if (value) onChange({ ...event, recurrence: value as DateTimeEventRecurrence });
}}
/>
</Group>
<ConfirmedDateTimePicker
label={t("target")}
value={targetValue || null}
onChange={(value) => updateWallDateTime(value, "targetUtc")}
confirmLabel={t("confirmDateTime")}
/>
{event.recurrence === "none" && (
<>
<Switch
label={t("trackProgress")}
checked={event.startUtc !== undefined}
onChange={(changeEvent) => {
if (!changeEvent.currentTarget.checked) {
const nextEvent = { ...event };
delete nextEvent.startUtc;
onChange(nextEvent);
return;
}
onChange({ ...event, startUtc: new Date().toISOString() });
}}
/>
{event.startUtc && (
<ConfirmedDateTimePicker
label={t("start")}
value={startValue}
onChange={(value) => updateWallDateTime(value, "startUtc")}
confirmLabel={t("confirmDateTime")}
/>
)}
</>
)}
</Stack>
</Card>
);
};
interface ConfirmedDateTimePickerProps {
label: string;
value: string | null;
confirmLabel: string;
onChange: (value: string | null) => void;
}
const ConfirmedDateTimePicker = ({ label, value, confirmLabel, onChange }: ConfirmedDateTimePickerProps) => {
const [draftValue, setDraftValue] = useState(value);
useEffect(() => setDraftValue(value), [value]);
const resetDraft = () => setDraftValue(value);
const confirmDraft = () => {
if (!draftValue) return;
onChange(draftValue);
};
return (
<DateTimePicker
label={label}
value={draftValue}
valueFormat="YYYY-MM-DD HH:mm"
timePickerProps={{ withDropdown: true, popoverProps: { withinPortal: false } }}
popoverProps={{ withinPortal: true }}
submitButtonProps={{ "aria-label": confirmLabel, onClick: confirmDraft }}
onChange={setDraftValue}
onDropdownClose={resetDraft}
/>
);
};
type EventListTranslationKey =
| "newEvent"
| "add"
| "remove"
| "reorder"
| "eventLabel"
| "timezone"
| "unsupportedOption"
| "recurrence"
| "recurrenceOption.none"
| "recurrenceOption.yearly"
| "target"
| "trackProgress"
| "start"
| "confirmDateTime"
| "limit";
type EventListTranslation = (key: EventListTranslationKey, values?: Record<string, string | number>) => string;
const useEventListTranslation = (kind: WidgetKind) =>
useScopedI18n(`widget.${kind}.dateTimeEventList` as never) as unknown as EventListTranslation;
@@ -1,45 +0,0 @@
"use client";
import { Loader, MultiSelect } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetDynamicMultiSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"dynamicMultiSelect">) => {
const t = useScopedI18n("widget.dynamicSelect");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
const selectedValues = (inputProps.value as string[] | undefined) ?? [];
const { data: selectData, isPending, isError } = options.useOptions();
const data = [
...selectData,
...selectedValues
.filter((selectedValue) => !selectData.some((option) => option.value === selectedValue))
.map((selectedValue) => ({ value: selectedValue, label: selectedValue })),
];
return (
<MultiSelect
{...inputProps}
label={tInput("label")}
description={options.withDescription ? tInput("description") : undefined}
placeholder={isPending ? t("loading") : tInput("placeholder")}
leftSection={isPending ? <Loader size="xs" /> : undefined}
searchable
clearable
maxValues={options.maxValues}
nothingFoundMessage={t("noResults")}
data={data}
disabled={isError}
error={isError ? t("loadError") : inputProps.error}
/>
);
};
@@ -89,7 +89,7 @@ export const WidgetDynamicSelectInput = ({
style={{ marginInlineStart: "auto" }}
color="currentColor"
opacity={0.6}
size="var(--mantine-font-size-lg)"
size={18}
stroke={1.5}
/>
)}
@@ -97,7 +97,7 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp
disabled={!selectionEnabled}
onClick={onSearch}
variant="light"
leftSection={<IconListSearch size="var(--mantine-font-size-md)" />}
leftSection={<IconListSearch size={16} />}
>
{tLocation("search")}
</Button>
@@ -257,7 +257,7 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
})}
>
<ActionIcon color="red" variant="subtle" onClick={onSelect}>
<IconClick size="var(--mantine-font-size-md)" />
<IconClick size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
@@ -229,19 +229,19 @@ export const WidgetMultiReleasesRepositoriesInput = ({
})
}
variant="light"
leftSection={<IconEdit size="var(--mantine-font-size-sm)" />}
leftSection={<IconEdit size={15} />}
size="xs"
>
{tRepository("edit.label")}
</Button>
<ActionIcon variant="transparent" color="red" onClick={() => onRepositoryRemove(index)}>
<IconTrash size="var(--mantine-font-size-sm)" />
<IconTrash size={15} />
</ActionIcon>
</Group>
{Object.keys(form.errors).filter((key) => key.startsWith(`options.${property}.${index}.`)).length > 0 && (
<Group align="center" justify="center" gap="xs" bg="red.1">
<IconTriangleFilled size="var(--mantine-font-size-sm)" color="var(--mantine-color-red-filled)" />
<IconTriangleFilled size={15} color="var(--mantine-color-red-filled)" />
<Text size="sm" c="red">
{tRepository("invalid")}
</Text>
@@ -371,7 +371,7 @@ const ProviderTokenInput = ({
}}
style={{ flex: 1 }}
size="xs"
leftSection={<IconKey size="var(--mantine-font-size-sm)" />}
leftSection={<IconKey size={14} />}
/>
{editing && value.trim() && (
<Button size="xs" onClick={handleSave} loading={saving}>
@@ -380,7 +380,7 @@ const ProviderTokenInput = ({
)}
{hasToken && !editing && (
<ActionIcon variant="light" color="red" size="sm" onClick={handleDelete} loading={saving}>
<IconTrash size="var(--mantine-font-size-sm)" />
<IconTrash size={14} />
</ActionIcon>
)}
</Group>
@@ -51,20 +51,20 @@ export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInput
label: translateIfNecessary(t, option.label) ?? option.value,
},
)}
leftSection={CurrentIcon && <CurrentIcon size="var(--mantine-font-size-md)" stroke={1.5} />}
leftSection={CurrentIcon && <CurrentIcon size={16} stroke={1.5} />}
renderOption={({ option, checked }) => {
const Icon = getIconFor(options.options, String(option.value));
return (
<Group flex="1" gap="xs">
{Icon && <Icon color="currentColor" opacity={0.6} size="var(--mantine-font-size-lg)" stroke={1.5} />}
{Icon && <Icon color="currentColor" opacity={0.6} size={18} stroke={1.5} />}
{option.label}
{checked && (
<IconCheck
style={{ marginInlineStart: "auto" }}
color="currentColor"
opacity={0.6}
size="var(--mantine-font-size-lg)"
size={18}
stroke={1.5}
/>
)}
@@ -72,7 +72,6 @@ export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInput
);
}}
description={options.withDescription ? tWidget("description") : undefined}
placeholder={options.withPlaceholder ? tWidget("placeholder") : undefined}
searchable={options.searchable}
{...inputProps}
/>
@@ -1,249 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { ActionIcon, Button, Card, Fieldset, Group, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconGripVertical, IconPlus, IconTrash } from "@tabler/icons-react";
import { createId } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import type { OptionTimezone } from "../options";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetTimezoneListInput = ({ property, kind, options }: CommonWidgetInputProps<"timezoneList">) => {
const t = useWidgetInputTranslation(kind, property);
const tEditor = useTimezoneListTranslation(kind);
const form = useFormContext();
const fieldPath = `options.${property}`;
const currentValue = form.values.options[property];
const values = Array.isArray(currentValue) ? (currentValue as OptionTimezone[]) : options.defaultValue;
const [selectedTimeZone, setSelectedTimeZone] = useState<string | null>(null);
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
if (Array.isArray(currentValue)) return;
form.setFieldValue(fieldPath, options.defaultValue);
}, [currentValue, fieldPath, form, options.defaultValue]);
const usedTimeZones = useMemo(() => new Set(values.map(({ timeZone }) => timeZone)), [values]);
const availableOptions = options.timeZoneOptions.map((option) => ({
...option,
disabled: usedTimeZones.has(option.value),
}));
const isFull = values.length >= options.maxValues;
const setValues = (nextValues: OptionTimezone[]) => form.setFieldValue(fieldPath, nextValues);
const addCity = (city: OptionTimezone) => {
if (isFull || usedTimeZones.has(city.timeZone)) return;
setValues([...values, city]);
};
const addSelectedTimeZone = () => {
if (!selectedTimeZone) return;
const option = options.timeZoneOptions.find(({ value }) => value === selectedTimeZone);
if (!option) return;
const label = option.label.replace(` (${option.value})`, "");
addCity({ id: createId(), label, timeZone: option.value });
setSelectedTimeZone(null);
};
return (
<Fieldset legend={t("label")}>
<Stack gap="sm">
{options.withDescription && (
<Text size="sm" c="dimmed">
{t("description")}
</Text>
)}
<Group gap="xs" align="end" wrap="nowrap">
<Select
flex={1}
label={tEditor("timezone")}
placeholder={tEditor("searchPlaceholder")}
searchable
clearable
data={availableOptions}
value={selectedTimeZone}
onChange={setSelectedTimeZone}
disabled={isFull}
nothingFoundMessage={tEditor("noResults")}
/>
<Button
leftSection={<IconPlus size="var(--mantine-font-size-md)" />}
onClick={addSelectedTimeZone}
disabled={isFull || selectedTimeZone === null}
>
{tEditor("add")}
</Button>
</Group>
<Group gap="xs">
{options.presets.map((preset) => (
<Button
key={preset.timeZone}
size="compact-xs"
variant="light"
disabled={isFull || usedTimeZones.has(preset.timeZone)}
onClick={() => addCity({ ...preset, id: createId() })}
>
{preset.label}
</Button>
))}
</Group>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
if (!over || active.id === over.id) return;
const activeIndex = values.findIndex(({ id }) => id === active.id);
const overIndex = values.findIndex(({ id }) => id === over.id);
if (activeIndex < 0 || overIndex < 0) return;
setValues(arrayMove(values, activeIndex, overIndex));
}}
>
<SortableContext items={values.map(({ id }) => id)} strategy={verticalListSortingStrategy}>
<Stack gap="xs">
{values.map((city) => (
<SortableTimezone
key={city.id}
kind={kind}
city={city}
timeZoneOptions={options.timeZoneOptions}
usedTimeZones={usedTimeZones}
onChange={(nextCity) =>
setValues(values.map((candidate) => (candidate.id === city.id ? nextCity : candidate)))
}
onRemove={() => setValues(values.filter((candidate) => candidate.id !== city.id))}
/>
))}
</Stack>
</SortableContext>
</DndContext>
<Text size="xs" c="dimmed">
{tEditor("limit", { count: values.length, maximum: options.maxValues })}
</Text>
</Stack>
</Fieldset>
);
};
interface SortableTimezoneProps {
kind: WidgetKind;
city: OptionTimezone;
timeZoneOptions: readonly { value: string; label: string }[];
usedTimeZones: ReadonlySet<string>;
onChange: (city: OptionTimezone) => void;
onRemove: () => void;
}
const SortableTimezone = ({
kind,
city,
timeZoneOptions,
usedTimeZones,
onChange,
onRemove,
}: SortableTimezoneProps) => {
const t = useTimezoneListTranslation(kind);
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: city.id,
});
const style = {
transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` : undefined,
transition,
opacity: isDragging ? 0.6 : undefined,
zIndex: isDragging ? 1 : undefined,
};
const hasCurrentTimeZone = timeZoneOptions.some(({ value }) => value === city.timeZone);
const selectableTimeZones = hasCurrentTimeZone
? timeZoneOptions
: [{ value: city.timeZone, label: t("unsupportedOption", { timeZone: city.timeZone }) }, ...timeZoneOptions];
return (
<Card ref={setNodeRef} withBorder p="xs" style={style}>
<Group gap="xs" wrap="wrap" align="end">
<ActionIcon
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
variant="subtle"
color="gray"
size="lg"
aria-label={t("reorder", { city: city.label })}
style={{ cursor: "grab" }}
>
<IconGripVertical size="var(--mantine-font-size-lg)" />
</ActionIcon>
<TextInput
flex={1}
label={t("cityLabel")}
value={city.label}
onChange={(event) => onChange({ ...city, label: event.currentTarget.value })}
/>
<Select
flex={2}
label={t("timezone")}
searchable
data={selectableTimeZones.map((option) => ({
...option,
disabled: option.value !== city.timeZone && usedTimeZones.has(option.value),
}))}
value={city.timeZone}
onChange={(timeZone) => {
if (timeZone) onChange({ ...city, timeZone });
}}
/>
<ActionIcon
color="red"
variant="subtle"
size="lg"
aria-label={t("remove", { city: city.label })}
onClick={onRemove}
>
<IconTrash size="var(--mantine-font-size-lg)" />
</ActionIcon>
</Group>
</Card>
);
};
type TimezoneListTranslationKey =
| "timezone"
| "cityLabel"
| "searchPlaceholder"
| "noResults"
| "unsupportedOption"
| "add"
| "remove"
| "reorder"
| "limit";
type TimezoneListTranslation = (key: TimezoneListTranslationKey, values?: Record<string, string | number>) => string;
const useTimezoneListTranslation = (kind: WidgetKind) =>
useScopedI18n(`widget.${kind}.timezoneList` as never) as unknown as TimezoneListTranslation;
@@ -1,399 +0,0 @@
"use client";
import { useId } from "react";
import { CompositeChart, Sparkline } from "@mantine/charts";
import { Box, Group, Paper, ScrollArea, SimpleGrid, Stack, Text, VisuallyHidden } from "@mantine/core";
import { IconMapPin } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import type { AirQualityData, PollutantKey, PollenKey } from "./display";
import {
getAqiCategory,
getAqiColor,
getAqiStandard,
getAqiValue,
getCompactAirQualityLayout,
getDailyAqiValue,
getDominantPollutant,
getStrongestPollen,
getUpcomingHours,
getUvCategory,
} from "./display";
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
const t = useScopedI18n("widget.airQuality");
const query = clientApi.widget.airQuality.atLocation.useQuery({
latitude: options.location.latitude,
longitude: options.location.longitude,
});
const airQuality = getUsableWidgetQueryData(query);
if (isInitialWidgetQueryPending(query)) return <WidgetQueryLoadingState />;
if (!airQuality) return <WidgetEmptyState />;
return (
<Box h="100%" w="100%" pos="relative">
{displayMode === "advanced" ? (
<AdvancedAirQuality airQuality={airQuality} options={options} width={width} />
) : (
<CompactAirQuality airQuality={airQuality} height={height} options={options} width={width} />
)}
{query.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={query.error} label={t("name")} />
</Box>
)}
</Box>
);
}
interface AirQualityViewProps {
airQuality: AirQualityData;
options: WidgetComponentProps<"airQuality">["options"];
}
const CompactAirQuality = ({
airQuality,
height,
options,
width,
}: AirQualityViewProps & { height: number; width: number }) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.airQuality");
const layout = getCompactAirQualityLayout(width, height);
const standard = getAqiStandard(options.aqiStandard, locale);
const value = getAqiValue(airQuality.current, standard);
const category = getAqiCategory(value, standard);
const dominant = getDominantPollutant(airQuality.current, standard);
const strongestPollen = getStrongestPollen(airQuality.current);
const labels = useAirQualityLabels();
const sixHours = getUpcomingHours(airQuality, 6)
.map((hour) => getAqiValue(hour, standard))
.filter((hour): hour is number => hour !== null);
return (
<Stack
h="100%"
w="100%"
justify="center"
align="center"
gap={layout.tiny ? 2 : "xs"}
p="xs"
style={{ overflow: "hidden" }}
>
<Group gap="xs" wrap="nowrap" align="center">
<Text fz={layout.tiny ? 34 : 42} fw={700} lh={1} c={getAqiColor(category)}>
{formatAqi(value, locale)}
</Text>
<Stack gap={1} align="flex-start">
<Text size={layout.tiny ? "xs" : "sm"} fw={600}>
{t(`category.${category}`)}
</Text>
</Stack>
</Group>
{!layout.tiny && (
<Group gap="md" justify="center" wrap="wrap">
{options.showUv && (
<MetricLine
label={t("uv.label")}
value={`${formatDecimal(airQuality.current.uvIndex, locale)} · ${t(`uv.category.${getUvCategory(airQuality.current.uvIndex)}`)}`}
/>
)}
{options.showPollutants && dominant && (
<MetricLine label={t("dominantPollutant")} value={labels.pollutants[dominant.key]} />
)}
</Group>
)}
{!layout.tiny && layout.showParticulatesAndPollen && (
<Group gap="md" justify="center" wrap="wrap">
{options.showPollutants && (
<>
<MetricLine
label={labels.pollutants.pm2_5}
value={formatConcentration(airQuality.current.pollutants.pm2_5, locale, t)}
/>
<MetricLine
label={labels.pollutants.pm10}
value={formatConcentration(airQuality.current.pollutants.pm10, locale, t)}
/>
</>
)}
{options.showPollen && strongestPollen && (
<MetricLine
label={t("strongestPollen")}
value={`${labels.pollen[strongestPollen.key]} · ${formatPollen(strongestPollen.value, locale, t)}`}
/>
)}
</Group>
)}
{!layout.tiny && layout.showSparkline && sixHours.length > 1 && (
<Box component="figure" w="100%" h={34} m={0}>
<Sparkline
aria-hidden
h="100%"
w="100%"
data={sixHours}
color={getAqiColor(category)}
curveType="monotone"
fillOpacity={0.2}
strokeWidth={2}
/>
<VisuallyHidden component="figcaption">{t("compact.sixHourTrend")}</VisuallyHidden>
</Box>
)}
</Stack>
);
};
const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps & { width: number }) => {
const chartId = useId();
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.airQuality");
const standard = getAqiStandard(options.aqiStandard, locale);
const value = getAqiValue(airQuality.current, standard);
const category = getAqiCategory(value, standard);
const labels = useAirQualityLabels();
const upcoming = getUpcomingHours(airQuality, 24);
const chartData = upcoming.map((hour) => ({
observedAt: hour.observedAt,
aqi: getAqiValue(hour, standard) ?? Number.NaN,
uv: hour.uvIndex ?? Number.NaN,
}));
const series = [
{ name: "aqi", label: t("aqiLabel"), color: getAqiColor(category), type: "line" as const },
...(options.showUv
? [{ name: "uv", label: t("uv.label"), color: "yellow.7", type: "line" as const, yAxisId: "right" as const }]
: []),
];
return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
<Stack gap="md" p="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2}>
<Text fz={38} fw={700} lh={1} c={getAqiColor(category)}>
{formatAqi(value, locale)}
</Text>
<Text fw={600}>{t(`category.${category}`)}</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Group gap={5} wrap="nowrap">
<IconMapPin size="var(--mantine-font-size-md)" aria-hidden />
<Text size="sm" fw={600}>
{options.location.name}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("advanced.observedAt", {
time: formatLocalizedTime(airQuality.current.observedAt, locale, { timeZone: airQuality.timezone }),
})}
</Text>
</Stack>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
<MetricCard label={t("aqiLabel")} value={formatAqi(value, locale)} />
{options.showUv && (
<MetricCard label={t("uv.label")} value={formatDecimal(airQuality.current.uvIndex, locale)} />
)}
{options.showPollutants && (
<>
<MetricCard
label={labels.pollutants.pm2_5}
value={formatConcentration(airQuality.current.pollutants.pm2_5, locale, t)}
/>
<MetricCard
label={labels.pollutants.pm10}
value={formatConcentration(airQuality.current.pollutants.pm10, locale, t)}
/>
</>
)}
</SimpleGrid>
<SimpleGrid cols={width >= 800 ? 2 : 1} spacing="md">
<Paper withBorder p="sm">
<Text id={chartId} size="sm" fw={600} mb="xs">
{t("advanced.next24Hours")}
</Text>
{chartData.length > 0 ? (
<>
<Box aria-hidden>
<CompositeChart
h={220}
data={chartData}
dataKey="observedAt"
series={series}
curveType="monotone"
gridAxis="y"
tickLine="none"
withDots={false}
withLegend
withRightYAxis={options.showUv}
rightYAxisProps={{ width: 34 }}
xAxisProps={{
tickFormatter: (time: string) =>
formatLocalizedTime(time, locale, { timeZone: airQuality.timezone }),
}}
/>
</Box>
<VisuallyHidden>
<ul aria-labelledby={chartId}>
{upcoming.map((hour) => (
<li key={hour.observedAt}>
{formatLocalizedTime(hour.observedAt, locale, { timeZone: airQuality.timezone })}:{" "}
{t("advanced.aqiValue", {
value: formatAqi(getAqiValue(hour, standard), locale),
})}
{options.showUv
? `, ${t("advanced.uvValue", { value: formatDecimal(hour.uvIndex, locale) })}`
: ""}
</li>
))}
</ul>
</VisuallyHidden>
</>
) : (
<Text size="sm" c="dimmed">
{t("advanced.noForecast")}
</Text>
)}
</Paper>
<Stack gap="md">
{options.showPollutants && (
<Paper withBorder p="sm">
<Text size="sm" fw={600} mb="xs">
{t("advanced.pollutants")}
</Text>
<SimpleGrid cols={2} spacing="xs">
{pollutantKeys.map((key) => (
<MetricLine
key={key}
label={labels.pollutants[key]}
value={formatConcentration(airQuality.current.pollutants[key], locale, t)}
/>
))}
</SimpleGrid>
</Paper>
)}
{options.showPollen && hasAvailableValue(airQuality.current.pollen) && (
<Paper withBorder p="sm">
<Text size="sm" fw={600} mb="xs">
{t("advanced.pollen")}
</Text>
<SimpleGrid cols={2} spacing="xs">
{pollenKeys.map((key) => {
const pollen = airQuality.current.pollen[key];
if (pollen === null) return null;
return <MetricLine key={key} label={labels.pollen[key]} value={formatPollen(pollen, locale, t)} />;
})}
</SimpleGrid>
</Paper>
)}
</Stack>
</SimpleGrid>
<Paper withBorder p="sm">
<Text size="sm" fw={600} mb="xs">
{t("advanced.sevenDayForecast")}
</Text>
<SimpleGrid cols={{ base: 2, sm: 4, md: 7 }} spacing="xs">
{airQuality.daily.map((day) => {
const dailyAqi = getDailyAqiValue(day, standard);
return (
<Stack key={day.date} gap={2} align="center">
<Text size="xs" c="dimmed">
{formatLocalizedDate(day.date, locale, { weekday: "short" })}
</Text>
<Text fw={700} c={getAqiColor(getAqiCategory(dailyAqi, standard))}>
{formatAqi(dailyAqi, locale)}
</Text>
{options.showUv && (
<Text size="xs">{t("advanced.uvShort", { value: formatDecimal(day.uvIndexMax, locale) })}</Text>
)}
</Stack>
);
})}
</SimpleGrid>
</Paper>
</Stack>
</ScrollArea>
);
};
const MetricCard = ({ label, value }: { label: string; value: string }) => (
<Paper withBorder p="xs">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text fz="lg" fw={700}>
{value}
</Text>
</Paper>
);
const MetricLine = ({ label, value }: { label: string; value: string }) => (
<Stack gap={0} miw={0}>
<Text size="xs" c="dimmed" truncate>
{label}
</Text>
<Text size="sm" fw={600} truncate>
{value}
</Text>
</Stack>
);
const pollutantKeys: PollutantKey[] = ["pm2_5", "pm10", "ozone", "nitrogenDioxide", "sulphurDioxide", "carbonMonoxide"];
const pollenKeys: PollenKey[] = ["alder", "birch", "grass", "mugwort", "olive", "ragweed"];
const useAirQualityLabels = () => {
const t = useScopedI18n("widget.airQuality");
return {
pollutants: {
pm2_5: t("pollutant.pm2_5"),
pm10: t("pollutant.pm10"),
ozone: t("pollutant.ozone"),
nitrogenDioxide: t("pollutant.nitrogenDioxide"),
sulphurDioxide: t("pollutant.sulphurDioxide"),
carbonMonoxide: t("pollutant.carbonMonoxide"),
} satisfies Record<PollutantKey, string>,
pollen: {
alder: t("pollen.alder"),
birch: t("pollen.birch"),
grass: t("pollen.grass"),
mugwort: t("pollen.mugwort"),
olive: t("pollen.olive"),
ragweed: t("pollen.ragweed"),
} satisfies Record<PollenKey, string>,
};
};
const formatAqi = (value: number | null, locale: string) => {
if (value === null) return "—";
return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(value);
};
const formatDecimal = (value: number | null, locale: string) => {
if (value === null) return "—";
return new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
};
type ScopedAirQualityTranslator = ReturnType<typeof useScopedI18n<"widget.airQuality">>;
const formatConcentration = (value: number | null, locale: string, t: ScopedAirQualityTranslator) =>
t("unit.concentration", { value: formatDecimal(value, locale) });
const formatPollen = (value: number | null, locale: string, t: ScopedAirQualityTranslator) =>
t("unit.pollen", { value: formatDecimal(value, locale) });
const hasAvailableValue = (values: Record<string, number | null>) =>
Object.values(values).some((value) => value !== null);
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff. Show more