Compare commits

..
Author SHA1 Message Date
Afonso Jorge Ramos e328f3e9a1 fix(onboarding): keep the account submit disabled until the step advances 2026-08-20 18:16:00 +02:00
Thomas d241bac55d feat: improve assistant conversation experience (#6636) 2026-08-20 16:26:23 +02:00
Thomas 58a24569ce feat: expand utility widgets (#6638) 2026-08-20 16:26:04 +02:00
Thomas 53b518960c feat: retain application log history (#6633)
* feat: retain application log history

* fix: harden retained log streaming

* fix: harden contextual log focus

* fix: retain multiline log output
2026-08-20 12:57:30 +02:00
Thomas ddf8ba9948 feat: rework management settings layout (#6634) 2026-08-20 12:33:18 +02:00
Bergasha a2219b72f6 Merge pull request #6632 from homarr-labs/feat/v2-stocks-widget
feat (v2 stocks widget):Add a "Show details" toggle to the stock widget's compact view
2026-08-20 12:59:52 +10:00
Bergasha 7150cdbbab Add a "Show details" toggle to the stock widget's compact view
The compact view auto-reveals name/change/range as the widget gets
bigger, which can overlap badly depending on how the widget's actual
proportions land relative to those breakpoints. Add a switch (on by
default, matching current behavior) to force the minimal symbol+price
look regardless of size. Scoped to the compact layout only - the
Shift-triggered advanced view is untouched.
2026-08-20 12:32:42 +10:00
Thomas ff14613f95 feat: recalculate permission scope & permission matrix UI (#6612) 2026-08-19 17:22:31 +02:00
ajnart 78b3dc1922 fix: restore workshop create icon 2026-08-19 16:34:21 +02:00
ajnart 2e97b337d2 fix: remove universal create search 2026-08-19 16:29:30 +02:00
Thomas 369a2660b1 Embed integration editing in widget tab (#6630) 2026-08-19 14:01:11 +02:00
Bergasha 60aec370d6 Merge pull request #6627 from homarr-labs/fix/v2-media-server-widget
fix(v2 media widget): updated media server widget
2026-08-19 16:56:30 +10:00
Bergasha 6a76b9eafe Merge branch 'release/v2' into fix/v2-media-server-widget 2026-08-19 16:55:15 +10:00
Bergasha 76ba13fbad Address Codex review feedback on PR #6627
- Classify IPv4/IPv6 link-local addresses (169.254.0.0/16, fe80::/10) as
  LAN in Jellyfin location parsing - they can't traverse a router, so a
  false WAN reading was possible for common autoconfig/link-local sessions.
- Fix portrait video resolution mislabeling - a 1080x1920 clip was
  classified off its long-axis height (1920) and showed as 1440p. Classify
  by the short axis instead so orientation doesn't flip the result.
- Raise the compact-mode status column breakpoint from 420 to 540px - at
  420 the fixed-width user (200px) and status (190px) columns left almost
  no room for the currentlyPlaying column, the widget's primary content.
- Document the new resolution indicator in the media-server widget docs.
2026-08-19 16:45:09 +10:00
Thomas 150d38be62 feat(docker): add endpoint filtering and streamline setup assistant (#6625)
* feat(docker): add endpoint filtering and setup assistant

* fix(docker): simplify environment setup

* test(docker): cover demo endpoint filtering

* refactor(docker): use accordion for setup suggestions

* fix(docker): use docker icon for setup assistant

* refactor(docker): simplify assisted service setup

* refactor(docker): reuse shared translations

* fix(docker): address review feedback
2026-08-19 08:45:07 +02:00
Bergasha a4bbed0cb8 Fixed resolution showing the raw cropped height (e.g. 804p) instead of the nominal class Plex/Jellyfin display (1080p) - letterboxed/cinemascope sources are shorter than their resolution tier, so classify off the width-derived 16:9 height instead 2026-08-19 14:54:48 +10:00
Bergasha 90fa9e8741 Added tests for the LAN/WAN parsing and fixed IPv4-mapped IPv6 addresses being misclassified as WAN - a dual-stack server can report a LAN client as ::ffff:192.168.x.x, which the private-range check was missing since it only matched literal IPv6 ranges 2026-08-19 13:52:14 +10:00
Bergasha 4b20d6ecd0 Widened the status column from 140 to 190px - the Np resolution format is shorter so line 2 had room, but Transcoding video/audio badges are noticeably longer than Direct play and needed the space on line 1 2026-08-19 13:33:21 +10:00
Bergasha 6cac34e968 Changed resolution to show the actual streaming resolution (transcode target) instead of the source file's, formatted as e.g. 1080p instead of 1920x1080 2026-08-19 13:33:14 +10:00
Bergasha c6f77cec2e Added resolution to the status column, restructured into two lines: status + LAN/WAN on top, resolution + bitrate underneath 2026-08-19 13:33:08 +10:00
Bergasha acc97da84b Added LAN/WAN detection to Jellyfin, matching what Plex already reports - Jellyfin's sessions endpoint only gives a client IP (RemoteEndPoint), so classify it against the standard private IP ranges ourselves 2026-08-19 13:32:40 +10:00
Bergasha 6b10ac2ab5 Gave User a fixed 200px width so Currently playing (the busiest column) gets the largest share of the row instead of splitting it evenly with User 2026-08-19 13:32:34 +10:00
Bergasha c7563f2c39 Fixed Jellyfin bitrate showing null on direct play - the sessions endpoint only reliably fills in MediaSources bitrate while transcoding, so sum the individual media streams' own bitrates as a fallback 2026-08-19 13:32:28 +10:00
Bergasha 96676182f4 Gave the status column a fixed 140px width - it had no explicit width so it shared an equal, unconstrained share of the row with the other columns despite only holding a small badge 2026-08-19 13:32:21 +10:00
Bergasha 3769d88ebb Dimmed the column header labels and added a bottom border under the sticky header, so it reads as its own band instead of the same weight as the row data 2026-08-19 13:32:15 +10:00
Bergasha d9c35744f6 Only show a server's badge in the footer when it actually has an active session - selecting both Plex and Jellyfin always showed both badges even if only one was streaming. Also gave the footer badges a pill style and added a separator between the stream count and bitrate 2026-08-19 13:31:58 +10:00
Bergasha 33991f4e22 Added flexShrink to the session avatar so it stops getting squished in a tight row 2026-08-19 13:31:33 +10:00
Bergasha df300919fc Gated season/episode fields on episode type - was showing S/E labels on movies and music too, using the episode number as season 2026-08-19 13:31:27 +10:00
158 changed files with 10571 additions and 2033 deletions

No files matched your search

@@ -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_ENDPOINTS` | JSON array of named, capability-scoped Docker or Podman endpoints. Supports socket, explicitly insecure TCP, and TLS transports. See the [Docker and Podman integration guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
| `DOCKER_HOSTNAMES` | Legacy comma-separated list of plaintext TCP hostnames. Ignored when `DOCKER_ENDPOINTS` is set | For example `localhost,docker.example.com` | - |
| `DOCKER_PORTS` | Legacy comma-separated list of plaintext TCP ports. Ignored when `DOCKER_ENDPOINTS` is set | For example `2375,2376` | - |
| `DOCKER_SOCKET_PATHS` | Legacy comma-separated list of mounted Docker-compatible socket paths. Ignored when `DOCKER_ENDPOINTS` is set | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
| Environment Variable | Description | Possible values | Default |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
| `DOCKER_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 | - |
## Database
+31 -16
View File
@@ -24,12 +24,11 @@ import { dockerContainersWidget } from "@site/docs/widgets/docker-containers";
]}
/>
## Enabling the Docker integration
## Connecting Docker to Homarr
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.
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.
In Docker Compose, you can simply add this line:
@@ -122,7 +121,23 @@ services:
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
```
For named endpoints, transport security, or least-privilege actions, prefer `DOCKER_ENDPOINTS`. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
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:
```yaml
services:
@@ -163,16 +178,12 @@ 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`
:::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.
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`.
### Managing your Docker containers within Homarr
Homarr allows you to interact with Docker containers running on your system.
The **Service setup** section compares the visible containers with your existing Homarr apps and integrations. It keeps every service distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
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.
Homarr can show a service as:
@@ -181,15 +192,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
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.
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.
:::info[Review before changing]
Service discovery is read-only. Homarr never creates or changes an app or integration automatically; use the action on an individual suggestion to review and confirm its setup.
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.
:::
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.
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.
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.
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.
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.
@@ -216,6 +227,10 @@ 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.
+4
View File
@@ -138,6 +138,10 @@ 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
+4 -1
View File
@@ -44,12 +44,15 @@ host gateway address.
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
add Homarr items as context, `/` for shortcuts, and the paperclip for supported files.
Use **Conversations** to search, switch, rename, export, or delete threads.
Each request includes the user's name, current page and board, home board, server time, browser time zone, and a small
summary of accessible Homarr resources. Live status is fetched only when needed.
## Permissions and privacy
- Read-only actions can run automatically. Changes require approval.
- Read-only actions can run automatically. Changes require approval by default. Automatic approvals apply only to the
current conversation, turn off when you switch conversations, and never answer questions for you.
- Homarr permissions apply to every tool call and mentioned item.
- API keys and custom headers are encrypted and never returned to the browser.
- Conversations are stored per user and can be exported or deleted.
+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 can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
Spotlight. It lists the available actions directly and 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.
@@ -81,9 +81,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 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.
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.
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
create one.
+34
View File
@@ -0,0 +1,34 @@
---
tags:
- Administration
- Logs
- Troubleshooting
---
# Logs
The logs page shows recent Homarr application logs and continues updating as new events occur. Use it to investigate
widget, integration, and server errors without opening the container logs separately.
## Opening the logs page
Navigate to **Management** > **Tools** > **Logs**. The page and widget log links are only available to users with the
**View logs** permission.
When a widget reports an error, **Check logs for more details** opens the logs page in a new tab and marks the time when
the widget displayed the error. The related server error normally appears immediately above that marker. If those logs
have already been removed from the retained history, the marker explains that they are no longer available.
## Retained and live logs
Homarr retains the latest 5,000 log events in Redis. Opening the page first loads this history and then continues with
live events without a gap. The history is bounded, so older events are removed automatically.
The history survives normal application restarts when the configured Redis instance persists its data. For complete
platform-level history, startup failures, or events older than the retained limit, use the logs provided by Docker or
your deployment platform.
## Display controls
The severity selector applies to both retained and live events. Selecting a different level redraws the existing view
using that threshold. The font-size control changes the terminal text size and is remembered in the browser.
+1 -1
View File
@@ -13,7 +13,7 @@ tags:
The settings page applies options to the whole Homarr application.
Some changes need a full page reload before they take effect.
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
All settings sections remain visible in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
## Analytics
@@ -0,0 +1,47 @@
---
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`.
@@ -0,0 +1,43 @@
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",
},
],
},
};
+21 -1
View File
@@ -15,6 +15,26 @@ 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";
@@ -27,7 +47,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 advancedView={false} />
<WidgetInteractionGuide />
### Adding the widget
+35 -22
View File
@@ -20,18 +20,6 @@ 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.",
@@ -52,21 +40,46 @@ export const clockWidget: WidgetDefinition = {
},
{
name: "Date format",
description: "Format of the date displayed below the clock.",
values: "A collection of date formats, e.g. 'DD/MM/YYYY', 'MM/DD/YYYY', etc.",
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",
defaultValue: "dddd, MMMM D",
},
{
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: "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 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: "-",
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",
},
],
},
@@ -0,0 +1,44 @@
---
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
@@ -0,0 +1,31 @@
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,20 +7,15 @@ 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 can only be used with administrator privileges.
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.
:::
### Screenshots
@@ -29,16 +24,6 @@ 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,6 +8,12 @@ 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, 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, 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.
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
@@ -0,0 +1,54 @@
---
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
@@ -0,0 +1,61 @@
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
@@ -0,0 +1,34 @@
---
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
@@ -0,0 +1,43 @@
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",
},
],
},
};
+18 -15
View File
@@ -13,23 +13,21 @@ import { weatherWidget } from ".";
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
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.
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.
### Screenshots
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.
import forecastWeather from "./img/forecast.png";
import currentWeather from "./img/current.png";
import advancedWeather from "./img/advanced-view.png";
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.
<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>
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.
<WidgetInteractionGuide />
@@ -43,5 +41,10 @@ import advancedWeather from "./img/advanced-view.png";
### Data source
We use the Open Meteo API to fetch the weather and locations.
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.
If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
+19 -3
View File
@@ -28,8 +28,24 @@ 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",
@@ -59,7 +75,7 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
defaultValue: "no",
defaultValue: "yes",
},
{
name: "Has forecast",
@@ -67,13 +83,13 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
defaultValue: "no",
defaultValue: "yes",
},
{
name: "Amount of forecast days",
description: "Number of days to show in the forecast",
values: "1-7",
defaultValue: "5",
defaultValue: "3",
},
],
},
@@ -1,4 +1,4 @@
import { IconCloud, IconWind, IconArrowUpRight, IconArrowDownRight } from "@tabler/icons-react";
import { IconCloud, IconCloudRain, IconSun } from "@tabler/icons-react";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
@@ -6,21 +6,27 @@ export const WeatherWidget = ({ className }: CommonWidgetProps) => {
return (
<WidgetCard width={1} className={clsx("gap-2", className)}>
<div className="flex gap-2 justify-center items-center">
<IconCloud size={20} />
<span className="text-xl font-bold">10.8°C</span>
</div>
<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>
<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>
<div className="flex justify-center items-center">
<IconArrowDownRight size={14} />
<span className="text-sm">6.4°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>
<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>
</div>
</WidgetCard>
+4
View File
@@ -2106,6 +2106,10 @@
"enum": [
"clock",
"weather",
"today",
"airQuality",
"countdown",
"timer",
"app",
"iframe",
"video",
@@ -1,6 +1,6 @@
"use client";
import { useRef, useState } from "react";
import { useCallback, useImperativeHandle, 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,8 +27,14 @@ import type { AnyMappedTestConnectionError } from "../../_components/test-connec
interface EditIntegrationFormProps {
integration: RouterOutputs["integration"]["byId"];
embedded?: boolean;
hideButtons?: boolean;
onSuccess?: () => void;
formRef?: React.Ref<EditIntegrationFormHandle>;
}
export interface EditIntegrationFormHandle {
submit: () => Promise<boolean>;
isDirty: () => boolean;
}
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
@@ -44,7 +50,12 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
}),
);
export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }: EditIntegrationFormProps) => {
export const EditIntegrationForm = ({
integration,
hideButtons = false,
onSuccess,
formRef,
}: EditIntegrationFormProps) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const allSecretKinds = getAllSecretKindOptions(integration.kind);
@@ -82,147 +93,172 @@ export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
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"),
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,
});
return;
}
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,
});
// 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;
}
// 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"),
showSuccessNotification({
title: t("integration.page.edit.notification.success.title"),
message: t("integration.page.edit.notification.success.message"),
});
requestAnimationFrame(() => errorRef.current?.focus());
return;
}
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],
);
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"));
}
};
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],
);
const isInitialSecretKinds =
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
form.values.secrets.length === initialSecretsKinds.length;
return (
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
<Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
const formFields = (
<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`)}
key={secret.kind}
kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
</SecretCard>
))}
{isInitialSecretKinds
? null
: form.values.secrets.map(({ kind }, index) => (
<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
withAsterisk
key={kind}
kind={kind}
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
key={secret.kind}
kind={secret.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>
<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>
)}
<Group justify="end" align="center">
{!embedded && (
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
</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>
<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>
)}
{!hideButtons && (
<Group justify="end" align="center">
<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>
</form>
)}
</Stack>
);
if (hideButtons) {
return formFields;
}
return <form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>{formFields}</form>;
};
type FormType = z.infer<typeof formSchema>;
@@ -29,6 +29,7 @@ export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) =>
}))}
{...form.getInputProps("defaultColorScheme")}
SelectOption={AppearanceCustomOption}
withinPortal
/>
</SectionCard>
);
@@ -2,7 +2,7 @@
import { Switch, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { RouterOutputs } from "@homarr/api";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
@@ -12,11 +12,11 @@ import type { FormValues } from "./settings-form";
interface BoardSettingsFormProps {
form: UseFormReturnType<FormValues>;
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
}
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormProps) => {
const tBoard = useScopedI18n("management.page.settings.section.board");
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
return (
<SectionCard title={tBoard("title")}>
@@ -26,6 +26,7 @@ export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
clearable
boards={selectableBoards}
{...form.getInputProps("homeBoardId")}
withinPortal
/>
<BoardSelect
@@ -34,6 +35,7 @@ export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
clearable
boards={selectableBoards}
{...form.getInputProps("mobileHomeBoardId")}
withinPortal
/>
<Text fw={500}>{tBoard("status.title")}</Text>
@@ -22,6 +22,7 @@ export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
label={tCulture("defaultLocale.label")}
{...localeInputProps}
value={localeInputProps.value as SupportedLanguage}
withinPortal
/>
</SectionCard>
);
@@ -2,7 +2,7 @@
import { Select } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { RouterOutputs } from "@homarr/api";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
@@ -11,11 +11,11 @@ import type { FormValues } from "./settings-form";
interface SearchSettingsFormProps {
form: UseFormReturnType<FormValues>;
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
}
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
export const SearchSettingsForm = ({ form, selectableSearchEngines }: SearchSettingsFormProps) => {
const tSearch = useScopedI18n("management.page.settings.section.search");
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
return (
<SectionCard title={tSearch("title")}>
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { Button, Stack } from "@mantine/core";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
@@ -58,9 +59,11 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
interface SettingsFormProps {
initialData: ServerSettings;
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
}
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
@@ -169,13 +172,13 @@ export const SettingsForm = ({ initialData }: SettingsFormProps) => {
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="xl">
<AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} />
<BoardSettingsForm form={form} />
<BoardSettingsForm form={form} selectableBoards={selectableBoards} />
<UserSettingsForm form={form} />
<SearchSettingsForm form={form} />
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
<AppearanceSettingsForm form={form} />
<CultureSettingsForm form={form} />
<AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} />
{form.isDirty() && (
<UnsavedChangesBar>
@@ -23,12 +23,20 @@ export default async function SettingsPage() {
notFound();
}
const serverSettings = await api.serverSettings.getAll();
const tSettings = await getScopedI18n("management.page.settings");
const [serverSettings, selectableBoards, selectableSearchEngines, tSettings] = await Promise.all([
api.serverSettings.getAll(),
api.board.getPublicBoards(),
api.searchEngine.getSelectable({ withIntegrations: false }),
getScopedI18n("management.page.settings"),
]);
return (
<ManagePageLayout title={tSettings("title")}>
<SettingsForm initialData={serverSettings} />
<SettingsForm
initialData={serverSettings}
selectableBoards={selectableBoards}
selectableSearchEngines={selectableSearchEngines}
/>
</ManagePageLayout>
);
}
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
import {
dismissDockerReconciliationCandidate,
filterDockerReconciliationInbox,
getValidDockerServiceUrl,
getTemplateUrl,
} from "./docker-reconciliation-inbox";
const candidates = [
@@ -37,9 +37,44 @@ describe("Docker reconciliation inbox", () => {
expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed);
});
test("accepts only HTTP service URLs", () => {
expect(getValidDockerServiceUrl("https://service.example.com")).toBe("https://service.example.com/");
expect(getValidDockerServiceUrl("ftp://service.example.com")).toBeNull();
expect(getValidDockerServiceUrl("service.example.com")).toBeNull();
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("");
});
});
@@ -1,3 +1,6 @@
import type { DockerServiceMatch, DockerServicePort, UrlTemplateMode } from "@homarr/definitions";
import { buildAppUrl, buildIntegrationUrl, getIntegrationDefaultPort } from "@homarr/definitions";
export type DockerReconciliationInboxFilter = "attention" | "represented" | "all";
interface DockerReconciliationInboxCandidate {
@@ -24,11 +27,28 @@ export const filterDockerReconciliationInbox = <Candidate extends DockerReconcil
export const dismissDockerReconciliationCandidate = (dismissedCandidateKeys: string[], candidateKey: string) =>
dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey];
export const getValidDockerServiceUrl = (value: string) => {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
} catch {
return null;
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);
}
return buildAppUrl(candidate.container.name, serverOrigin, urlMode, publishedPort);
};
@@ -1,46 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import {
Accordion,
ActionIcon,
Alert,
Autocomplete,
Avatar,
Badge,
Button,
Card,
Collapse,
Divider,
Group,
Paper,
SegmentedControl,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconAlertTriangle, IconArrowRight, IconEyeOff, IconPlugConnected, IconRefresh } from "@tabler/icons-react";
import {
IconAlertTriangle,
IconApps,
IconCheck,
IconChevronDown,
IconEye,
IconEyeOff,
IconBrandDocker,
IconMinus,
IconPlugConnected,
IconRefresh,
IconSettings,
} 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 { useScopedI18n } from "@homarr/translation/client";
import { normalizeServiceUrl, ServiceUrlTemplate } from "@homarr/onboarding";
import { useI18n, 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,
getValidDockerServiceUrl,
getTemplateUrl,
} from "./docker-reconciliation-inbox";
type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number];
type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number];
export const DockerReconciliation = () => {
export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOrigin: string }) => {
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();
const health = clientApi.docker.getServiceHealth.useQuery(undefined, { enabled: isOpen });
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
async onSuccess() {
await Promise.all([
@@ -61,6 +87,14 @@ export const DockerReconciliation = () => {
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 (
@@ -68,7 +102,7 @@ export const DockerReconciliation = () => {
<Stack gap="sm">
<Text size="sm">{t("loadError.message")}</Text>
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void reconciliation.refetch()}>
{t("action.retry")}
{tGlobal("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
@@ -79,231 +113,274 @@ export const DockerReconciliation = () => {
const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable");
const candidates = filterDockerReconciliationInbox(reconciliation.data.candidates, filter, dismissedCandidateKeys);
const attentionCount = health.data
? health.data.services.filter(
({ key, status }) =>
["newRecognized", "newApp", "moved"].includes(status) && !dismissedCandidateKeys.includes(key),
).length
: 0;
const attentionCount = reconciliation.data.candidates.filter(
({ candidateKey, state }) =>
["newRecognized", "newApp", "moved"].includes(state) && !dismissedCandidateKeys.includes(candidateKey),
).length;
const isRefreshing = refreshInventory.isPending || reconciliation.isFetching || health.isFetching;
const toggleLabel = isOpen ? tGlobal("common.action.hide") : t("action.review");
return (
<Card withBorder>
<Stack>
<Paper withBorder p="sm">
<Stack gap={isOpen ? "sm" : 0}>
<Group justify="space-between" align="start">
<div>
<Group gap="xs">
<IconPlugConnected size={20} />
<Text fw={600}>{t("title")}</Text>
</Group>
<Text c="dimmed" size="sm">
{t("description")}
</Text>
</div>
{health.data && <Badge variant="light">{t("attention", { count: String(attentionCount) })}</Badge>}
</Group>
<Group justify="space-between">
<SegmentedControl
value={filter}
onChange={(value) => setFilter(value as DockerReconciliationInboxFilter)}
data={[
{ value: "attention", label: t("filter.attention") },
{ value: "represented", label: t("filter.represented") },
{ value: "all", label: t("filter.all") },
]}
/>
<Group gap="xs">
{dismissedCandidateKeys.length > 0 && (
<Button variant="subtle" size="xs" onClick={() => setDismissedCandidateKeys([])}>
{t("action.restoreDismissed", { count: String(dismissedCandidateKeys.length) })}
</Button>
)}
<Button
variant="light"
size="xs"
loading={isRefreshing}
leftSection={<IconRefresh size={14} />}
onClick={() => refreshInventory.mutate()}
>
{t("action.refresh")}
</Button>
<Group 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>
</Group>
{unavailableEndpoints.length > 0 && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("endpointUnavailable", { names: unavailableEndpoints.map(({ name }) => name).join(", ") })}
</Alert>
)}
<Collapse id={panelId} expanded={isOpen}>
<Divider mb="sm" />
<Stack gap="sm">
<ServiceUrlTemplate
serverOrigin={serverOrigin}
onServerOriginChange={setServerOrigin}
mode={urlMode}
onModeChange={setUrlMode}
/>
{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 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") },
]}
/>
))}
</SimpleGrid>
)}
<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>
</Stack>
</Card>
</Paper>
);
};
const DockerReconciliationCandidate = ({
candidate,
health,
serverOrigin,
urlMode,
onDismiss,
}: {
candidate: ReconciliationCandidate;
health: ServiceHealth | undefined;
serverOrigin: string;
urlMode: UrlTemplateMode;
onDismiss: () => void;
}) => {
const t = useScopedI18n("docker.reconciliation");
const { openModal } = useModalAction(AddDockerAppToHomarr);
const initialUrlCandidate =
candidate.urlCandidates.find(
({ url, scopes }) => url.length > 0 && (candidate.match ? true : scopes.includes("browser")),
) ?? candidate.urlCandidates.find(({ source }) => source === "manual");
const [selectedCandidateId, setSelectedCandidateId] = useState(initialUrlCandidate?.id ?? null);
const [url, setUrl] = useState(initialUrlCandidate?.url ?? "");
const selectedCandidate = candidate.urlCandidates.find(({ id }) => id === selectedCandidateId);
const validatedUrl = getValidDockerServiceUrl(url);
const isInvalidUrl = url.length > 0 && validatedUrl === null;
const target = getCandidateTarget(candidate, validatedUrl ?? "");
const 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 actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration";
const isActionDisabled = actionNeedsUrl && validatedUrl === null;
const visibleHealthLayers =
health?.layers.filter(({ status }) => status !== "notApplicable" && status !== "notObserved") ?? [];
useEffect(() => {
setSelectedCandidateId(initialUrlCandidate?.id ?? null);
setUrl(initialUrlCandidate?.url ?? "");
}, [initialUrlCandidate?.id, initialUrlCandidate?.url]);
if (isUrlEdited.current) return;
setUrl(initialUrl);
}, [initialUrl]);
return (
<Card withBorder padding="md">
<Stack gap="sm">
<Group justify="space-between" align="start" wrap="nowrap">
<Group wrap="nowrap">
<Avatar src={candidate.container.iconUrl} radius="sm">
<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">
{candidate.container.name.at(0)?.toUpperCase()}
</Avatar>
<div>
<Text fw={600} lineClamp={1}>
}
>
<div style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text fw={600} size="sm" truncate>
{candidate.container.name}
</Text>
<Text c="dimmed" size="xs">
{candidate.endpointName}
</Text>
</div>
</Group>
<Badge color={stateColor(candidate.state)} variant="light">
{t(`state.${candidate.state}`)}
</Badge>
</Group>
{candidate.match && (
<Group gap="xs">
<Badge variant="outline">{candidate.match.kind}</Badge>
<Text c="dimmed" size="xs">
{t("match", { confidence: t(`confidence.${candidate.match.confidence}`) })}
</Text>
</Group>
)}
{candidate.representation.signals.ambiguous && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("ambiguous")}
</Alert>
)}
{health && (
<div>
<Text c="dimmed" size="xs" mb={4}>
{t("health.title")}
</Text>
<Group gap={4}>
{health.layers.map((layer) => (
<Badge key={layer.layer} color={healthStatusColor(layer.status)} variant="dot" size="sm">
{t(`health.layer.${layer.layer}`)}: {t(`health.status.${layer.status}`)}
</Badge>
))}
<Badge color={stateColor(candidate.state)} variant="light" size="xs">
{t(`state.${candidate.state}`)}
</Badge>
</Group>
<Text c="dimmed" size="xs">
{candidate.endpointName}
</Text>
</div>
)}
<Select
label={t("url.label")}
description={selectedCandidate ? t(`reason.${selectedCandidate.reason}`) : t("reason.manualHostRequired")}
value={selectedCandidateId}
data={candidate.urlCandidates.map((item) => ({
value: item.id,
label: item.url || t("url.manual"),
}))}
onChange={(value) => {
setSelectedCandidateId(value);
setUrl(candidate.urlCandidates.find(({ id }) => id === value)?.url ?? "");
}}
/>
<TextInput
label={t("url.inputLabel")}
aria-label={t("url.inputLabel")}
value={url}
placeholder="https://service.example.com"
error={isInvalidUrl ? t("url.invalid") : undefined}
onChange={(event) => setUrl(event.currentTarget.value)}
/>
<Group justify="space-between">
<Button variant="subtle" color="gray" leftSection={<IconEyeOff size={16} />} onClick={onDismiss}>
{t("action.dismiss")}
</Button>
{target.kind === "createApp" ? (
</Accordion.Control>
<Group gap={4} wrap="nowrap">
{target.kind === "createApp" && (
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
disabled={isActionDisabled}
size="compact-xs"
leftSection={<IconApps size={14} />}
onClick={() =>
openModal({
openAppModal({
selectedContainers: [candidate.container],
initialUrls: validatedUrl ? [validatedUrl] : undefined,
initialUrls: [(normalizeServiceUrl(url) ?? url) || null],
})
}
>
{t("action.createApp")}
{tGlobal("integration.field.createApp.label")}
</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"
rightSection={<IconArrowRight size={16} />}
disabled={isActionDisabled}
onClick={(event) => {
if (isActionDisabled) event.preventDefault();
}}
size="compact-xs"
leftSection={target.kind === "reviewIntegration" ? <IconSettings size={14} /> : <IconEye size={14} />}
>
{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>
</Stack>
</Card>
</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>
);
};
const getCandidateTarget = (candidate: ReconciliationCandidate, url: string) => {
const getCandidateTarget = (candidate: ReconciliationCandidate) => {
if (candidate.representation.signals.ambiguous) {
if (candidate.nextAction === "reviewIntegration" && candidate.match) {
const params = new URLSearchParams({ search: getIntegrationName(candidate.match.kind) });
@@ -312,9 +389,7 @@ const getCandidateTarget = (candidate: ReconciliationCandidate, url: string) =>
return { kind: "viewRepresentation" as const, href: "/manage/apps" };
}
if (candidate.state === "newRecognized" && candidate.match) {
const params = new URLSearchParams({ kind: candidate.match.kind, name: candidate.container.name });
if (url) params.set("url", url);
return { kind: "setupIntegration" as const, href: `/manage/integrations/new?${params.toString()}` };
return { kind: "setupIntegration" as const, integrationKind: candidate.match.kind };
}
if (candidate.state === "newApp") return { kind: "createApp" as const };
@@ -337,8 +412,21 @@ const stateColor = (state: ReconciliationCandidate["state"]) => {
return "blue";
};
const healthStatusColor = (status: ServiceHealth["layers"][number]["status"]) => {
if (["available", "configured", "linked"].includes(status)) return "green";
if (["missing", "changed", "unused"].includes(status)) return "yellow";
return "gray";
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>
);
};
@@ -1,8 +1,10 @@
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";
@@ -16,7 +18,10 @@ export default async function DockerPage() {
notFound();
}
const { containers, endpoints, timestamp } = await api.docker.getContainers();
const [{ containers, endpoints, timestamp }, requestHeaders] = await Promise.all([
api.docker.getContainers(),
headers(),
]);
const tDocker = await getScopedI18n("docker");
return (
@@ -24,7 +29,7 @@ export default async function DockerPage() {
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tDocker("title")}</Title>
<DockerReconciliation />
<DockerReconciliation defaultServerOrigin={extractBaseUrlFromHeaders(requestHeaders)} />
<DockerTable initialData={{ containers, endpoints, timestamp }} />
</Stack>
</>
@@ -2,7 +2,11 @@
import dynamic from "next/dynamic";
export const ClientSideTerminalComponent = dynamic(
interface ClientSideTerminalComponentProps {
focusTimestamp?: number;
}
export const ClientSideTerminalComponent = dynamic<ClientSideTerminalComponentProps>(
() => import("./terminal").then(({ TerminalComponent }) => TerminalComponent),
{
ssr: false,
@@ -29,12 +29,25 @@ export async function generateMetadata() {
};
}
export default async function LogsManagementPage() {
interface LogsManagementPageProps {
searchParams: Promise<{ focus?: string | string[] }>;
}
export default async function LogsManagementPage({ searchParams }: LogsManagementPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("other-view-logs")) {
notFound();
}
const focus = (await searchParams).focus;
let focusTimestamp: number | undefined;
if (typeof focus === "string") {
const parsedFocusTimestamp = Number(focus);
if (Number.isSafeInteger(parsedFocusTimestamp) && parsedFocusTimestamp > 0) {
focusTimestamp = parsedFocusTimestamp;
}
}
return (
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
<Group justify="space-between" align="center" wrap="nowrap">
@@ -45,7 +58,7 @@ export default async function LogsManagementPage() {
</Group>
</Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideTerminalComponent />
<ClientSideTerminalComponent focusTimestamp={focusTimestamp} />
</Box>
</LogContextProvider>
);
@@ -7,25 +7,163 @@ import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { clientApi } from "@homarr/api/client";
import type { LoggerMessage, LogLevel } from "@homarr/core/infrastructure/logs/constants";
import { LOG_HISTORY_MAX_ENTRIES, logLevels } from "@homarr/core/infrastructure/logs/constants";
import { useI18n } from "@homarr/translation/client";
import { useLogContext } from "./log-context";
import classes from "./terminal.module.css";
export const TerminalComponent = () => {
const ALL_LOG_LEVELS = [...logLevels];
const TERMINAL_SCROLLBACK_LINES = LOG_HISTORY_MAX_ENTRIES * 20;
const getFocusMarker = (
messages: LoggerMessage[],
visibleMessages: LoggerMessage[],
focusTimestamp: number | undefined,
markerText: string,
expiredMarkerText: string,
) => {
if (focusTimestamp === undefined) return null;
const oldestTimestamp = messages[0]?.timestamp.getTime();
if (oldestTimestamp === undefined || focusTimestamp < oldestTimestamp) {
return { index: 0, text: expiredMarkerText };
}
return {
index: visibleMessages.findLastIndex((message) => message.timestamp.getTime() <= focusTimestamp) + 1,
text: markerText,
};
};
const findMarkerLine = (terminal: Terminal, linesFromEnd: number) => {
const buffer = terminal.buffer.active;
const logicalLineStarts: number[] = [];
const cursorLine = buffer.baseY + buffer.cursorY;
for (let index = 0; index <= cursorLine; index++) {
const line = buffer.getLine(index);
if (!line) continue;
if (!line.isWrapped) {
logicalLineStarts.push(index);
}
}
return logicalLineStarts.at(-linesFromEnd) ?? null;
};
const renderMessages = ({
terminal,
messages,
activeLevels,
focusTimestamp,
markerText,
expiredMarkerText,
}: {
terminal: Terminal;
messages: LoggerMessage[];
activeLevels: LogLevel[];
focusTimestamp: number | undefined;
markerText: string;
expiredMarkerText: string;
}) => {
const activeLevelSet = new Set(activeLevels);
const visibleMessages = messages.filter((message) => activeLevelSet.has(message.level));
const focusMarker = getFocusMarker(messages, visibleMessages, focusTimestamp, markerText, expiredMarkerText);
const output: string[] = [];
let markerOutputIndex: number | null = null;
visibleMessages.forEach((message, index) => {
if (focusMarker?.index === index) {
markerOutputIndex = output.length;
output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`);
}
output.push(message.message);
});
if (focusMarker?.index === visibleMessages.length) {
markerOutputIndex = output.length;
output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`);
}
let markerLinesFromEnd: number | null = null;
if (markerOutputIndex !== null) {
markerLinesFromEnd =
1 + output.slice(markerOutputIndex).reduce((lineCount, line) => lineCount + line.split(/\r?\n/).length, 0);
}
terminal.write(`\x1bc${output.join("\r\n")}${output.length > 0 ? "\r\n" : ""}`, () => {
if (markerLinesFromEnd === null) {
terminal.scrollToBottom();
return;
}
const markerLine = findMarkerLine(terminal, markerLinesFromEnd);
if (markerLine !== null) {
terminal.scrollToLine(markerLine);
}
});
};
interface TerminalComponentProps {
focusTimestamp?: number;
}
export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) => {
const ref = useRef<HTMLDivElement>(null);
const { activeLevels, fontSize } = useLogContext();
const t = useI18n();
const terminalRef = useRef<Terminal>(null);
const fitAddonRef = useRef<FitAddon>(null);
const messagesRef = useRef<LoggerMessage[]>([]);
const initialFontSizeRef = useRef(fontSize);
const activeLevelsRef = useRef(activeLevels);
const markerTextRef = useRef("");
const expiredMarkerTextRef = useRef("");
const focusTimestampRef = useRef(focusTimestamp);
activeLevelsRef.current = activeLevels;
markerTextRef.current = t("log.context.widgetError");
expiredMarkerTextRef.current = t("log.context.expiredWidgetError");
focusTimestampRef.current = focusTimestamp;
const redrawTerminal = () => {
const terminal = terminalRef.current;
if (!terminal) return;
renderMessages({
terminal,
messages: messagesRef.current,
activeLevels: activeLevelsRef.current,
focusTimestamp: focusTimestampRef.current,
markerText: markerTextRef.current,
expiredMarkerText: expiredMarkerTextRef.current,
});
};
clientApi.log.subscribe.useSubscription(
{
levels: activeLevels,
levels: ALL_LOG_LEVELS,
},
{
onData(data) {
terminalRef.current?.writeln(data.message);
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
onData(event) {
if (event.type === "history") {
messagesRef.current = event.messages.slice(-LOG_HISTORY_MAX_ENTRIES);
redrawTerminal();
return;
}
messagesRef.current.push(event.message);
if (messagesRef.current.length > LOG_HISTORY_MAX_ENTRIES) {
messagesRef.current.shift();
}
if (activeLevelsRef.current.includes(event.message.level)) {
terminalRef.current?.writeln(event.message.message);
}
},
onError(err) {
alert(err);
@@ -44,22 +182,26 @@ export const TerminalComponent = () => {
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize,
fontSize: initialFontSizeRef.current,
scrollback: TERMINAL_SCROLLBACK_LINES,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
setTimeout(() => {
const fitTimeout = window.setTimeout(() => {
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
redrawTerminal();
});
return () => {
window.clearTimeout(fitTimeout);
canvasAddon.dispose();
terminalRef.current?.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
};
}, []);
@@ -69,5 +211,9 @@ export const TerminalComponent = () => {
fitAddonRef.current?.fit();
}, [fontSize]);
useEffect(() => {
redrawTerminal();
}, [activeLevels, focusTimestamp]);
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
};
@@ -104,6 +104,46 @@ describe("assistant automatic approval tracker", () => {
await act(async () => root.unmount());
});
test("does not approve a ready action while switching conversations", async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
let confirmationCount = 0;
const renderConversation = async (conversationId: string, ready: boolean) => {
await act(async () => {
root.render(
createElement(
AssistantAutoApprovalProvider,
{ conversationId },
createElement(
Fragment,
null,
createElement(AutoApprovalProbe),
createElement(AutomaticActionProbe, {
ready,
completed: false,
confirm: () => {
confirmationCount += 1;
},
}),
),
),
);
});
};
await renderConversation("local-thread-1", false);
act(() => currentAutoApproval?.setEnabled(true));
await renderConversation("local-thread-2", true);
expect(confirmationCount).toBe(0);
expect(currentAutoApproval?.enabled).toBe(false);
expect(automaticActionInProgress).toBe(false);
await act(async () => root.unmount());
});
test("clears claimed calls when automatic approvals are turned off", async () => {
const container = document.createElement("div");
containers.push(container);
@@ -52,6 +52,8 @@ export const AssistantAutoApprovalProvider = ({
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
const retryTimersRef = useRef(new Map<string, number>());
const previousConversationIdRef = useRef(conversationId);
const conversationMatches = previousConversationIdRef.current === conversationId;
const effectiveEnabled = conversationMatches && enabled;
const clearRetryTimer = useCallback((toolCallId: string) => {
const timer = retryTimersRef.current.get(toolCallId);
@@ -86,7 +88,7 @@ export const AssistantAutoApprovalProvider = ({
const requestAction = useCallback(
(toolCallId: string, confirm: () => void) => {
if (!enabled || !trackerRef.current.claim(toolCallId)) return false;
if (!effectiveEnabled || !trackerRef.current.claim(toolCallId)) return false;
try {
confirm();
@@ -105,7 +107,7 @@ export const AssistantAutoApprovalProvider = ({
return false;
}
},
[clearRetryTimer, enabled],
[clearRetryTimer, effectiveEnabled],
);
const completeAction = useCallback(
@@ -116,10 +118,15 @@ export const AssistantAutoApprovalProvider = ({
[clearRetryTimer],
);
const value = useMemo(
() => ({ enabled, setEnabled, requestAction, completeAction, retryRevision }),
[completeAction, enabled, requestAction, retryRevision, setEnabled],
);
const value = useMemo(() => {
return {
enabled: effectiveEnabled,
setEnabled,
requestAction,
completeAction,
retryRevision,
};
}, [completeAction, effectiveEnabled, requestAction, retryRevision, setEnabled]);
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
};
@@ -20,11 +20,15 @@ export interface AssistantContextValue {
isRunning: boolean;
isRefreshing: boolean;
unreadCount: number;
hasVisibleWidget: boolean;
activeWidgetId: string | null;
open: () => void;
close: () => void;
toggle: () => void;
sendPrompt: (prompt: string) => boolean;
refreshCurrentView: () => Promise<void>;
setWidgetVisible: (widgetId: string, visible: boolean) => void;
activateWidget: (widgetId: string) => void;
}
export const AssistantContext = createContext<AssistantContextValue | null>(null);
@@ -34,11 +34,15 @@ const DisabledAssistant = ({ children, description }: PropsWithChildren<{ descri
isRunning: false,
isRefreshing: false,
unreadCount: 0,
hasVisibleWidget: false,
activeWidgetId: null,
open: () => undefined,
close: () => undefined,
toggle: () => undefined,
sendPrompt: () => false,
refreshCurrentView: () => Promise.resolve(),
setWidgetVisible: () => undefined,
activateWidget: () => undefined,
}),
[description],
);
@@ -29,7 +29,7 @@ import { AppForm } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
import { getAssistantAffirmativeResult, getAssistantAskUserOptionKind } from "./assistant-ask-user";
import { getAssistantAskUserOptionKind } from "./assistant-ask-user";
import { useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
import classes from "./assistant-panel.module.css";
@@ -67,7 +67,6 @@ export const AssistantAskUserTool = ({
result,
addResult,
status,
toolCallId,
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
const t = useScopedI18n("common.assistant.askUser");
const [showOther, setShowOther] = useState(false);
@@ -75,17 +74,6 @@ export const AssistantAskUserTool = ({
const [submitting, setSubmitting] = useState(false);
const otherInputRef = useRef<HTMLInputElement>(null);
const options = Array.isArray(args?.options) ? args.options : [];
const affirmativeResult = getAssistantAffirmativeResult(options);
const hasValidQuestion = hasCompleteAssistantToolArguments(status) && Boolean(args?.question) && options.length >= 2;
const autoConfirming = useAssistantAutomaticAction({
toolCallId,
ready: result === undefined && hasValidQuestion && affirmativeResult !== undefined,
completed: result !== undefined,
confirm: () => {
if (!affirmativeResult) return;
addResult(affirmativeResult);
},
});
useEffect(() => {
if (showOther) otherInputRef.current?.focus();
@@ -165,67 +153,62 @@ export const AssistantAskUserTool = ({
</Group>
<Stack gap="xs" mt="md">
{autoConfirming ? (
<AssistantAutomaticActionProgress label={t("automaticAffirmative")} />
) : (
options.map((option, index) => {
const kind = getAssistantAskUserOptionKind(option);
const icon =
kind === "affirmative" ? (
<IconCheck size={17} />
) : kind === "negative" ? (
<IconX size={17} />
) : (
<IconArrowRight size={17} />
);
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
return (
<UnstyledButton
key={`${option.id}:${index}`}
className={classes.humanToolOption}
data-kind={kind}
disabled={submitting}
onClick={() =>
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
}
>
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
{icon}
</ThemeIcon>
<Box className={classes.humanToolOptionText} ta="start">
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
{option.label}
</Text>
{option.description && (
<Text
component="span"
display="block"
size="xs"
c="dimmed"
fw={400}
className={classes.humanToolOptionDescription}
>
{option.description}
</Text>
)}
</Box>
<Badge
className={classes.humanToolOptionBadge}
classNames={{ label: classes.humanToolOptionBadgeLabel }}
variant="light"
color={color}
size="xs"
>
{t(`optionKind.${kind}`)}
</Badge>
</UnstyledButton>
{options.map((option, index) => {
const kind = getAssistantAskUserOptionKind(option);
const icon =
kind === "affirmative" ? (
<IconCheck size={17} />
) : kind === "negative" ? (
<IconX size={17} />
) : (
<IconArrowRight size={17} />
);
})
)}
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
{!autoConfirming &&
args.allowOther !== false &&
return (
<UnstyledButton
key={`${option.id}:${index}`}
className={classes.humanToolOption}
data-kind={kind}
disabled={submitting}
onClick={() =>
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
}
>
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
{icon}
</ThemeIcon>
<Box className={classes.humanToolOptionText} ta="start">
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
{option.label}
</Text>
{option.description && (
<Text
component="span"
display="block"
size="xs"
c="dimmed"
fw={400}
className={classes.humanToolOptionDescription}
>
{option.description}
</Text>
)}
</Box>
<Badge
className={classes.humanToolOptionBadge}
classNames={{ label: classes.humanToolOptionBadgeLabel }}
variant="light"
color={color}
size="xs"
>
{t(`optionKind.${kind}`)}
</Badge>
</UnstyledButton>
);
})}
{args.allowOther !== false &&
(showOther ? (
<Group align="flex-end" gap="xs" wrap="nowrap">
<TextInput
@@ -98,18 +98,18 @@
container-type: size;
position: fixed;
z-index: 290;
inset-inline-start: 50%;
bottom: 1rem;
inset-block: 1rem;
inset-inline-end: 1rem;
inset-inline-start: auto;
display: flex;
width: min(46rem, calc(100vw - 2rem));
height: min(36rem, calc(100dvh - 5rem));
min-height: min(22rem, calc(100dvh - 5rem));
width: min(34rem, calc(100vw - 2rem));
height: auto;
min-height: min(30rem, calc(100dvh - 2rem));
margin: 0;
transform: translateX(-50%);
flex-direction: column;
overflow: hidden;
padding: 0;
border: 0;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-lg);
color: var(--mantine-color-text);
background: var(--mantine-color-body);
@@ -157,27 +157,28 @@
border-bottom: 1px solid var(--mantine-color-default-border);
}
.panelIdentity {
min-width: 0;
flex: 1 1 auto;
}
.panelIdentityIcon {
flex: 0 0 auto;
}
.panelIdentityTitle,
.panelThreadTitle {
max-width: 14rem;
}
.panelActions {
min-width: 0;
flex: 0 0 auto;
margin-inline-start: auto;
}
.windowControls {
.newConversationButton {
flex: 0 0 auto;
margin: 0;
padding: 0;
border: 0;
}
.windowControl {
width: 2.25rem;
height: 2.25rem;
}
.windowControl:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.panelAction {
@@ -191,10 +192,6 @@
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
}
.composerHints {
display: none;
}
.historyButton {
width: 2.625rem;
max-width: 2.625rem;
@@ -210,6 +207,21 @@
display: none;
}
.newConversationButton {
width: 2.625rem;
max-width: 2.625rem;
height: 2.625rem;
padding-inline: 0.75rem;
}
.newConversationButton :global(.mantine-Button-section) {
margin-inline: 0;
}
.newConversationButton :global(.mantine-Button-label) {
display: none;
}
.messages {
padding: 0.5rem 0.65rem 1rem;
}
@@ -246,9 +258,27 @@
}
}
@container assistant-widget (max-width: 36rem) {
.historyButton {
width: 2.625rem;
max-width: 2.625rem;
height: 2.625rem;
padding-inline: 0.75rem;
}
.historyButtonSection {
margin-inline-end: 0;
}
.historyButtonLabel {
display: none;
}
}
@container assistant-widget (max-width: 22rem) {
.panelAction,
.historyButton {
.historyButton,
.newConversationButton {
width: 2.25rem;
max-width: 2.25rem;
height: 2.25rem;
@@ -268,7 +298,11 @@
padding-inline: 0.15rem;
}
.composerContext > :last-child {
.composerContextLabel {
display: none;
}
.panelThreadTitle {
display: none;
}
}
@@ -298,17 +332,70 @@
}
.historyMenu {
min-width: 0;
overflow: hidden;
background: var(--mantine-color-body);
}
.historyMenu :global(.mantine-ScrollArea-content) {
display: block;
width: 100%;
min-width: 0;
}
.historyItem {
display: flex;
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
align-items: center;
gap: 0.2rem;
overflow: hidden;
padding: 0.15rem 0.2rem 0.15rem 0.35rem;
border-radius: var(--mantine-radius-md);
}
.historyItem:hover {
.historyItem:hover,
.historyItem[data-active] {
background: var(--mantine-color-default-hover);
}
.historyItemTrigger {
min-width: 0;
flex: 1;
overflow: hidden;
padding: 0.35rem 0.25rem;
text-align: start;
}
.historyItemTrigger > span {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.historyRenameInput {
min-width: 0;
flex: 1;
}
.historyItemTrigger:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -1px;
}
.historyItemMenu {
flex: 0 0 auto;
opacity: 1;
}
.historyItemActions {
flex: 0 0 auto;
}
.thread {
position: relative;
display: flex;
@@ -341,6 +428,11 @@
margin-block: 0.9rem;
}
.message[data-static] {
contain-intrinsic-size: auto 12rem;
content-visibility: auto;
}
.userMessageWrap {
width: fit-content;
max-width: 86%;
@@ -706,24 +798,21 @@
.pendingQuestionDock {
min-height: 0;
flex: 1 1 auto;
max-height: min(22rem, 48dvh);
flex: 0 1 auto;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.75rem;
border-top: 1px solid var(--mantine-color-default-border);
background: var(--mantine-color-body);
}
.thread[data-pending-question] .viewport {
display: none;
}
.pendingQuestionTarget {
display: flex;
width: 100%;
min-height: 100%;
max-width: 44rem;
margin-inline: auto;
align-items: safe center;
align-items: flex-start;
}
.pendingQuestionTarget .humanTool {
@@ -1091,6 +1180,43 @@
flex: 0 1 auto;
}
.messageMoreMenu {
z-index: 410;
min-width: 11rem;
padding: 0.25rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
color: var(--mantine-color-text);
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-md);
}
.messageMoreItem {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.55rem;
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-sm);
cursor: pointer;
outline: 0;
}
.messageMoreItem[data-highlighted] {
background: var(--mantine-color-default-hover);
}
.messageMoreItem:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
.messageMoreSeparator {
height: 1px;
margin-block: 0.2rem;
background: var(--mantine-color-default-border);
}
.userActions {
min-height: 1.75rem;
}
@@ -1173,13 +1299,13 @@
.composerContext {
display: flex;
width: 1.75rem;
width: auto;
height: 1.75rem;
align-items: center;
justify-content: center;
padding: 0;
padding-inline: 0.3rem 0.42rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: 50%;
border-radius: var(--mantine-radius-sm);
color: var(--mantine-color-text);
background: var(--mantine-color-body);
}
@@ -1415,10 +1541,6 @@
pointer-events: none;
}
.composerHints {
padding-inline: 0.45rem;
}
.composerFooter {
min-width: 0;
max-width: 100%;
@@ -1641,12 +1763,12 @@
@keyframes assistant-panel-in {
from {
opacity: 0;
transform: translate(-50%, 0.75rem) scale(0.985);
transform: translateY(0.75rem) scale(0.985);
}
to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
transform: translateY(0) scale(1);
}
}
@@ -1656,10 +1778,11 @@
}
.floatingPanel {
bottom: max(0.5rem, env(safe-area-inset-bottom, 0px));
width: calc(100vw - 1rem);
height: min(44rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
min-height: min(20rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
inset: 0;
width: 100vw;
height: 100dvh;
min-height: 100dvh;
border-radius: 0;
}
.panelHeader {
@@ -1686,10 +1809,6 @@
padding-inline: 0.1rem;
}
.composerHints {
display: none;
}
.runtimeSelectorTrigger {
max-width: 100%;
}
@@ -1705,6 +1824,10 @@
}
@media (max-width: 30em) {
.historyItemMenu {
opacity: 1;
}
.historyButton {
width: 2.625rem;
max-width: 2.625rem;
@@ -25,6 +25,8 @@ import type {
import type { MessageStatus } from "@assistant-ui/react";
import {
ActionBarPrimitive,
ActionBarMorePrimitive,
AuiIf,
AttachmentPrimitive,
BranchPickerPrimitive,
ChainOfThoughtByIndicesProvider,
@@ -66,6 +68,7 @@ import {
SegmentedControl,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
UnstyledButton,
@@ -91,6 +94,7 @@ import {
IconChevronUp,
IconCopy,
IconDownload,
IconDotsVertical,
IconFile,
IconFileExport,
IconLink,
@@ -112,6 +116,8 @@ import {
IconThumbUp,
IconTool,
IconTrash,
IconVolume,
IconVolumeOff,
IconX,
} from "@tabler/icons-react";
import remarkBreaks from "remark-breaks";
@@ -124,6 +130,7 @@ import { useTimeAgo } from "@homarr/common";
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
import type { AssistantProvider } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useConfirmModal } from "@homarr/modals";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
@@ -184,6 +191,7 @@ interface AssistantPanelProps extends AssistantConversationControls {
onClose: () => void;
onDismissActivity: () => void;
activityDismissed: boolean;
hasVisibleWidget: boolean;
isRunning: boolean;
unreadCount: number;
latestAssistantText: string;
@@ -1107,63 +1115,43 @@ const downloadAssistantMarkdown = (markdown: string, filename: string) => {
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
};
const AssistantMessageExportAction = () => {
const t = useScopedI18n("common.assistant");
const message = useAuiState((state) => state.message);
return (
<Tooltip label={t("exportMarkdown")}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("exportMarkdown")}
onClick={() => {
const markdown = buildAssistantMessageMarkdown({
id: message.id,
parentId: message.parentId,
format: "assistant-ui/runtime",
content: message,
createdAt: message.createdAt,
});
downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`);
}}
>
<IconFileExport size={14} />
</ActionIcon>
</Tooltip>
);
};
const AssistantMessageActions = () => {
const t = useScopedI18n("common.assistant");
const aui = useAui();
const exportMessage = () => {
const message = aui.message().getState();
const markdown = buildAssistantMessageMarkdown({
id: message.id,
parentId: message.parentId,
format: "assistant-ui/runtime",
content: message,
createdAt: message.createdAt,
});
downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`);
};
return (
<Box className={classes.messageActions}>
<ActionBarPrimitive.Root hideWhenRunning className={classes.messageActionBar}>
<Group gap={2} wrap="wrap">
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
autohideFloat="single-branch"
className={classes.messageActionBar}
>
<Group gap={2} wrap="nowrap">
<ProviderMessageInfo />
<Tooltip label={t("copy")}>
<ActionBarPrimitive.Copy asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
<IconCopy size={14} />
<AuiIf condition={(state) => !state.message.isCopied}>
<IconCopy size={14} />
</AuiIf>
<AuiIf condition={(state) => state.message.isCopied}>
<IconCheck size={14} />
</AuiIf>
</ActionIcon>
</ActionBarPrimitive.Copy>
</Tooltip>
<Tooltip label={t("helpful")}>
<ActionBarPrimitive.FeedbackPositive asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("helpful")}>
<IconThumbUp size={14} />
</ActionIcon>
</ActionBarPrimitive.FeedbackPositive>
</Tooltip>
<Tooltip label={t("notHelpful")}>
<ActionBarPrimitive.FeedbackNegative asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("notHelpful")}>
<IconThumbDown size={14} />
</ActionIcon>
</ActionBarPrimitive.FeedbackNegative>
</Tooltip>
<AssistantMessageExportAction />
<Tooltip label={t("regenerate")}>
<ActionBarPrimitive.Reload asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
@@ -1171,6 +1159,50 @@ const AssistantMessageActions = () => {
</ActionIcon>
</ActionBarPrimitive.Reload>
</Tooltip>
<ActionBarMorePrimitive.Root>
<ActionBarMorePrimitive.Trigger asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("moreActions")}>
<IconDotsVertical size={14} />
</ActionIcon>
</ActionBarMorePrimitive.Trigger>
<ActionBarMorePrimitive.Content className={classes.messageMoreMenu} sideOffset={4} align="end">
<ActionBarPrimitive.FeedbackPositive asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconThumbUp size={15} />
{t("helpful")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.FeedbackPositive>
<ActionBarPrimitive.FeedbackNegative asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconThumbDown size={15} />
{t("notHelpful")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.FeedbackNegative>
<AuiIf condition={(state) => state.thread.capabilities.speech && state.message.speech === undefined}>
<ActionBarPrimitive.Speak asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconVolume size={15} />
{t("readAloud")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.Speak>
</AuiIf>
<AuiIf condition={(state) => state.message.speech !== undefined}>
<ActionBarPrimitive.StopSpeaking asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconVolumeOff size={15} />
{t("stopReading")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.StopSpeaking>
</AuiIf>
<ActionBarMorePrimitive.Separator className={classes.messageMoreSeparator} />
<ActionBarPrimitive.ExportMarkdown onExport={() => exportMessage()} asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconFileExport size={15} />
{t("exportMarkdown")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.ExportMarkdown>
</ActionBarMorePrimitive.Content>
</ActionBarMorePrimitive.Root>
</Group>
</ActionBarPrimitive.Root>
<BranchPicker />
@@ -1181,7 +1213,7 @@ const AssistantMessageActions = () => {
const UserMessageActions = () => {
const t = useScopedI18n("common.assistant");
return (
<ActionBarPrimitive.Root hideWhenRunning className={classes.userActions}>
<ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className={classes.userActions}>
<Group gap={2}>
<Tooltip label={t("copyMessage")}>
<ActionBarPrimitive.Copy asChild>
@@ -1225,7 +1257,7 @@ const UserMessage = () => {
const isEditing = useAuiState((state) => state.composer.isEditing);
if (isEditing) return <EditComposer />;
return (
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`}>
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`} data-static>
<Box className={classes.userMessage}>
<MessagePrimitive.Quote>
{(quote) => (
@@ -1732,12 +1764,17 @@ const ConversationContext = () => {
aria-haspopup="dialog"
onClick={() => setOpened((value) => !value)}
>
<RingProgress
size={24}
thickness={3}
roundCaps
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
/>
<Group gap={5} wrap="nowrap">
<RingProgress
size={22}
thickness={3}
roundCaps
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
/>
<Text className={classes.composerContextLabel} component="span" size="xs" fw={650}>
{hasContext ? `${contextPercentage}%` : t("mentions.context")}
</Text>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown className={classes.conversationContextPopover}>
@@ -1902,60 +1939,80 @@ const WebSearchActivity = () => {
);
};
const AssistantMessage = () => (
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
{({ part, children }) => {
switch (part.type) {
case "group-agent-trace": {
const startIndex = part.indices[0];
const endIndex = part.indices.at(-1);
if (startIndex === undefined || endIndex === undefined) return null;
return (
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
<AssistantChainOfThought>{children}</AssistantChainOfThought>
</ChainOfThoughtByIndicesProvider>
);
const AssistantMessage = () => {
const isComplete = useAuiState((state) => state.message.status?.type === "complete");
return (
<MessagePrimitive.Root
className={`${classes.message} ${classes.assistantMessage}`}
data-static={isComplete || undefined}
>
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
{({ part, children }) => {
switch (part.type) {
case "group-agent-trace": {
const startIndex = part.indices[0];
const endIndex = part.indices.at(-1);
if (startIndex === undefined || endIndex === undefined) return null;
return (
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
<AssistantChainOfThought>{children}</AssistantChainOfThought>
</ChainOfThoughtByIndicesProvider>
);
}
case "group-reasoning":
return <>{children}</>;
case "group-tool":
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
case "indicator":
return <AssistantMessagePending status={{ type: "running" }} />;
case "text":
return <AssistantTextPart />;
case "reasoning":
return <ReasoningPart {...part} />;
case "tool-call":
return part.toolUI ?? <ToolPart {...part} />;
case "source":
return <SourcePart {...part} />;
case "file":
return <FilePart {...part} />;
case "image":
return <ImagePart {...part} />;
case "data":
return part.dataRendererUI;
default:
return null;
}
case "group-reasoning":
return <>{children}</>;
case "group-tool":
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
case "indicator":
return <AssistantMessagePending status={{ type: "running" }} />;
case "text":
return <AssistantTextPart />;
case "reasoning":
return <ReasoningPart {...part} />;
case "tool-call":
return part.toolUI ?? <ToolPart {...part} />;
case "source":
return <SourcePart {...part} />;
case "file":
return <FilePart {...part} />;
case "image":
return <ImagePart {...part} />;
case "data":
return part.dataRendererUI;
default:
return null;
}
}}
</MessagePrimitive.GroupedParts>
<RuntimeError />
<WebSearchActivity />
<AssistantMessageActions />
</MessagePrimitive.Root>
);
}}
</MessagePrimitive.GroupedParts>
<RuntimeError />
<WebSearchActivity />
<AssistantMessageActions />
</MessagePrimitive.Root>
);
};
const assistantThreadMessageComponents = { UserMessage, AssistantMessage };
const HistorySelectContext = createContext<() => void>(() => undefined);
const ThreadListItem = () => {
const t = useScopedI18n("common.assistant");
const aui = useAui();
const { openConfirmModal } = useConfirmModal();
const onSelect = useContext(HistorySelectContext);
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
const title = useAuiState((state) => state.threadListItem.title);
const [exporting, setExporting] = useState(false);
const [actionsOpened, setActionsOpened] = useState(false);
const [renameOpened, setRenameOpened] = useState(false);
const [renaming, setRenaming] = useState(false);
const [nextTitle, setNextTitle] = useState(title ?? "");
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (renameOpened) renameInputRef.current?.focus();
}, [renameOpened]);
const exportConversation = async () => {
if (!remoteId || exporting) return;
@@ -1977,23 +2034,109 @@ const ThreadListItem = () => {
}
};
const renameConversation = async () => {
const trimmedTitle = nextTitle.trim();
if (!trimmedTitle || renaming) return;
setRenaming(true);
try {
await aui.threadListItem().rename(trimmedTitle);
setRenameOpened(false);
setActionsOpened(false);
} catch {
showErrorNotification({
title: t("renameConversation.failedTitle"),
message: t("renameConversation.failedDescription"),
});
} finally {
setRenaming(false);
}
};
const deleteConversation = () => {
openConfirmModal({
title: t("deleteConversation.title"),
children: t("deleteConversation.description", { title: title ?? t("newConversation") }),
confirmProps: { color: "red" },
labels: { confirm: t("delete"), cancel: t("cancel") },
onConfirm: async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
},
});
};
if (renameOpened) {
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<TextInput
ref={renameInputRef}
className={classes.historyRenameInput}
value={nextTitle}
onChange={(event) => setNextTitle(event.currentTarget.value)}
aria-label={t("renameConversation.label")}
maxLength={72}
size="xs"
onKeyDown={(event) => {
if (event.key === "Enter") void renameConversation();
if (event.key === "Escape" && !renaming) setRenameOpened(false);
}}
/>
<ActionIcon
variant="subtle"
color="green"
size="sm"
aria-label={t("renameConversation.save")}
disabled={!nextTitle.trim()}
loading={renaming}
loaderProps={{ type: "bars" }}
onClick={() => void renameConversation()}
>
<IconCheck size={15} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("cancel")}
disabled={renaming}
onClick={() => setRenameOpened(false)}
>
<IconX size={15} />
</ActionIcon>
</ThreadListItemPrimitive.Root>
);
}
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<Group gap={4} wrap="nowrap">
<ThreadListItemPrimitive.Trigger asChild>
<Button
<ThreadListItemPrimitive.Trigger asChild>
<UnstyledButton className={classes.historyItemTrigger} title={title} onClick={onSelect}>
<Text component="span" size="sm" fw={500} truncate>
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</Text>
</UnstyledButton>
</ThreadListItemPrimitive.Trigger>
{actionsOpened && (
<Group className={classes.historyItemActions} gap={1} wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
size="compact-sm"
justify="flex-start"
flex={1}
style={{ minWidth: 0 }}
onClick={onSelect}
size="sm"
title={t("renameConversation.action")}
aria-label={t("renameConversation.action")}
onClick={() => {
setNextTitle(title ?? "");
setRenameOpened(true);
}}
>
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</Button>
</ThreadListItemPrimitive.Trigger>
<Tooltip label={t("exportConversation.action")}>
<IconPencil size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
@@ -2001,24 +2144,49 @@ const ThreadListItem = () => {
loading={exporting}
loaderProps={{ type: "bars" }}
disabled={!remoteId}
title={t("exportConversation.action")}
aria-label={t("exportConversation.action")}
onClick={() => void exportConversation()}
>
<IconFileExport size={14} />
</ActionIcon>
</Tooltip>
<ThreadListItemPrimitive.Delete asChild>
<ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
<ActionIcon
variant="subtle"
color="red"
size="sm"
title={t("delete")}
aria-label={t("delete")}
onClick={deleteConversation}
>
<IconTrash size={14} />
</ActionIcon>
</ThreadListItemPrimitive.Delete>
</Group>
</Group>
)}
<ActionIcon
className={classes.historyItemMenu}
variant="subtle"
color="gray"
size="sm"
title={t("conversationActions")}
aria-label={t("conversationActions")}
aria-expanded={actionsOpened}
onClick={() => setActionsOpened((current) => !current)}
>
{actionsOpened ? <IconX size={15} /> : <IconDotsVertical size={15} />}
</ActionIcon>
</ThreadListItemPrimitive.Root>
);
};
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
const t = useScopedI18n("common.assistant");
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLocaleLowerCase();
const threadItems = useAuiState((state) => state.threads.threadItems);
const hasMatchingConversation = threadItems.some((item) => {
const itemTitle = item.title ?? t("newConversation");
return itemTitle.toLocaleLowerCase().includes(normalizedQuery);
});
return (
<HistorySelectContext.Provider value={onSelect}>
<Stack className={classes.historyMenu} gap="xs" p="xs">
@@ -2035,9 +2203,28 @@ const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
{t("conversations")}
</Text>
</Group>
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" offsetScrollbars>
<TextInput
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
leftSection={<IconSearch size={15} />}
placeholder={t("searchConversations")}
aria-label={t("searchConversations")}
size="xs"
/>
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" scrollbars="y" offsetScrollbars>
<Stack gap={3}>
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
<ThreadListPrimitive.Items>
{({ threadListItem }) => {
const itemTitle = threadListItem.title ?? t("newConversation");
if (normalizedQuery && !itemTitle.toLocaleLowerCase().includes(normalizedQuery)) return null;
return <ThreadListItem />;
}}
</ThreadListPrimitive.Items>
{normalizedQuery && !hasMatchingConversation && (
<Text size="xs" c="dimmed" ta="center" py="md">
{t("noMatchingConversations")}
</Text>
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
@@ -2052,7 +2239,6 @@ const ConversationHistory = () => {
<Popover
opened={opened}
onChange={setOpened}
onDismiss={() => setOpened(false)}
position="bottom-end"
width="min(22rem, calc(100vw - 1rem))"
shadow="md"
@@ -2191,7 +2377,7 @@ const EmptyThread = () => {
</Text>
</Stack>
<Box className={classes.suggestions}>
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send asChild>
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send={false} clearComposer asChild>
<Button
variant="default"
className={classes.suggestion}
@@ -2200,17 +2386,17 @@ const EmptyThread = () => {
{t("suggestions.health.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send asChild>
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send={false} clearComposer asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
{t("suggestions.explore.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send asChild>
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send={false} clearComposer asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
{t("suggestions.media.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send asChild>
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send={false} clearComposer asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
{t("suggestions.style.label")}
</Button>
@@ -2792,14 +2978,6 @@ const Composer = (props: ComposerProps) => {
<HomarrProviderQuota />
<ConversationContext />
</Group>
<Group className={classes.composerHints} gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{t("mentions.hint")}
</Text>
<Text size="xs" c="dimmed">
{t("commands.hint")}
</Text>
</Group>
</Group>
</ComposerPrimitive.Root>
</ComposerPrimitive.AttachmentDropzone>
@@ -2979,9 +3157,30 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
pendingAction: AssistantPendingAction | undefined;
onExpand?: () => void;
onMinimize?: () => void;
onWindowClose?: () => void;
onDismiss?: () => void;
}
const AssistantPanelIdentity = () => {
const t = useScopedI18n("common.assistant");
const threadTitle = useAuiState((state) => state.threadListItem.title);
return (
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
<ThemeIcon className={classes.panelIdentityIcon} variant="light" color="red" radius="xl" size="md">
<IconRobot size={16} />
</ThemeIcon>
<Box miw={0}>
<Text className={classes.panelIdentityTitle} size="sm" fw={700} truncate>
{t("title")}
</Text>
<Text className={classes.panelThreadTitle} size="xs" c="dimmed" truncate>
{threadTitle ?? t("newConversation")}
</Text>
</Box>
</Group>
);
};
export const AssistantConversationSurface = ({
isRunning,
pendingAction,
@@ -2996,7 +3195,7 @@ export const AssistantConversationSurface = ({
onReasoningChange,
onExpand,
onMinimize,
onWindowClose,
onDismiss,
}: AssistantConversationSurfaceProps) => {
const t = useScopedI18n("common.assistant");
const reducedMotion = useReducedMotion();
@@ -3011,52 +3210,23 @@ export const AssistantConversationSurface = ({
return (
<AssistantDirectiveEntitiesProvider>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
{onWindowClose && onMinimize && (
<Group
component="fieldset"
className={classes.windowControls}
gap={4}
wrap="nowrap"
aria-label={t("windowControls")}
>
<Tooltip label={t("close")}>
<ActionIcon
className={classes.windowControl}
variant="subtle"
color="gray"
onClick={onWindowClose}
aria-label={t("close")}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("minimize")}>
<ActionIcon
className={classes.windowControl}
variant="subtle"
color="gray"
onClick={onMinimize}
aria-label={t("minimize")}
>
<IconMinus size={17} />
</ActionIcon>
</Tooltip>
</Group>
)}
<AssistantPanelIdentity />
<Group className={classes.panelActions} gap={2} wrap="nowrap">
<ConversationHistory />
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
<AutoApprovalControl />
<Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild>
<ActionIcon
className={classes.panelAction}
<Button
className={classes.newConversationButton}
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<IconPlus size={17} />}
aria-label={t("newConversation")}
>
<IconPlus size={17} />
</ActionIcon>
{t("newConversation")}
</Button>
</ThreadListPrimitive.New>
</Tooltip>
{onExpand && (
@@ -3072,6 +3242,32 @@ export const AssistantConversationSurface = ({
</ActionIcon>
</Tooltip>
)}
{onMinimize && (
<Tooltip label={t("minimize")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onMinimize}
aria-label={t("minimize")}
>
<IconMinus size={17} />
</ActionIcon>
</Tooltip>
)}
{onDismiss && (
<Tooltip label={t("close")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onDismiss}
aria-label={t("close")}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
<AssistantQuestionPortalProvider target={questionPortalTarget}>
@@ -3083,7 +3279,7 @@ export const AssistantConversationSurface = ({
<ThreadPrimitive.Viewport className={classes.viewport} autoScroll>
<Box className={classes.messages}>
<EmptyThread />
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
<ThreadPrimitive.Messages components={assistantThreadMessageComponents} />
</Box>
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
<SelectionToolbarPrimitive.Quote asChild>
@@ -3130,6 +3326,7 @@ export const AssistantPanel = ({
onClose,
onDismissActivity,
activityDismissed,
hasVisibleWidget,
isRunning,
unreadCount,
latestAssistantText,
@@ -3147,8 +3344,18 @@ export const AssistantPanel = ({
onReasoningChange,
}: AssistantPanelProps) => {
const t = useScopedI18n("common.assistant");
const aui = useAui();
const previousFocusRef = useRef<HTMLElement | null>(null);
const panelRef = useRef<HTMLDialogElement>(null);
const stopSpeechWithoutSurface = () => {
if (hasVisibleWidget) return;
const thread = aui.thread();
if (thread.getState().speech !== undefined) thread.stopSpeaking();
};
const minimize = () => {
stopSpeechWithoutSurface();
onClose();
};
useWindowEvent("keydown", (event) => {
if (
@@ -3160,7 +3367,7 @@ export const AssistantPanel = ({
)
return;
event.preventDefault();
onClose();
minimize();
});
useEffect(() => {
if (!opened) {
@@ -3182,7 +3389,7 @@ export const AssistantPanel = ({
return (
<>
{!opened && (
{!opened && !hasVisibleWidget && (
<AssistantActivityBar
onOpen={onOpen}
onDismissActivity={onDismissActivity}
@@ -3210,8 +3417,9 @@ export const AssistantPanel = ({
onModelChange={onModelChange}
onReasoningChange={onReasoningChange}
autoFocusComposer
onMinimize={onClose}
onWindowClose={() => {
onMinimize={minimize}
onDismiss={() => {
stopSpeechWithoutSurface();
onDismissActivity();
onClose();
}}
@@ -7,11 +7,19 @@ import type {
AttachmentAdapter,
MessageFormatAdapter,
RemoteThreadListAdapter,
SpeechSynthesisAdapter,
ThreadHistoryAdapter,
ThreadMessage,
Toolkit,
} from "@assistant-ui/react";
import { defineToolkit, useAui, useAuiEvent, useAuiState, useRemoteThreadListRuntime } from "@assistant-ui/react";
import {
defineToolkit,
useAui,
useAuiEvent,
useAuiState,
useRemoteThreadListRuntime,
WebSpeechSynthesisAdapter,
} from "@assistant-ui/react";
import { useChat } from "@ai-sdk/react";
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
import { useHotkeys } from "@mantine/hooks";
@@ -41,7 +49,8 @@ import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assi
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
import {
AssistantComposerSurfaceProvider,
AssistantComposerRuntimeProvider,
AssistantComposerSurfaceBoundary,
AssistantRunFocusPreserver,
AssistantRuntimeProviderWithTools,
} from "./assistant-runtime-provider";
@@ -475,6 +484,12 @@ const AssistantThreadRuntime = () => {
),
[selectedModel],
);
const [speech, setSpeech] = useState<SpeechSynthesisAdapter>();
useEffect(() => {
if ("speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined") {
setSpeech(new WebSpeechSynthesisAdapter());
}
}, []);
const feedback = useMemo(
() => ({
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
@@ -512,6 +527,7 @@ const AssistantThreadRuntime = () => {
history,
attachments,
feedback,
speech,
},
});
transport.setRuntime(runtime);
@@ -667,6 +683,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
const [opened, setOpened] = useState(false);
const [activityDismissed, setActivityDismissed] = useState(true);
const [unreadCount, setUnreadCount] = useState(0);
const [visibleWidgetIds, setVisibleWidgetIds] = useState<Set<string>>(() => new Set());
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null);
const aui = useAui();
const preferences = useAssistantPreferences();
@@ -714,6 +731,21 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
setOpened(true);
}, [markRead]);
const close = useCallback(() => setOpened(false), []);
const setWidgetVisible = useCallback((widgetId: string, visible: boolean) => {
setVisibleWidgetIds((current) => {
if (current.has(widgetId) === visible) return current;
const next = new Set(current);
if (visible) next.add(widgetId);
else next.delete(widgetId);
return next;
});
}, []);
const activateWidget = useCallback((widgetId: string) => {
setVisibleWidgetIds((current) => {
if (!current.has(widgetId) || current.values().next().value === widgetId) return current;
return new Set([widgetId, ...Array.from(current).filter((id) => id !== widgetId)]);
});
}, []);
const toggle = useCallback(() => {
if (opened) close();
else open();
@@ -835,6 +867,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
}, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
const activeWidgetId = visibleWidgetIds.values().next().value ?? null;
const value = useMemo(
() => ({
enabled: true,
@@ -843,46 +876,67 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
isRunning: assistantIsRunning,
isRefreshing,
unreadCount,
hasVisibleWidget: visibleWidgetIds.size > 0,
activeWidgetId,
open,
close,
toggle,
sendPrompt,
refreshCurrentView,
setWidgetVisible,
activateWidget,
}),
[assistantIsRunning, close, isRefreshing, open, opened, refreshCurrentView, sendPrompt, toggle, unreadCount],
[
assistantIsRunning,
activateWidget,
activeWidgetId,
close,
isRefreshing,
open,
opened,
refreshCurrentView,
sendPrompt,
setWidgetVisible,
toggle,
unreadCount,
visibleWidgetIds.size,
],
);
return (
<AssistantAutoApprovalProvider conversationId={conversationId}>
<AssistantContext.Provider value={value}>
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
<AssistantComposerSurfaceProvider surfaceId="floating-panel">
<AssistantPanel
opened={opened}
onOpen={open}
onClose={close}
onDismissActivity={() => {
markRead();
setActivityDismissed(true);
}}
activityDismissed={activityDismissed}
isRunning={assistantIsRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
latestAssistantPartType={latestAssistantPartType}
latestUserText={queuedPrompt ?? latestUserText}
latestStatus={latestStatus}
pendingAction={pendingAction}
modelId={preferences.modelId}
models={preferences.models}
modelOptionsLoading={preferences.isLoading}
reasoning={preferences.reasoning}
isRefreshing={isRefreshing}
onRefresh={refreshCurrentView}
onModelChange={selectModel}
onReasoningChange={preferences.setReasoning}
/>
</AssistantComposerSurfaceProvider>
<AssistantComposerRuntimeProvider>
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
<AssistantComposerSurfaceBoundary surfaceId="assistant-panel">
<AssistantPanel
opened={opened}
onOpen={open}
onClose={close}
onDismissActivity={() => {
markRead();
setActivityDismissed(true);
}}
activityDismissed={activityDismissed}
hasVisibleWidget={visibleWidgetIds.size > 0}
isRunning={assistantIsRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
latestAssistantPartType={latestAssistantPartType}
latestUserText={queuedPrompt ?? latestUserText}
latestStatus={latestStatus}
pendingAction={pendingAction}
modelId={preferences.modelId}
models={preferences.models}
modelOptionsLoading={preferences.isLoading}
reasoning={preferences.reasoning}
isRefreshing={isRefreshing}
onRefresh={refreshCurrentView}
onModelChange={selectModel}
onReasoningChange={preferences.setReasoning}
/>
</AssistantComposerSurfaceBoundary>
</AssistantComposerRuntimeProvider>
</AssistantContext.Provider>
</AssistantAutoApprovalProvider>
);
@@ -26,7 +26,8 @@ import { z } from "zod/v4";
import { showErrorNotification } from "@homarr/notifications";
import {
AssistantComposerSurfaceProvider,
AssistantComposerRuntimeProvider,
AssistantComposerSurfaceBoundary,
AssistantRunFocusPreserver,
AssistantRuntimeProviderWithTools,
assistantSurfaceComposerCacheLimit,
@@ -148,14 +149,22 @@ const SurfaceTestAssistant = ({
{ runtime, toolkit },
createElement(AssistantRunFocusPreserver),
createElement(
AssistantComposerSurfaceProvider,
{ surfaceId: "surface-a" },
createElement(SurfaceProbe, { id: "a", onReady }),
AssistantComposerRuntimeProvider,
null,
createElement(
AssistantComposerSurfaceBoundary,
{ surfaceId: "surface-a" },
createElement(SurfaceProbe, { id: "a", onReady }),
),
),
createElement(
AssistantComposerSurfaceProvider,
{ surfaceId: "surface-b" },
createElement(SurfaceProbe, { id: "b", onReady }),
AssistantComposerRuntimeProvider,
null,
createElement(
AssistantComposerSurfaceBoundary,
{ surfaceId: "surface-b" },
createElement(SurfaceProbe, { id: "b", onReady }),
),
),
simulateRunFocus ? createElement(LexicalRunFocusProbe) : null,
);
@@ -157,9 +157,6 @@ const createSurfaceComposerHost = (
get attachments() {
return getThreadAdapters(threadCore)?.attachments ? attachments : undefined;
},
get dictation() {
return getThreadAdapters(threadCore)?.dictation;
},
};
return new Proxy(threadCore as ThreadCoreWithAdapters, {
@@ -294,18 +291,21 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
subscribe(callback) {
let activeCore = sharedBinding.getState();
let activeConversationId = getConversationId();
let activeComposer = getComposer(activeConversationId, activeCore);
retainConversation(activeConversationId);
let unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
let unsubscribeComposer = activeComposer.subscribe(callback);
const handleSharedUpdate = () => {
const nextCore = sharedBinding.getState();
const nextConversationId = getConversationId();
if (nextCore !== activeCore || nextConversationId !== activeConversationId) {
if (activeCore.speech !== undefined) activeCore.stopSpeaking();
unsubscribeComposer();
releaseConversation(activeConversationId);
activeCore = nextCore;
activeConversationId = nextConversationId;
retainConversation(activeConversationId);
unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
activeComposer = getComposer(activeConversationId, activeCore);
unsubscribeComposer = activeComposer.subscribe(callback);
}
callback();
};
@@ -360,6 +360,13 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
registerModelContextProvider: () => () => {},
dispose() {
activeConversationCounts.clear();
const threadCores = new Set([
...Array.from(surfaceCoreByConversation.values(), (entry) => entry.source),
...Array.from(composerByConversation.values(), (entry) => entry.source),
]);
for (const threadCore of threadCores) {
if (threadCore.speech !== undefined) threadCore.stopSpeaking();
}
for (const [conversationId, entry] of composerByConversation) disposeComposer(conversationId, entry);
},
};
@@ -373,7 +380,7 @@ const AssistantComposerSurfaceContext = createContext<AssistantComposerSurfaceVa
export const useAssistantComposerSurface = () => {
const value = useContext(AssistantComposerSurfaceContext);
if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceProvider");
if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceBoundary");
return value;
};
@@ -437,21 +444,23 @@ const AssistantComposerSurfaceEvents = () => {
return null;
};
const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
export const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
const t = useScopedI18n("common.assistant");
const aui = useAui();
const instanceId = useId();
const resolvedSurfaceId = `${surfaceId}:${instanceId}`;
const elementRef = useRef<HTMLDivElement>(null);
const contextValue = useMemo(() => ({ id: surfaceId }), [surfaceId]);
const contextValue = useMemo(() => ({ id: resolvedSurfaceId }), [resolvedSurfaceId]);
useLayoutEffect(() => {
const element = elementRef.current;
if (!element) return undefined;
surfaceElements.set(surfaceId, element);
surfaceElements.set(resolvedSurfaceId, element);
return () => {
if (surfaceElements.get(surfaceId) === element) surfaceElements.delete(surfaceId);
if (surfaceElements.get(resolvedSurfaceId) === element) surfaceElements.delete(resolvedSurfaceId);
};
}, [surfaceId]);
}, [resolvedSurfaceId]);
const handlePasteCapture = (event: ClipboardEvent<HTMLDivElement>) => {
const target = event.target;
@@ -507,7 +516,6 @@ const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChil
}}
onPasteCapture={handlePasteCapture}
>
<AssistantComposerSurfaceEvents />
{children}
</div>
</AssistantComposerSurfaceContext.Provider>
@@ -566,18 +574,12 @@ export const AssistantRunFocusPreserver = () => {
return null;
};
interface AssistantComposerSurfaceProviderProps extends PropsWithChildren {
surfaceId: string;
}
/**
* Gives one rendered assistant surface a local composer while preserving the shared conversation.
* Every component below this boundary can keep using the normal assistant-ui primitives and hooks.
* Gives every rendered assistant surface one shared local composer while preserving the shared
* conversation. Individual surfaces should add AssistantComposerSurfaceBoundary around their UI.
*/
export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: AssistantComposerSurfaceProviderProps) => {
export const AssistantComposerRuntimeProvider = ({ children }: PropsWithChildren) => {
const parentAui = useAui();
const instanceId = useId();
const resolvedSurfaceId = `${surfaceId}:${instanceId}`;
// oxlint-disable-next-line no-underscore-dangle -- required to derive a scoped runtime without cloning the shared thread.
const sharedRuntime = parentAui.threads().__internal_getAssistantRuntime?.();
const surfaceRuntime = useMemo(() => {
@@ -588,7 +590,8 @@ export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: Assist
return (
<AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}>
<AssistantComposerSurfaceBoundary surfaceId={resolvedSurfaceId}>{children}</AssistantComposerSurfaceBoundary>
<AssistantComposerSurfaceEvents />
{children}
</AssistantRuntimeProvider>
);
};
@@ -1,8 +1,9 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import { useEffect, useId, useRef } from "react";
import { ActionIcon, Box, Center, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { IconArrowsMaximize, IconRobot } from "@tabler/icons-react";
import { IconArrowsMaximize, IconArrowsMinimize, IconRobot } from "@tabler/icons-react";
import { useAui, useAuiState } from "@assistant-ui/react";
import { useI18n } from "@homarr/translation/client";
@@ -13,23 +14,47 @@ import classes from "./assistant-panel.module.css";
import { AssistantConversationSurface } from "./assistant-panel";
import { getPendingAssistantAction } from "./assistant-pending-action";
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context";
import { AssistantComposerSurfaceProvider } from "./assistant-runtime-provider";
import { AssistantComposerSurfaceBoundary } from "./assistant-runtime-provider";
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
const assistant = useHomarrAssistant();
const { enabled, setWidgetVisible } = assistant;
const generatedWidgetId = useId();
const widgetId = props.itemId ?? generatedWidgetId;
const widgetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const widget = widgetRef.current;
if (!enabled || !widget) return;
const observer = new IntersectionObserver(([entry]) => setWidgetVisible(widgetId, entry?.isIntersecting === true), {
threshold: 0.15,
});
observer.observe(widget);
return () => {
observer.disconnect();
setWidgetVisible(widgetId, false);
};
}, [enabled, setWidgetVisible, widgetId]);
if (!assistant.enabled) {
return <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
}
return (
<AssistantComposerSurfaceProvider surfaceId={`board-widget-${props.itemId ?? "preview"}`}>
<EnabledAssistantBoardWidget {...props} />
</AssistantComposerSurfaceProvider>
<AssistantComposerSurfaceBoundary surfaceId={`assistant-widget-${widgetId}`}>
<Box ref={widgetRef} w="100%" h="100%">
<EnabledAssistantBoardWidget {...props} assistantWidgetId={widgetId} />
</Box>
</AssistantComposerSurfaceBoundary>
);
};
const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
const EnabledAssistantBoardWidget = ({
width,
height,
isEditMode,
assistantWidgetId,
}: WidgetComponentProps<"assistant"> & { assistantWidgetId: string }) => {
const board = useRequiredBoard();
const assistant = useHomarrAssistant();
const preferences = useAssistantPreferences();
@@ -45,6 +70,14 @@ const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetCompon
threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId });
};
if (assistant.opened) {
return <CompactAssistantWidget onOpen={assistant.close} location="panel" />;
}
if (assistant.activeWidgetId !== null && assistant.activeWidgetId !== assistantWidgetId) {
return <CompactAssistantWidget onOpen={() => assistant.activateWidget(assistantWidgetId)} location="widget" />;
}
if (width < 300 || height < 280) {
return <CompactAssistantWidget onOpen={assistant.open} />;
}
@@ -75,8 +108,30 @@ const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetCompon
);
};
const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
const CompactAssistantWidget = ({
onOpen,
location = "compact",
}: {
onOpen: () => void;
location?: "compact" | "panel" | "widget";
}) => {
const t = useI18n();
let title = t("widget.assistant.compact.title");
let description = t("widget.assistant.compact.description");
let actionLabel = t("widget.assistant.compact.open");
let actionIcon = <IconArrowsMaximize size={18} />;
if (location === "panel") {
title = t("widget.assistant.compact.openTitle");
description = t("widget.assistant.compact.openDescription");
actionLabel = t("widget.assistant.compact.returnToWidget");
actionIcon = <IconArrowsMinimize size={18} />;
}
if (location === "widget") {
title = t("widget.assistant.compact.otherWidgetTitle");
description = t("widget.assistant.compact.otherWidgetDescription");
actionLabel = t("widget.assistant.compact.useHere");
}
return (
<Box className={classes.widgetPanel}>
@@ -87,23 +142,16 @@ const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
</ThemeIcon>
<Stack className={classes.compactCopy} gap={1}>
<Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}>
{t("widget.assistant.compact.title")}
{title}
</Text>
<Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}>
{t("widget.assistant.compact.description")}
{description}
</Text>
</Stack>
</Group>
<Tooltip label={t("widget.assistant.compact.open")}>
<ActionIcon
variant="light"
color="red"
size={44}
radius="md"
onClick={onOpen}
aria-label={t("widget.assistant.compact.open")}
>
<IconArrowsMaximize size={18} />
<Tooltip label={actionLabel}>
<ActionIcon variant="light" color="red" size={44} radius="md" onClick={onOpen} aria-label={actionLabel}>
{actionIcon}
</ActionIcon>
</Tooltip>
</Group>
@@ -227,7 +227,9 @@ const LoadedBoardItemContent = ({
(host: HTMLDivElement | null) => {
if (!host || !surfacePortalTarget || isAdvanced) return;
cancelPreviewEntrance();
host.append(surfacePortalTarget);
if (host.childNodes.length !== 1 || host.firstChild !== surfacePortalTarget) {
host.replaceChildren(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 IntegrationEditModalModule from "~/components/integration/embedded-integration-edit-modal";
import type { EmbeddedIntegrationEditModalProps } from "~/components/integration/embedded-integration-edit-modal";
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
@@ -29,42 +29,38 @@ export const loadWidgetEditModal = () => {
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
let integrationEditModalPromise: Promise<typeof IntegrationEditModalModule> | undefined;
let integrationEditFormPromise: Promise<typeof IntegrationEditFormModule> | undefined;
const loadIntegrationEditModal = () => {
if (integrationEditModalPromise) return integrationEditModalPromise;
const loadIntegrationEditForm = () => {
if (integrationEditFormPromise) return integrationEditFormPromise;
const currentPromise = import("~/components/integration/embedded-integration-edit-modal");
integrationEditModalPromise = currentPromise;
const currentPromise = import("~/components/integration/embedded-integration-edit-form");
integrationEditFormPromise = currentPromise;
void currentPromise.catch(() => {
if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined;
if (integrationEditFormPromise === currentPromise) integrationEditFormPromise = undefined;
});
return currentPromise;
};
const LazyIntegrationEditModalContent = (props: {
actions: { closeModal: () => void };
innerProps: EmbeddedIntegrationEditModalProps;
}) => {
const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal());
const Component = EmbeddedIntegrationEditModal.component;
return <Component {...props} />;
const LazyIntegrationEditFormContent = (props: EmbeddedIntegrationEditFormProps) => {
const { EmbeddedIntegrationEditForm } = use(loadIntegrationEditForm());
return <EmbeddedIntegrationEditForm {...props} />;
};
const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>((props) => (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<Center py="xl">
<WidgetError
error={error}
resetErrorBoundary={() => {
integrationEditModalPromise = undefined;
resetErrorBoundary();
}}
/>
</Center>
)}
>
const IntegrationEditErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => (
<Center py="xl">
<WidgetError
error={error}
resetErrorBoundary={() => {
integrationEditFormPromise = undefined;
resetErrorBoundary();
}}
/>
</Center>
);
const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => (
<ErrorBoundary FallbackComponent={IntegrationEditErrorFallback}>
<Suspense
fallback={
<Center py="xl">
@@ -72,15 +68,10 @@ const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
</Center>
}
>
<LazyIntegrationEditModalContent {...props} />
<LazyIntegrationEditFormContent {...props} />
</Suspense>
</ErrorBoundary>
)).withOptions({
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: true,
});
);
interface LazyWidgetEditModalContentProps {
actions: { closeModal: () => void };
@@ -90,17 +81,12 @@ 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,
onEditIntegration: (integrationId) =>
openIntegrationEditModal({
integrationId,
onSuccess: props.innerProps.onIntegrationSaved,
}),
integrationEditForm: LazyIntegrationEditForm,
}}
/>
);
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { Group, Stack, Text, TextInput, ThemeIcon, UnstyledButton } from "@mantine/core";
import { Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
import {
IconApps,
IconBox,
@@ -26,7 +26,7 @@ import type {
UniversalCreateActionGroup,
UniversalCreateActionKey,
} from "./universal-create-actions";
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
import { getUniversalCreateActionDefinitions } from "./universal-create-actions";
import type { SetupMetricEntryPoint } from "./setup-analytics";
import { useSetupAnalytics } from "./setup-analytics";
@@ -52,7 +52,6 @@ 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");
@@ -118,58 +117,43 @@ export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ ac
keywords: t(`action.${definition.key}.keywords`).split(" "),
}) satisfies RankedUniversalCreateAction,
);
const visibleActions = filterAndRankUniversalCreateActions(resolvedActions, query);
const orderedActions = resolvedActions.toSorted((left, right) => right.priority - left.priority);
const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
return (
<Stack gap="md">
<TextInput
data-autofocus
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={t("search.placeholder")}
aria-label={t("search.label")}
/>
{groups.map((group) => {
const groupActions = orderedActions.filter((action) => action.group === group);
if (groupActions.length === 0) return null;
{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>
);
})
)}
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({
@@ -0,0 +1,90 @@
"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>
);
};
@@ -1,86 +0,0 @@
"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,12 +1,13 @@
"use client";
import { useState } from "react";
import { Group, ScrollArea, Title } from "@mantine/core";
import { IconArrowLeft } from "@tabler/icons-react";
import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
import { IconArrowLeft, IconX } 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";
@@ -19,11 +20,15 @@ interface IntegrationSelectModalProps {
enableMockIntegration?: boolean;
allowedKinds?: readonly IntegrationKind[];
completionBoardId?: string;
initialKind?: IntegrationKind;
initialUrl?: string;
initialName?: string;
}
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
const [step, setStep] = useState<"select" | "form">("select");
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(null);
const t = useI18n();
const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? null);
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
const handleSelect = (kind: IntegrationKind) => {
@@ -35,6 +40,7 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
setStep("select");
setSelectedKind(null);
};
const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
const handleSuccess = (result?: CreatedIntegrationResult) => {
actions.closeModal();
@@ -46,14 +52,29 @@ 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">
<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} />
<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}
/>
</ScrollArea.Autosize>
);
}
+2
View File
@@ -12,6 +12,7 @@ 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";
@@ -35,6 +36,7 @@ export const mcpRouter = createTRPCRouter({
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
airQuality: airQualityRouter,
bazarr: bazarrRouter,
serverSettings: serverSettingsRouter,
beszel: beszelRouter,
@@ -23,6 +23,11 @@ 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")
@@ -65,12 +70,14 @@ export const dockerRouter = createTRPCRouter({
.meta({
mcp: {
enabled: true,
description: "List all Docker containers with their state, image, CPU/memory usage, and ports",
description:
"List Docker containers with their state, image, CPU/memory usage, and ports. Optionally filter by endpoint IDs; omitted or empty means all endpoints.",
},
})
.concat(dockerMiddleware())
.query(async () => {
const innerHandler = dockerContainersRequestHandler.handler({});
.input(z.object({ endpointIds: z.array(z.string().min(1)).max(100).optional() }).optional())
.query(async ({ input }) => {
const innerHandler = dockerContainersRequestHandler.handler(normalizeGetContainersInput(input));
const result = await innerHandler.getDataAsync();
const { data, timestamp } = result;
+57 -7
View File
@@ -11,6 +11,10 @@ import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
const logger = createLogger({ module: "logRouter" });
type LogSubscriptionEvent =
| { type: "history"; messages: LoggerMessage[] }
| { type: "message"; message: LoggerMessage };
export const logRouter = createTRPCRouter({
subscribe: permissionRequiredProcedure
.requiresPermission("other-view-logs")
@@ -20,15 +24,61 @@ export const logRouter = createTRPCRouter({
}),
)
.subscription(({ input }) => {
return observable<LoggerMessage>((emit) => {
const unsubscribe = loggingChannel.subscribe((data) => {
if (!input.levels.includes(data.level)) return;
emit.next(data);
});
logger.info("A tRPC client has connected to the logging procedure");
return observable<LogSubscriptionEvent>((emit) => {
let isClosed = false;
let unsubscribe: (() => void) | null = null;
let isHistoryLoaded = false;
const bufferedMessages: LoggerMessage[] = [];
const onMessage = (message: LoggerMessage) => {
if (!input.levels.includes(message.level)) return;
if (!isHistoryLoaded) {
bufferedMessages.push(message);
return;
}
emit.next({ type: "message", message });
};
void loggingChannel
.subscribeAsync(onMessage)
.then(async (unsubscribeFromLogging) => {
if (isClosed) {
unsubscribeFromLogging();
return;
}
unsubscribe = unsubscribeFromLogging;
logger.info("A tRPC client has connected to the logging procedure");
const history = (await loggingChannel.getRecentAsync()).filter((message) =>
input.levels.includes(message.level),
);
if (isClosed) return;
emit.next({ type: "history", messages: history });
const historyIds = new Set(history.map((message) => message.id));
isHistoryLoaded = true;
for (const message of bufferedMessages) {
if (!historyIds.has(message.id)) {
emit.next({ type: "message", message });
}
}
bufferedMessages.length = 0;
})
.catch((error: unknown) => {
unsubscribe?.();
unsubscribe = null;
if (!isClosed) {
emit.error(error);
}
});
return () => {
unsubscribe();
isClosed = true;
unsubscribe?.();
};
});
}),
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import type { LoggerMessage } from "@homarr/redis";
import { logRouter } from "../log";
const mocks = vi.hoisted(() => ({
getRecentAsync: vi.fn(),
subscribeAsync: vi.fn(),
}));
vi.mock("@homarr/redis", () => ({
loggingChannel: {
getRecentAsync: mocks.getRecentAsync,
subscribeAsync: mocks.subscribeAsync,
},
}));
const session = {
user: {
id: "log-viewer",
permissions: ["other-view-logs"],
colorScheme: "light",
},
expires: new Date(0).toISOString(),
} satisfies Session;
const createMessage = (id: string, timestamp: number): LoggerMessage => ({
id,
timestamp: new Date(timestamp),
level: "info",
message: id,
});
describe("log.subscribe", () => {
test("emits history before buffered live messages without duplicates", async () => {
const historyDeferred = Promise.withResolvers<LoggerMessage[]>();
let onMessage: ((message: LoggerMessage) => void) | undefined;
const unsubscribe = vi.fn();
mocks.subscribeAsync.mockImplementation(async (callback: (message: LoggerMessage) => void) => {
onMessage = callback;
return unsubscribe;
});
mocks.getRecentAsync.mockReturnValue(historyDeferred.promise);
const caller = logRouter.createCaller({
db: null as never,
deviceType: undefined,
session,
});
const subscription = await caller.subscribe({ levels: ["info"] });
const events: object[] = [];
const observer = subscription.subscribe({
next(event) {
events.push(event);
},
});
await vi.waitFor(() => expect(mocks.getRecentAsync).toHaveBeenCalledOnce());
const retainedMessage = createMessage("retained", 1);
const liveMessage = createMessage("live", 2);
onMessage?.(retainedMessage);
onMessage?.(liveMessage);
historyDeferred.resolve([retainedMessage]);
await vi.waitFor(() =>
expect(events).toEqual([
{ type: "history", messages: [retainedMessage] },
{ type: "message", message: liveMessage },
]),
);
observer.unsubscribe();
expect(unsubscribe).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,29 @@
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,6 +13,7 @@ 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(),
latitude: z.number(),
longitude: z.number().min(-180).max(180),
latitude: z.number().min(-90).max(90),
});
export const weatherRouter = createTRPCRouter({
+1
View File
@@ -9,6 +9,7 @@ vi.mock("@homarr/auth", () => ({}));
const MCP_TOOL_ALLOWLIST = {
query: [
"airQuality_atLocation",
"apiKeys_getAll",
"app_all",
"app_byId",
@@ -1,6 +1,20 @@
import z from "zod/v4";
export const logLevels = ["error", "warn", "info", "debug"] as const;
export type LogLevel = (typeof logLevels)[number];
export const loggerMessageSchema = z.object({
id: z.string(),
timestamp: z.date(),
message: z.string(),
level: z.enum(logLevels),
});
export type LoggerMessage = z.infer<typeof loggerMessageSchema>;
export const LOG_HISTORY_MAX_ENTRIES = 5_000;
export const LOG_HISTORY_KEY = "list:logging:history";
export const LOG_PUBLISH_CHANNEL = "pubSub:logging";
export const logLevelConfiguration = {
error: {
prefix: "🔴",
@@ -1,8 +1,11 @@
import superjson from "superjson";
import { randomUUID } from "node:crypto";
import { stringify } from "superjson";
import Transport from "winston-transport";
import type { RedisClient } from "../../redis/client";
import { createRedisClient } from "../../redis/client";
import type { LoggerMessage } from "../constants";
import { LOG_HISTORY_KEY, LOG_HISTORY_MAX_ENTRIES, LOG_PUBLISH_CHANNEL } from "../constants";
const messageSymbol = Symbol.for("message");
const levelSymbol = Symbol.for("level");
@@ -13,32 +16,41 @@ const levelSymbol = Symbol.for("level");
//
export class RedisTransport extends Transport {
private redis: RedisClient | null = null;
public static readonly publishChannel = "pubSub:logging";
/**
* Log the info to the Redis channel
* Retain the log in a bounded Redis list and publish it to live subscribers.
*/
log(info: { [messageSymbol]: string; [levelSymbol]: string }, callback: () => void) {
log(info: { [messageSymbol]: string; [levelSymbol]: LoggerMessage["level"] }, callback: () => void) {
setImmediate(() => {
this.emit("logged", info);
});
// Is only initialized here because it did not work when initialized in the constructor or outside the class
this.redis ??= createRedisClient();
const message: LoggerMessage = {
id: randomUUID(),
timestamp: new Date(),
message: info[messageSymbol],
level: info[levelSymbol],
};
const serializedMessage = stringify(message);
this.redis
.publish(
RedisTransport.publishChannel,
superjson.stringify({
message: info[messageSymbol],
level: info[levelSymbol],
}),
)
callback();
void Promise.resolve()
.then(() => {
callback();
// Is only initialized here because it did not work when initialized in the constructor or outside the class
if (!this.redis) {
this.redis = createRedisClient();
this.redis.options.maxRetriesPerRequest = 1;
this.redis.on("error", () => undefined);
}
return this.redis
.multi()
.lpush(LOG_HISTORY_KEY, serializedMessage)
.ltrim(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1)
.publish(LOG_PUBLISH_CHANNEL, serializedMessage)
.exec();
})
.catch(() => {
// Ignore errors
});
.catch(() => undefined);
}
}
@@ -336,6 +336,10 @@ 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,6 +3,10 @@ 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",
+8
View File
@@ -1,6 +1,10 @@
export const widgetKinds = [
"clock",
"weather",
"today",
"airQuality",
"countdown",
"timer",
"app",
"iframe",
"video",
@@ -59,6 +63,10 @@ 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 },
@@ -112,13 +112,15 @@ 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: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
@@ -0,0 +1,65 @@
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,3 +1,4 @@
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";
@@ -21,6 +22,42 @@ 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({
@@ -56,17 +93,27 @@ 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 ?? null;
sessionInfo.TranscodingInfo?.Bitrate ??
sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ??
(mediaStreamsBitrateBps || 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: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
@@ -74,7 +121,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
positionMs,
durationMs,
},
location: null,
location: parseLocation(sessionInfo.RemoteEndPoint),
metadata: {
video: {
resolution:
@@ -115,9 +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: parseOptionalNumber(mediaElement.$.parentIndex),
episodeName: mediaElement.$.title ?? null,
episodeNumber: parseOptionalNumber(mediaElement.$.index),
seasonNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.parentIndex) : null,
episodeName: mediaElement.$.type === "episode" ? (mediaElement.$.title ?? null) : null,
episodeNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.index) : null,
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
episodeCount: parseOptionalNumber(mediaElement.$.index),
playback: {
@@ -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[];
initialUrls?: (string | null)[];
}
const formSchema = z.object({
containerUrls: z.array(appHrefSchema),
});
export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(({ actions, innerProps }) => {
const t = useI18n();
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 form = useZodForm(formSchema, {
initialValues: {
containerUrls: innerProps.selectedContainers.map((container, index) => {
const initialUrl = innerProps.initialUrls?.[index];
if (initialUrl !== undefined) 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 = () => {
const handleSubmit = ({ containerUrls }: z.infer<typeof formSchema>) => {
mutate(
innerProps.selectedContainers.map((container, index) => ({
name: container.name,
iconUrl: container.iconUrl,
description: null,
href: form.values.containerUrls[index] ?? null,
href: containerUrls[index] || null,
pingUrl: null,
})),
);
@@ -68,28 +68,31 @@ export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(
<LoadingOverlay visible={isPending} />
<Stack>
<List spacing={"xs"}>
{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>
))}
{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>
);
})}
</List>
<Group justify="end">
<Button onClick={actions.closeModal} variant="light" px={"xl"}>
+2 -2
View File
@@ -104,7 +104,7 @@ const CredentialsSetup = () => {
return (
<AccountShell title={t("title")} description={t("description")}>
<form onSubmit={form.onSubmit((values) => void submitAsync(values))}>
<form onSubmit={form.onSubmit((values) => submitAsync(values))}>
<Stack gap="md">
<TextInput label={t("username")} autoComplete="username" withAsterisk {...form.getInputProps("username")} />
<UserCreatePasswordFields
@@ -119,7 +119,7 @@ const CredentialsSetup = () => {
{submitError}
</Alert>
) : null}
<Button type="submit" size="md" loading={mutation.isPending} rightSection={<IconArrowRight size={18} />}>
<Button type="submit" size="md" loading={form.submitting} rightSection={<IconArrowRight size={18} />}>
{t("create")}
</Button>
</Stack>
+2
View File
@@ -1,3 +1,5 @@
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,6 +358,7 @@
display: flex;
width: max-content;
min-width: 100%;
margin: 0;
padding: 0.2rem;
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-default);
@@ -0,0 +1,106 @@
"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>
);
};
+6 -68
View File
@@ -15,7 +15,6 @@ import {
ColorSwatch,
CopyButton,
Fieldset,
FloatingIndicator,
Group,
Paper,
PasswordInput,
@@ -99,6 +98,7 @@ 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,57 +152,6 @@ 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");
@@ -924,24 +873,13 @@ const Essentials = (props: StudioSectionProps) => {
<Alert variant="light" icon={<IconInfoCircle size={18} />} my="xs">
{t("serverOriginHelp")}
</Alert>
<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)}
<ServiceUrlTemplate
serverOrigin={props.serverOrigin}
onServerOriginChange={props.setServerOrigin}
mode={props.urlMode}
onModeChange={props.setUrlMode}
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>
+38 -6
View File
@@ -1,6 +1,16 @@
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
import { parse } from "superjson";
import type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
import {
loggerMessageSchema,
LOG_HISTORY_KEY,
LOG_HISTORY_MAX_ENTRIES,
LOG_PUBLISH_CHANNEL,
} from "@homarr/core/infrastructure/logs/constants";
import { createListChannel, createSubPubChannel } from "./lib/channel";
import { ChannelSubscriptionTracker } from "./lib/channel-subscription-tracker";
import { createRedisConnection } from "./lib/connection";
export {
handshakeAsync,
@@ -15,9 +25,31 @@ export const pingChannel = createSubPubChannel<
>("ping");
export const pingUrlChannel = createListChannel<string>("ping-url");
export interface LoggerMessage {
message: string;
level: LogLevel;
}
export type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
export const loggingChannel = createSubPubChannel<LoggerMessage>("logging");
const loggingHistoryClient = createRedisConnection();
const parseLoggerMessage = (message: string): LoggerMessage | null => {
try {
const result = loggerMessageSchema.safeParse(parse<unknown>(message));
return result.success ? result.data : null;
} catch {
return null;
}
};
export const loggingChannel = {
subscribeAsync: (callback: (message: LoggerMessage) => void) =>
ChannelSubscriptionTracker.subscribeAsync(LOG_PUBLISH_CHANNEL, (message) => {
const parsedMessage = parseLoggerMessage(message);
if (parsedMessage) callback(parsedMessage);
}),
getRecentAsync: async () => {
const messages =
(await loggingHistoryClient?.lrange(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1).catch(() => [])) ?? [];
return messages
.map(parseLoggerMessage)
.filter((message): message is LoggerMessage => message !== null)
.toReversed();
},
};
@@ -18,6 +18,7 @@ type SubscriptionCallback = (message: string) => MaybePromise<void>;
*/
export class ChannelSubscriptionTracker {
private static subscriptions = new Map<string, Map<string, SubscriptionCallback>>();
private static subscriptionReadiness = new Map<string, Promise<void>>();
private static redis = createRedisConnection();
private static listenerActive = false;
@@ -28,6 +29,25 @@ export class ChannelSubscriptionTracker {
* @returns a function to unsubscribe from the channel
*/
public static subscribe(channelName: string, callback: SubscriptionCallback) {
return this.registerSubscription(channelName, callback).unsubscribe;
}
/**
* Subscribes to a channel and resolves once Redis confirms the subscription.
*/
public static async subscribeAsync(channelName: string, callback: SubscriptionCallback) {
const subscription = this.registerSubscription(channelName, callback);
try {
await subscription.ready;
return subscription.unsubscribe;
} catch (error) {
subscription.unsubscribe();
throw error;
}
}
private static registerSubscription(channelName: string, callback: SubscriptionCallback) {
logger.debug("Adding redis channel callback", { channel: channelName });
// We only want to activate the listener once
@@ -38,11 +58,18 @@ export class ChannelSubscriptionTracker {
const channelSubscriptions = this.subscriptions.get(channelName) ?? new Map<string, SubscriptionCallback>();
const id = randomUUID();
let ready = this.subscriptionReadiness.get(channelName);
// If there are no subscriptions to the channel, subscribe to it
if (channelSubscriptions.size === 0) {
// Subscribe when this channel has no active or pending Redis subscription.
if (!ready) {
logger.debug("Subscribing to redis channel", { channel: channelName });
void this.redis.subscribe(channelName);
ready = this.redis.subscribe(channelName).then(() => undefined);
this.subscriptionReadiness.set(channelName, ready);
void ready.catch(() => {
if (this.subscriptionReadiness.get(channelName) === ready) {
this.subscriptionReadiness.delete(channelName);
}
});
}
logger.debug("Adding redis channel callback", { channel: channelName, id });
@@ -50,24 +77,26 @@ export class ChannelSubscriptionTracker {
this.subscriptions.set(channelName, channelSubscriptions);
// Return a function to unsubscribe
return () => {
const unsubscribe = () => {
logger.debug("Removing redis channel callback", { channel: channelName, id });
const channelSubscriptions = this.subscriptions.get(channelName);
if (!channelSubscriptions) return;
const currentSubscriptions = this.subscriptions.get(channelName);
if (!currentSubscriptions) return;
channelSubscriptions.delete(id);
currentSubscriptions.delete(id);
// If there are no subscriptions to the channel, unsubscribe from it
if (channelSubscriptions.size >= 1) {
if (currentSubscriptions.size >= 1) {
return;
}
logger.debug("Unsubscribing from redis channel", { channel: channelName });
void this.redis.unsubscribe(channelName);
this.subscriptions.delete(channelName);
this.subscriptionReadiness.delete(channelName);
};
return { ready, unsubscribe };
}
/**
@@ -0,0 +1,101 @@
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
@@ -0,0 +1,283 @@
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;
}[];
}
+32 -17
View File
@@ -195,9 +195,24 @@ 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() {
async requestAsync({ endpointIds }: { endpointIds?: string[] }) {
if (isDemoMode()) {
const includesDemoEndpoint =
endpointIds === undefined || endpointIds.length === 0 || endpointIds.includes("demo");
if (!includesDemoEndpoint) return { containers: [], endpoints: [] };
return {
containers: mockContainers.map((container) => ({
...container,
@@ -205,21 +220,10 @@ export const dockerContainersRequestHandler = createWidgetRequestHandler({
endpointName: "Demo Docker",
resourceId: `demo:${container.id}`,
})),
endpoints: [
{
id: "demo",
name: "Demo Docker",
status: "available",
kind: "docker",
transport: "socket",
capabilities: ["inventory"],
source: "default",
scope: "admin",
},
] satisfies DockerEndpointStatus[],
endpoints: [demoDockerEndpoint],
};
}
return await getContainersWithStatsAsync();
return await getContainersWithStatsAsync(dockerWidgetEndpointTimeoutMs, endpointIds);
},
});
@@ -304,6 +308,8 @@ export const streamContainerLogsAsync = async (
};
export const getDockerEndpointsAsync = (): DockerEndpointStatus[] => {
if (isDemoMode()) return [demoDockerEndpoint];
const dockerInstances = DockerSingleton.getInstances();
const initializationFailures = DockerSingleton.getInitializationFailures();
@@ -331,9 +337,18 @@ export const getDockerEndpointsAsync = (): DockerEndpointStatus[] => {
];
};
export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpointTimeoutMs) {
const dockerInstances = DockerSingleton.getInstances();
const initializationFailures = DockerSingleton.getInitializationFailures();
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),
);
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host, endpointId, endpointName }) => {
const instanceContainers = await withDockerTimeoutAsync(
@@ -2,11 +2,14 @@ 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>({ requestAsync: options.requestAsync });
const inner = createRequestHandler<TData, TInput>(options);
return inner;
};
@@ -7,6 +7,8 @@ import { DockerSingleton } from "@homarr/docker";
import {
calculateCpuUsage,
calculateMemoryUsage,
dockerContainersRequestHandler,
getDockerEndpointsAsync,
getContainersWithStatsAsync,
hasDockerEndpointCapability,
} from "../docker";
@@ -127,6 +129,31 @@ 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 {
@@ -211,14 +238,26 @@ describe("getContainersWithStatsAsync", () => {
});
});
test("demo capabilities match the advertised inventory-only endpoint", () => {
test("demo mode advertises and filters the same inventory-only endpoint", async () => {
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;
}
@@ -0,0 +1,112 @@
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();
});
});
+225 -43
View File
@@ -1,73 +1,255 @@
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({
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;
},
cacheTtlMs: 5 * 60 * 1000,
requestAsync: requestWeatherAsync,
});
const nullableNumberArray = z.array(z.number().nullable()).default([]);
const nullableStringArray = z.array(z.string().nullable()).default([]);
const atLocationOutput = z.object({
current_weather: z.object({
weathercode: z.number(),
temperature: z.number(),
windspeed: z.number(),
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,
}),
daily: z.object({
time: z.array(z.string()),
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()),
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,
}),
});
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: {
weathercode: number;
temperature: number;
apparentTemperature: number;
relativeHumidity: number;
precipitation: number;
weatherCode: number;
weathercode: number;
windSpeed: 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;
sunrise: string | undefined;
sunset: string | undefined;
maxWindSpeed: number | undefined;
maxWindGusts: 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;
}[];
}
+405 -45
View File
@@ -66,15 +66,10 @@
"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."
},
@@ -2171,6 +2166,13 @@
"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.",
@@ -2307,7 +2309,6 @@
"open": "Open Homarr Assistant",
"close": "Close assistant",
"minimize": "Minimize assistant",
"windowControls": "Conversation window controls",
"spotlight": "Ask Homarr Assistant",
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
"spotlightPrompt": {
@@ -2325,6 +2326,24 @@
"newConversation": "New conversation",
"conversations": "Conversations",
"delete": "Delete",
"cancel": "Cancel",
"conversationActions": "Conversation actions",
"searchConversations": "Search conversations",
"noMatchingConversations": "No matching conversations",
"moreActions": "More response actions",
"renameConversation": {
"action": "Rename",
"label": "Conversation name",
"save": "Save name",
"failedTitle": "Conversation could not be renamed",
"failedDescription": "Homarr could not rename this conversation. Try again."
},
"deleteConversation": {
"title": "Delete conversation?",
"description": "“{title}” and its messages will be permanently deleted.",
"failedTitle": "Conversation could not be deleted",
"failedDescription": "Homarr could not delete this conversation. Try again."
},
"closeHistory": "Close conversation history",
"openHistory": "Open conversation history",
"emptyTitle": "What can I help with?",
@@ -2397,6 +2416,8 @@
"regenerate": "Regenerate response",
"helpful": "Helpful",
"notHelpful": "Not helpful",
"readAloud": "Read response aloud",
"stopReading": "Stop reading response",
"feedbackError": "Your feedback could not be saved. Try again.",
"exportMarkdown": "Export as Markdown",
"exportConversation": {
@@ -2425,7 +2446,6 @@
},
"mentions": {
"menu": "Add Homarr context",
"hint": "Type @ to add context",
"context": "Context",
"app": "Apps",
"integration": "Integrations",
@@ -2440,7 +2460,6 @@
},
"commands": {
"menu": "Assistant commands",
"hint": "Type / for shortcuts",
"health": "Prepare a service-health check",
"explore": "Prepare an overview of this Homarr instance",
"media": "Prepare a media search",
@@ -2553,7 +2572,6 @@
"otherPlaceholder": "Type another answer…",
"submit": "Send answer",
"cancelOther": "Cancel other answer",
"automaticAffirmative": "Choosing the affirmative answer automatically",
"optionKind": {
"affirmative": "Confirm",
"negative": "Decline",
@@ -2628,7 +2646,7 @@
"autoApproval": {
"label": "Automatic approvals",
"title": "Automatic approvals",
"description": "Automatically approve protected changes, use presented form details, and choose answers categorized as affirmative. Neutral selections still wait for you. Your Homarr permissions still apply. This turns off when you switch conversations.",
"description": "Automatically prepare forms and approve protected changes for this conversation. Questions still wait for your answer, and your Homarr permissions still apply. This turns off when you switch conversations.",
"enable": "Enable for this conversation",
"disable": "Turn off automatic approvals",
"enabled": "Automatic approvals are on",
@@ -3151,9 +3169,6 @@
"label": "24-hour format",
"description": "Use 24-hour format instead of 12-hour format"
},
"showSeconds": {
"label": "Display seconds"
},
"useCustomTimezone": {
"label": "Use a fixed timezone"
},
@@ -3165,16 +3180,12 @@
"label": "Show the date"
},
"dateFormat": {
"label": "Date Format",
"description": "How the date should look like"
"label": "Date format",
"description": "Choose a Day.js preset. Each option shows a live example and its format tokens."
},
"customTimeFormat": {
"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)"
"label": "Time format",
"description": "Choose a Day.js preset. The selected format controls 12/24-hour display and whether the clock updates every second."
},
"showWeather": {
"label": "Show weather",
@@ -3185,7 +3196,317 @@
},
"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": {
@@ -3531,6 +3852,10 @@
"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."
}
}
},
@@ -3577,6 +3902,13 @@
"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"
@@ -3630,11 +3962,40 @@
"detailsFor": "Show weather details for {date}",
"advanced": {
"forecast": "7-day forecast",
"hourlyForecast": "Next 24 hours",
"day": "Day",
"time": "Time",
"conditions": "Conditions",
"temperature": "High / low",
"temperature": "Temperature",
"feelsLike": "Feels like {temperature}",
"feelsLikeLabel": "Feels like",
"feelsLikeRange": "Feels like: {maximum} / {minimum}",
"highLow": "High {maximum} · low {minimum}",
"observedAt": "Updated at {time}",
"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"
}
@@ -3771,6 +4132,11 @@
"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"
},
@@ -5719,6 +6085,7 @@
},
"dynamicSelect": {
"noResults": "No results found",
"loading": "Loading...",
"loadError": "Failed to load options"
},
"assistant": {
@@ -5732,7 +6099,13 @@
"compact": {
"title": "Assistant",
"description": "Resize this widget to chat here, or open the full conversation.",
"open": "Open assistant"
"open": "Open assistant",
"openTitle": "Assistant open in side panel",
"openDescription": "The conversation stays in one active surface.",
"returnToWidget": "Return the conversation to this widget",
"otherWidgetTitle": "Assistant active in another widget",
"otherWidgetDescription": "Only one conversation surface stays active at a time.",
"useHere": "Use the assistant in this widget"
}
}
},
@@ -7107,23 +7480,18 @@
"docker": {
"title": "Containers",
"reconciliation": {
"title": "Service setup",
"description": "Docker services are compared with your apps and integrations. Review every suggestion before Homarr changes anything.",
"title": "Assisted Docker setup",
"suggestions": "{count, plural, =0 {No suggestions} one {# suggestion} other {# suggestions}}",
"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",
@@ -7132,19 +7500,9 @@
"moved": "Address changed"
},
"url": {
"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"
"manual": "Enter an address manually"
},
"health": {
"title": "Observed service layers",
"layer": {
"docker": "Docker",
"integrationConfiguration": "Integration",
@@ -7166,17 +7524,15 @@
}
},
"action": {
"refresh": "Refresh",
"retry": "Try again",
"review": "Review",
"dismiss": "Dismiss",
"restoreDismissed": "Restore dismissed ({count})",
"setupIntegration": "Set up integration",
"createApp": "Create app",
"reviewIntegration": "Review integration",
"viewRepresentation": "View setup"
},
"loadError": {
"title": "Service setup could not be loaded",
"title": "Docker setup assistant could not be loaded",
"message": "Docker service information is unavailable. Try again without leaving this page."
},
"refreshError": {
@@ -8390,6 +8746,10 @@
}
},
"log": {
"context": {
"widgetError": "Widget reported an error around this time",
"expiredWidgetError": "Logs around this widget error are no longer retained"
},
"level": {
"option": {
"debug": "Debug",
+1
View File
@@ -65,6 +65,7 @@
"@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,7 +3,9 @@ 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";
@@ -14,6 +16,7 @@ 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";
@@ -25,6 +28,8 @@ const mapping = {
internal: WidgetInternalInput,
anchorNote: WidgetAnchorNoteInput,
text: WidgetTextInput,
dateTimeEventList: WidgetDateTimeEventListInput,
timezoneList: WidgetTimezoneListInput,
location: WidgetLocationInput,
multiSelect: WidgetMultiSelectInput,
multiText: WidgetMultiTextInput,
@@ -36,6 +41,7 @@ const mapping = {
sortableItemList: WidgetSortedItemListInput,
multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput,
dynamicSelect: WidgetDynamicSelectInput,
dynamicMultiSelect: WidgetDynamicMultiSelectInput,
umamiEventName: WidgetUmamiEventInput,
umamiEventNames: WidgetUmamiEventNamesInput,
umamiWebsite: WidgetUmamiWebsiteInput,
@@ -0,0 +1,317 @@
"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;
@@ -0,0 +1,45 @@
"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}
/>
);
};
@@ -72,6 +72,7 @@ export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInput
);
}}
description={options.withDescription ? tWidget("description") : undefined}
placeholder={options.withPlaceholder ? tWidget("placeholder") : undefined}
searchable={options.searchable}
{...inputProps}
/>
@@ -0,0 +1,249 @@
"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;
@@ -0,0 +1,399 @@
"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);
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
getAqiCategory,
getAqiStandard,
getCompactAirQualityLayout,
getDominantPollutant,
getStrongestPollen,
} from "./display";
const point = {
observedAt: "2026-07-01T10:00:00.000Z",
europeanAqi: 42,
usAqi: 60,
uvIndex: 4,
pollutants: { pm2_5: 12, pm10: 20, ozone: 30, nitrogenDioxide: 4, sulphurDioxide: 3, carbonMonoxide: 100 },
europeanSubIndices: {
pm2_5: 20,
pm10: 31,
ozone: 42,
nitrogenDioxide: 10,
sulphurDioxide: 5,
carbonMonoxide: null,
},
usSubIndices: {
pm2_5: 50,
pm10: 40,
ozone: 30,
nitrogenDioxide: 20,
sulphurDioxide: 10,
carbonMonoxide: 60,
},
pollen: { alder: null, birch: 12, grass: 20, mugwort: 5, olive: null, ragweed: 2 },
};
describe("air quality display", () => {
it("uses the US scale only for US locales in automatic mode", () => {
expect(getAqiStandard("auto", "en-US")).toBe("us");
expect(getAqiStandard("auto", "fr-FR")).toBe("european");
expect(getAqiStandard("auto", "en")).toBe("european");
expect(getAqiStandard("us", "fr-FR")).toBe("us");
});
it("uses the defined AQI band boundaries", () => {
expect(getAqiCategory(20, "european")).toBe("good");
expect(getAqiCategory(21, "european")).toBe("fair");
expect(getAqiCategory(101, "european")).toBe("extremelyPoor");
expect(getAqiCategory(50, "us")).toBe("good");
expect(getAqiCategory(51, "us")).toBe("moderate");
expect(getAqiCategory(301, "us")).toBe("hazardous");
});
it("finds dominant subindices and raw pollen without combining them", () => {
expect(getDominantPollutant(point, "european")).toEqual({ key: "ozone", value: 42 });
expect(getDominantPollutant(point, "us")).toEqual({ key: "carbonMonoxide", value: 60 });
expect(getStrongestPollen(point)).toEqual({ key: "grass", value: 20 });
});
it("progressively reveals compact details", () => {
expect(getCompactAirQualityLayout(149, 200)).toMatchObject({ tiny: true });
expect(getCompactAirQualityLayout(250, 150)).toMatchObject({ showParticulatesAndPollen: true });
expect(getCompactAirQualityLayout(300, 170)).toMatchObject({ showSparkline: true });
});
});
+144
View File
@@ -0,0 +1,144 @@
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: Record<"alder" | "birch" | "grass" | "mugwort" | "olive" | "ragweed", number | null>;
}
export interface AirQualityData {
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;
}[];
}
export type AqiStandard = "european" | "us";
export type AqiStandardOption = "auto" | AqiStandard;
export type AqiCategory =
| "good"
| "fair"
| "moderate"
| "unhealthySensitive"
| "unhealthy"
| "poor"
| "veryPoor"
| "veryUnhealthy"
| "extremelyPoor"
| "hazardous"
| "unknown";
export type UvCategory = "low" | "moderate" | "high" | "veryHigh" | "extreme" | "unknown";
export type PollutantKey = keyof AirQualityPoint["pollutants"];
export type PollenKey = keyof AirQualityPoint["pollen"];
export const getAqiStandard = (option: AqiStandardOption, locale: string): AqiStandard => {
if (option !== "auto") return option;
try {
return new Intl.Locale(locale).region === "US" ? "us" : "european";
} catch {
return "european";
}
};
export const getAqiValue = (point: AirQualityPoint, standard: AqiStandard): number | null => {
if (standard === "us") return point.usAqi;
return point.europeanAqi;
};
export const getDailyAqiValue = (day: AirQualityData["daily"][number], standard: AqiStandard): number | null => {
if (standard === "us") return day.usAqiMax;
return day.europeanAqiMax;
};
export const getAqiCategory = (value: number | null, standard: AqiStandard): AqiCategory => {
if (value === null) return "unknown";
if (standard === "us") {
if (value <= 50) return "good";
if (value <= 100) return "moderate";
if (value <= 150) return "unhealthySensitive";
if (value <= 200) return "unhealthy";
if (value <= 300) return "veryUnhealthy";
return "hazardous";
}
if (value <= 20) return "good";
if (value <= 40) return "fair";
if (value <= 60) return "moderate";
if (value <= 80) return "poor";
if (value <= 100) return "veryPoor";
return "extremelyPoor";
};
export const getAqiColor = (category: AqiCategory): string => {
if (category === "good") return "green.6";
if (category === "fair") return "lime.7";
if (category === "moderate") return "yellow.7";
if (category === "unhealthySensitive" || category === "poor") return "orange.7";
if (category === "unhealthy" || category === "veryPoor") return "red.7";
if (category === "veryUnhealthy" || category === "extremelyPoor") return "grape.7";
if (category === "hazardous") return "dark.6";
return "gray.6";
};
export const getUvCategory = (value: number | null): UvCategory => {
if (value === null) return "unknown";
if (value < 3) return "low";
if (value < 6) return "moderate";
if (value < 8) return "high";
if (value < 11) return "veryHigh";
return "extreme";
};
export const getDominantPollutant = (
point: AirQualityPoint,
standard: AqiStandard,
): { key: PollutantKey; value: number } | null => {
const values = standard === "us" ? point.usSubIndices : point.europeanSubIndices;
return getHighestValue(values);
};
export const getStrongestPollen = (point: AirQualityPoint): { key: PollenKey; value: number } | null =>
getHighestValue(point.pollen);
const getHighestValue = <TKey extends string>(
values: Record<TKey, number | null>,
): { key: TKey; value: number } | null => {
let highest: { key: TKey; value: number } | null = null;
for (const [key, value] of Object.entries(values) as [TKey, number | null][]) {
if (value === null) continue;
if (highest === null || value > highest.value) highest = { key, value };
}
return highest;
};
export const getCompactAirQualityLayout = (width: number, height: number) => ({
tiny: width < 150 || height < 96,
showParticulatesAndPollen: width >= 240 && height >= 140,
showSparkline: width >= 300 && height >= 170,
});
export const getUpcomingHours = (data: AirQualityData, count: number): AirQualityData["hourly"] => {
const currentTime = Date.parse(data.current.observedAt);
return data.hourly.filter((hour) => Date.parse(hour.observedAt) >= currentTime).slice(0, count);
};
+43
View File
@@ -0,0 +1,43 @@
import { IconWind } from "@tabler/icons-react";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("airQuality", {
icon: IconWind,
supportsAdvancedFocus: true,
queryKey: [["widget", "airQuality", "atLocation"]],
refetchInterval: 900,
queryMatcher: ({ input }, scope) => {
const location = scope.options.location;
if (location === null || typeof location !== "object" || !("latitude" in location) || !("longitude" in location)) {
return false;
}
return widgetQueryInputMatches(input, {
latitude: location.latitude,
longitude: location.longitude,
});
},
createOptions() {
return optionsBuilder.from((factory) => ({
location: factory.location({
defaultValue: {
name: "Paris",
latitude: 48.85341,
longitude: 2.3488,
},
}),
aqiStandard: factory.select({
defaultValue: "auto",
options: [
{ value: "auto", label: (t) => t("widget.airQuality.option.aqiStandard.options.auto") },
{ value: "european", label: (t) => t("widget.airQuality.option.aqiStandard.options.european") },
{ value: "us", label: (t) => t("widget.airQuality.option.aqiStandard.options.us") },
],
}),
showUv: factory.switch({ defaultValue: true }),
showPollutants: factory.switch({ defaultValue: true }),
showPollen: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
+8
View File
@@ -15,15 +15,18 @@ import {
IconBuildingBank,
IconBusStop,
IconCalendar,
IconCalendarWeek,
IconChartAreaLine,
IconChartBar,
IconClock,
IconClockPlay,
IconCloud,
IconDeviceCctv,
IconDeviceGamepad,
IconDownload,
IconFileText,
IconGraphFilled,
IconHourglass,
IconHeadphones,
IconHeartRateMonitor,
IconHeartbeat,
@@ -47,6 +50,7 @@ import {
IconTransform,
IconVideo,
IconWall,
IconWind,
IconZoomQuestion,
} from "@tabler/icons-react";
@@ -56,6 +60,10 @@ import type { TablerIcon } from "@homarr/ui";
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
clock: IconClock,
weather: IconCloud,
today: IconCalendarWeek,
airQuality: IconWind,
countdown: IconHourglass,
timer: IconClockPlay,
app: IconApps,
assistant: IconRobot,
iframe: IconBrowser,
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more