Compare commits

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

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

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

No files matched your search

@@ -58,12 +58,12 @@ A random secret can be generated by using the following command: `openssl rand -
## Docker ## Docker
| Environment Variable | Description | Possible values | Default | | 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_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` | 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_HOSTNAMES` | Legacy comma-separated list of plaintext TCP hostnames. Ignored when `DOCKER_ENDPOINTS` is set | For example `localhost,docker.example.com` | - |
| `DOCKER_PORTS` | Comma-separated plaintext TCP ports paired with `DOCKER_HOSTNAMES` | For example `2375,2376` | - | | `DOCKER_PORTS` | Legacy comma-separated list of plaintext TCP ports. Ignored when `DOCKER_ENDPOINTS` is set | 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 | - | | `DOCKER_SOCKET_PATHS` | Legacy comma-separated list of mounted Docker-compatible socket paths. Ignored when `DOCKER_ENDPOINTS` is set | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
## Database ## Database
+16 -31
View File
@@ -24,11 +24,12 @@ import { dockerContainersWidget } from "@site/docs/widgets/docker-containers";
]} ]}
/> />
## Connecting Docker to Homarr ## Enabling the Docker integration
Docker connections come directly from Homarr's environment variables. They are automatically available in the Docker management table and Docker widget; you do not create a Docker integration under **Management → Integrations**. 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.
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. Otherwise, you'll need to mount the [docker socket](https://docs.docker.com/engine/security/protect-access/) to the Homarr container,
so Homarr can interact with the Docker service.
In Docker Compose, you can simply add this line: In Docker Compose, you can simply add this line:
@@ -121,23 +122,7 @@ services:
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
``` ```
For remote socket proxies, list the hostnames and matching ports in the same order: For named endpoints, transport security, or least-privilege actions, prefer `DOCKER_ENDPOINTS`. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
```yaml
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 ```yaml
services: services:
@@ -178,12 +163,16 @@ Supported transports are:
- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS - `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS
- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true` - `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true`
The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`. When `DOCKER_ENDPOINTS` is set, it takes precedence over `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS`. :::warning[Plaintext TCP]
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy, or TLS. The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`.
:::
When `DOCKER_ENDPOINTS` is set, it takes precedence over the legacy `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS` variables.
### Managing your Docker containers within Homarr ### Managing your Docker containers within Homarr
Homarr allows you to interact with Docker containers running on your system. Homarr allows you to interact with Docker containers running on your system.
The collapsed **Assisted Docker setup** helper optionally compares visible containers with your existing Homarr apps and integrations. Open it only when you want setup suggestions; it never blocks normal container management. Every service stays distinct by Docker endpoint and container ID, including multiple containers that use the same integration type. The **Service setup** section compares the visible containers with your existing Homarr apps and integrations. It keeps every service distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
Homarr can show a service as: Homarr can show a service as:
@@ -192,15 +181,15 @@ Homarr can show a service as:
- **Already represented** or **Linked** when an app or integration already matches - **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 - **Address changed** when a service appears to match an integration but its reachable address has changed
Set the usual server address once, then choose whether services normally use different ports, subdomains, or paths. Homarr uses that pattern to prefill every detected service. Each suggestion has one editable URL field with the generated and Docker-reported addresses available as autocomplete options, so you can select a suggestion or type any address directly. Suggested addresses are ranked and explain where they came from. A published address or Docker endpoint host is normally usable from a browser; a container-name address is server-only and requires Homarr to share the container network. Wildcard addresses such as `0.0.0.0` are never offered as browser destinations. You can edit every suggestion before continuing.
:::info[Review before changing] :::info[Review before changing]
Service discovery is read-only. Homarr never creates or changes an app or integration automatically. **Set up integration** opens the standard prefilled integration form, while **Create app** opens the existing Docker app form, so you can review and confirm the final setup. Service discovery is read-only. Homarr never creates or changes an app or integration automatically; use the action on an individual suggestion to review and confirm its setup.
::: :::
Suggestions appear as a compact task list. Expand an item only when you want to edit its URL or inspect relevant setup statuses. Use the filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same helper. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically. Use the inbox filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same panel. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
Compact status icons summarize the setup information Homarr can act on. Unknown and non-applicable runtime layers are omitted to keep the helper focused; use the integration form's connection test or the relevant board action for live diagnostics. The health badges are an evidence-based projection across Docker, integration configuration, app representation, and persisted widget links. Authentication, API request success, and widget query execution are labeled **Not observed** because Homarr does not currently retain trustworthy last-run results for these layers. Use the suggested integration test or board action to diagnose them live; **Not observed** is never presented as healthy.
If one Docker endpoint is unavailable, Homarr reports that endpoint while continuing to show services from healthy endpoints. An unavailable endpoint is not treated as an empty Docker installation. If one Docker endpoint is unavailable, Homarr reports that endpoint while continuing to show services from healthy endpoints. An unavailable endpoint is not treated as an empty Docker installation.
@@ -227,10 +216,6 @@ This opens a dedicated page with an xterm.js terminal that streams the container
The Docker management table supports optional **CPU usage** and **Memory usage** columns. 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. 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 #### 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. 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
@@ -110,10 +110,6 @@ Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.resta
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different 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 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 ## Partial integration failures
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
@@ -63,9 +63,9 @@ When you connect a service from a board, Homarr keeps you in the same setup flow
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a 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. widget returns to its settings with the new integration already selected.
Users with full access can also test and update a selected integration directly in the **Edit widget → Edit integration** Users with full access can also test and update a selected integration from **Edit widget → Edit integration** on a
tab on a board. Failed tests keep the editor open and show the same connection diagnostics as the management page. board. Failed tests keep the editor open and show the same connection diagnostics as the management page. Successful
Successful changes apply to every app and widget that uses the integration. 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 When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
create one. create one.
@@ -7,15 +7,20 @@ hide_title: true
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration"; 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 { 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 { dockerContainersWidget } from ".";
import { dockerIntegration } from "@site/docs/integrations/docker";
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} /> <WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
This widget displays the resource usage of your Docker containers and allows you to manage them. This widget displays the resource usage of your Docker containers and allows you to manage them.
:::info :::info
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges. This widget can only be used with administrator privileges.
::: :::
### Screenshots ### Screenshots
@@ -24,6 +29,16 @@ import dockerTable from "./img/table.png";
<img src={dockerTable} alt="docker-stats table docker-containers" width={128 * 3} height={128 * 2} /> <img src={dockerTable} alt="docker-stats table docker-containers" width={128 * 3} height={128 * 2} />
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: dockerIntegration,
},
]}
/>
### Interactions ### 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. Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
@@ -8,12 +8,6 @@ export const dockerContainersWidget: WidgetDefinition = {
path: "../../widgets/docker-containers", path: "../../widgets/docker-containers",
configuration: { configuration: {
items: [ 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", name: "Columns to show",
description: "Select which columns are visible in the table", 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"]} /> <WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
The media server widget shows active sessions across your media servers. The media server widget shows active sessions across your media servers.
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view. 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. If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
@@ -1,14 +1,12 @@
"use client"; "use client";
import { Avatar, Badge, Button, Group, Text, Tooltip, UnstyledButton } from "@mantine/core"; import { Avatar, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react"; import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection"; import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { CustomWidgetRowActions } from "./_custom-widget-actions"; import { CustomWidgetRowActions } from "./_custom-widget-actions";
@@ -43,92 +41,75 @@ type WidgetDef = RouterOutputs["customWidget"]["list"][number];
function CustomWidgetRow({ widget }: { widget: WidgetDef }) { function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
const t = useScopedI18n("customWidget"); const t = useScopedI18n("customWidget");
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
const origins = [...new Set(widget.sources.map((source) => source.origin))]; const origins = [...new Set(widget.sources.map((source) => source.origin))];
return ( return (
<> <ManageCollectionItem
<ManageCollectionItem leading={
leading={ <Avatar
<Avatar size={40}
size={40} radius="sm"
radius="sm" src={widget.iconUrl}
src={widget.iconUrl} color={widget.valid ? "pink" : "red"}
color={widget.valid ? "pink" : "red"} styles={{ image: { objectFit: "contain" } }}
styles={{ image: { objectFit: "contain" } }} alt=""
alt="" >
> <IconApi size={20} stroke={1.5} />
<IconApi size={20} stroke={1.5} /> </Avatar>
</Avatar> }
} title={
title={ <Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}> {widget.name}
{widget.name} </Text>
}
badges={<CustomWidgetStatusBadges widget={widget} />}
description={
<Text size="sm" c="dimmed" lineClamp={2}>
{widget.description || t("page.list.noDescription")}
</Text>
}
metadata={
<Group gap={6} wrap="wrap">
<Text size="xs" c="dimmed">
{t("page.list.requestCount", { count: widget.requestCount })}
</Text> </Text>
} {origins.map((origin) => (
badges={<CustomWidgetStatusBadges widget={widget} onConfigureSources={sourceSetupControls.open} />} <Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
description={ · {origin}
<Text size="sm" c="dimmed" lineClamp={2}>
{widget.description || t("page.list.noDescription")}
</Text>
}
metadata={
<Group gap={6} wrap="wrap">
<Text size="xs" c="dimmed">
{t("page.list.requestCount", { count: widget.requestCount })}
</Text> </Text>
{origins.map((origin) => ( ))}
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}> </Group>
· {origin} }
</Text> actions={
))} <Group gap="xs" wrap="nowrap">
</Group> {widget.valid && (
} <Button
actions={ component={Link}
<Group gap="xs" wrap="nowrap"> href={`/manage/custom-widgets/edit/${widget.id}`}
{widget.valid && ( variant="default"
<Button size="sm"
component={Link} leftSection={<IconPencil size={16} stroke={1.5} />}
href={`/manage/custom-widgets/edit/${widget.id}`} >
variant="default" {t("action.edit")}
size="sm" </Button>
leftSection={<IconPencil size={16} stroke={1.5} />} )}
> <CustomWidgetRowActions
{t("action.edit")} widget={{
</Button> id: widget.id,
)} name: widget.name,
<CustomWidgetRowActions enabled: widget.enabled,
widget={{ valid: widget.valid,
id: widget.id, migrationRequired: widget.migrationRequired,
name: widget.name, }}
enabled: widget.enabled, />
valid: widget.valid, </Group>
migrationRequired: widget.migrationRequired, }
}} />
/>
</Group>
}
actionsAlignment="center"
/>
{widget.valid && (
<CustomWidgetSourceSetupDialog
definitionId={widget.id}
opened={sourceSetupOpened}
onClose={sourceSetupControls.close}
/>
)}
</>
); );
} }
/** Everything that decides whether this widget can actually run right now. */ /** Everything that decides whether this widget can actually run right now. */
function CustomWidgetStatusBadges({ function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) {
widget,
onConfigureSources,
}: {
widget: WidgetDef;
onConfigureSources(): void;
}) {
const t = useScopedI18n("customWidget"); const t = useScopedI18n("customWidget");
if (widget.migrationRequired) { if (widget.migrationRequired) {
@@ -176,17 +157,9 @@ function CustomWidgetStatusBadges({
multiline multiline
maw={320} maw={320}
> >
<UnstyledButton onClick={onConfigureSources} aria-label={t("action.configureSources")}> <Badge color="yellow" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
<Badge {t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
color="yellow" </Badge>
size="sm"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
style={{ cursor: "pointer" }}
>
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
</Badge>
</UnstyledButton>
</Tooltip> </Tooltip>
)} )}
</Group> </Group>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useImperativeHandle, useRef, useState } from "react"; import { useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core"; import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react"; import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
@@ -27,14 +27,8 @@ import type { AnyMappedTestConnectionError } from "../../_components/test-connec
interface EditIntegrationFormProps { interface EditIntegrationFormProps {
integration: RouterOutputs["integration"]["byId"]; integration: RouterOutputs["integration"]["byId"];
hideButtons?: boolean; embedded?: boolean;
onSuccess?: () => void; onSuccess?: () => void;
formRef?: React.Ref<EditIntegrationFormHandle>;
}
export interface EditIntegrationFormHandle {
submit: () => Promise<boolean>;
isDirty: () => boolean;
} }
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and( const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
@@ -50,12 +44,7 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
}), }),
); );
export const EditIntegrationForm = ({ export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }: EditIntegrationFormProps) => {
integration,
hideButtons = false,
onSuccess,
formRef,
}: EditIntegrationFormProps) => {
const t = useI18n(); const t = useI18n();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const allSecretKinds = getAllSecretKindOptions(integration.kind); const allSecretKinds = getAllSecretKindOptions(integration.kind);
@@ -93,172 +82,147 @@ export const EditIntegrationForm = ({
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret])); const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = useCallback( const handleSubmitAsync = async ({ app, ...values }: FormType) => {
async ({ app, ...values }: FormType) => { setError(null);
setError(null); let url: string;
let url: string; try {
try { url = hasUrlSecret
url = hasUrlSecret ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin : values.url;
: values.url; } catch {
} catch { showErrorNotification({
showErrorNotification({ title: t("integration.page.edit.notification.error.title"),
title: t("integration.page.edit.notification.error.title"), message: t("integration.page.edit.notification.error.message"),
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;
}
// We do it this way as we are unable to send a typesafe error through onError const data = await mutateAsync({
if (data?.error) { id: integration.id,
setError(data.error); ...values,
showErrorNotification({ url,
title: t("integration.page.edit.notification.error.title"), secrets: values.secrets.map((secret) => ({
message: t("integration.page.edit.notification.error.message"), kind: secret.kind,
}); value: secret.value === "" ? null : secret.value,
requestAnimationFrame(() => errorRef.current?.focus()); })),
return false; appId: app?.id ?? null,
} });
showSuccessNotification({ // We do it this way as we are unable to send a typesafe error through onError
title: t("integration.page.edit.notification.success.title"), if (data?.error) {
message: t("integration.page.edit.notification.success.message"), setError(data.error);
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
}); });
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]); requestAnimationFrame(() => errorRef.current?.focus());
onSuccess?.(); return;
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],
);
useImperativeHandle( showSuccessNotification({
formRef, title: t("integration.page.edit.notification.success.title"),
() => ({ message: t("integration.page.edit.notification.success.message"),
submit: () => });
new Promise<boolean>((resolve) => { void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
form.onSubmit( onSuccess?.();
async (values) => { if (!embedded) {
try { void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
resolve(await handleSubmitAsync(values)); }
} catch { };
resolve(false);
}
},
() => resolve(false),
)();
}),
isDirty: () => form.isDirty(),
}),
[form, handleSubmitAsync],
);
const isInitialSecretKinds = const isInitialSecretKinds =
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) && initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
form.values.secrets.length === initialSecretsKinds.length; form.values.secrets.length === initialSecretsKinds.length;
const formFields = ( return (
<Stack> <form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} /> <Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
{hasUrlSecret ? null : ( {hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} /> <TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)} )}
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
{allSecretKinds.length > 1 && ( {allSecretKinds.length > 1 && (
<SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} /> <SecretKindsSegmentedControl
)} defaultKinds={initialSecretsKinds}
{!isInitialSecretKinds secretKinds={allSecretKinds}
? null form={form}
: form.values.secrets.map((secret, index) => ( />
<SecretCard )}
key={secret.kind} {!isInitialSecretKinds
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }} ? null
onCancel={() => : form.values.secrets.map((secret, index) => (
new Promise((resolve) => { <SecretCard
// 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} key={secret.kind}
kind={secret.kind} secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
onCancel={() =>
new Promise((resolve) => {
// When nothing changed, just close the secret card
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
return resolve(true);
}
openConfirmModal({
title: t("integration.secrets.reset.title"),
children: t("integration.secrets.reset.message"),
onCancel: () => resolve(false),
onConfirm: () => {
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
resolve(true);
},
});
})
}
>
<IntegrationSecretInput
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
key={secret.kind}
kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
</SecretCard>
))}
{isInitialSecretKinds
? null
: form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput
withAsterisk
key={kind}
kind={kind}
{...form.getInputProps(`secrets.${index}.value`)} {...form.getInputProps(`secrets.${index}.value`)}
/> />
</SecretCard> ))}
))} {form.values.secrets.length === 0 && (
{isInitialSecretKinds <Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
? null <Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
: form.values.secrets.map(({ kind }, index) => ( </Alert>
<IntegrationSecretInput )}
withAsterisk </Stack>
key={kind} </Fieldset>
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)} /> <IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
{error !== null && ( {error !== null && (
<div ref={errorRef} role="alert" tabIndex={-1}> <div ref={errorRef} role="alert" tabIndex={-1}>
<IntegrationTestConnectionError error={error} url={form.values.url} /> <IntegrationTestConnectionError error={error} url={form.values.url} />
</div> </div>
)} )}
{!hideButtons && (
<Group justify="end" align="center"> <Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations"> {!embedded && (
{t("common.action.backToOverview")} <Button variant="default" component={Link} href="/manage/integrations">
</Button> {t("common.action.backToOverview")}
</Button>
)}
<Button type="submit" loading={isPending}> <Button type="submit" loading={isPending}>
{t("integration.testConnection.action.edit")} {t("integration.testConnection.action.edit")}
</Button> </Button>
</Group> </Group>
)} </Stack>
</Stack> </form>
); );
if (hideButtons) {
return formFields;
}
return <form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>{formFields}</form>;
}; };
type FormType = z.infer<typeof formSchema>; type FormType = z.infer<typeof formSchema>;
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
import { import {
dismissDockerReconciliationCandidate, dismissDockerReconciliationCandidate,
filterDockerReconciliationInbox, filterDockerReconciliationInbox,
getTemplateUrl, getValidDockerServiceUrl,
} from "./docker-reconciliation-inbox"; } from "./docker-reconciliation-inbox";
const candidates = [ const candidates = [
@@ -37,44 +37,9 @@ describe("Docker reconciliation inbox", () => {
expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed); expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed);
}); });
test("builds service URLs from the server origin and published ports", () => { test("accepts only HTTP service URLs", () => {
expect( expect(getValidDockerServiceUrl("https://service.example.com")).toBe("https://service.example.com/");
getTemplateUrl( expect(getValidDockerServiceUrl("ftp://service.example.com")).toBeNull();
{ expect(getValidDockerServiceUrl("service.example.com")).toBeNull();
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,6 +1,3 @@
import type { DockerServiceMatch, DockerServicePort, UrlTemplateMode } from "@homarr/definitions";
import { buildAppUrl, buildIntegrationUrl, getIntegrationDefaultPort } from "@homarr/definitions";
export type DockerReconciliationInboxFilter = "attention" | "represented" | "all"; export type DockerReconciliationInboxFilter = "attention" | "represented" | "all";
interface DockerReconciliationInboxCandidate { interface DockerReconciliationInboxCandidate {
@@ -27,28 +24,11 @@ export const filterDockerReconciliationInbox = <Candidate extends DockerReconcil
export const dismissDockerReconciliationCandidate = (dismissedCandidateKeys: string[], candidateKey: string) => export const dismissDockerReconciliationCandidate = (dismissedCandidateKeys: string[], candidateKey: string) =>
dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey]; dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey];
interface DockerTemplateCandidate { export const getValidDockerServiceUrl = (value: string) => {
container: { try {
name: string; const url = new URL(value);
ports?: DockerServicePort[]; return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
}; } catch {
match: Pick<DockerServiceMatch, "kind"> | null; return 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,72 +1,46 @@
"use client"; "use client";
import { useEffect, useId, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { import {
Accordion,
ActionIcon,
Alert, Alert,
Autocomplete,
Avatar, Avatar,
Badge, Badge,
Button, Button,
Collapse, Card,
Divider,
Group, Group,
Paper,
SegmentedControl, SegmentedControl,
Select,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
ThemeIcon, TextInput,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks"; import { useLocalStorage } from "@mantine/hooks";
import { import { IconAlertTriangle, IconArrowRight, IconEyeOff, IconPlugConnected, IconRefresh } from "@tabler/icons-react";
IconAlertTriangle,
IconApps,
IconCheck,
IconChevronDown,
IconEye,
IconEyeOff,
IconBrandDocker,
IconMinus,
IconPlugConnected,
IconRefresh,
IconSettings,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { UrlTemplateMode } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions"; import { getIntegrationName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection"; import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification } from "@homarr/notifications"; import { showErrorNotification } from "@homarr/notifications";
import { normalizeServiceUrl, ServiceUrlTemplate } from "@homarr/onboarding"; import { useScopedI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox"; import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox";
import { import {
dismissDockerReconciliationCandidate, dismissDockerReconciliationCandidate,
filterDockerReconciliationInbox, filterDockerReconciliationInbox,
getTemplateUrl, getValidDockerServiceUrl,
} from "./docker-reconciliation-inbox"; } from "./docker-reconciliation-inbox";
type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number]; type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number];
type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number]; type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number];
export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOrigin: string }) => { export const DockerReconciliation = () => {
const t = useScopedI18n("docker.reconciliation"); const t = useScopedI18n("docker.reconciliation");
const tGlobal = useI18n();
const utils = clientApi.useUtils(); 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 reconciliation = clientApi.docker.reconcileServices.useQuery();
const health = clientApi.docker.getServiceHealth.useQuery(undefined, { enabled: isOpen }); const health = clientApi.docker.getServiceHealth.useQuery();
const refreshInventory = clientApi.docker.refreshInventory.useMutation({ const refreshInventory = clientApi.docker.refreshInventory.useMutation({
async onSuccess() { async onSuccess() {
await Promise.all([ await Promise.all([
@@ -87,14 +61,6 @@ export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOri
key: "homarr-docker-reconciliation-dismissed", key: "homarr-docker-reconciliation-dismissed",
defaultValue: [], 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) { if (reconciliation.isError) {
return ( return (
@@ -102,7 +68,7 @@ export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOri
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm">{t("loadError.message")}</Text> <Text size="sm">{t("loadError.message")}</Text>
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void reconciliation.refetch()}> <Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void reconciliation.refetch()}>
{tGlobal("common.action.tryAgain")} {t("action.retry")}
</Button> </Button>
</Stack> </Stack>
</Alert> </Alert>
@@ -113,274 +79,231 @@ export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOri
const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable"); const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable");
const candidates = filterDockerReconciliationInbox(reconciliation.data.candidates, filter, dismissedCandidateKeys); const candidates = filterDockerReconciliationInbox(reconciliation.data.candidates, filter, dismissedCandidateKeys);
const attentionCount = reconciliation.data.candidates.filter( const attentionCount = health.data
({ candidateKey, state }) => ? health.data.services.filter(
["newRecognized", "newApp", "moved"].includes(state) && !dismissedCandidateKeys.includes(candidateKey), ({ key, status }) =>
).length; ["newRecognized", "newApp", "moved"].includes(status) && !dismissedCandidateKeys.includes(key),
).length
: 0;
const isRefreshing = refreshInventory.isPending || reconciliation.isFetching || health.isFetching; const isRefreshing = refreshInventory.isPending || reconciliation.isFetching || health.isFetching;
const toggleLabel = isOpen ? tGlobal("common.action.hide") : t("action.review");
return ( return (
<Paper withBorder p="sm"> <Card withBorder>
<Stack gap={isOpen ? "sm" : 0}> <Stack>
<Group justify="space-between" align="start"> <Group justify="space-between" align="start">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}> <div>
<ThemeIcon variant="light" size="lg" radius="md"> <Group gap="xs">
<IconBrandDocker size={18} /> <IconPlugConnected size={20} />
</ThemeIcon> <Text fw={600}>{t("title")}</Text>
<div> </Group>
<Group gap="xs"> <Text c="dimmed" size="sm">
<Text fw={600}>{t("title")}</Text> {t("description")}
<Badge variant="light" color={attentionCount > 0 ? "blue" : "gray"} aria-live="polite"> </Text>
{t("suggestions", { count: String(attentionCount) })} </div>
</Badge> {health.data && <Badge variant="light">{t("attention", { 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> </Group>
<Collapse id={panelId} expanded={isOpen}> <Group justify="space-between">
<Divider mb="sm" /> <SegmentedControl
<Stack gap="sm"> value={filter}
<ServiceUrlTemplate onChange={(value) => setFilter(value as DockerReconciliationInboxFilter)}
serverOrigin={serverOrigin} data={[
onServerOriginChange={setServerOrigin} { value: "attention", label: t("filter.attention") },
mode={urlMode} { value: "represented", label: t("filter.represented") },
onModeChange={setUrlMode} { value: "all", label: t("filter.all") },
/> ]}
/>
<Group gap="xs">
{dismissedCandidateKeys.length > 0 && (
<Button variant="subtle" size="xs" onClick={() => setDismissedCandidateKeys([])}>
{t("action.restoreDismissed", { count: String(dismissedCandidateKeys.length) })}
</Button>
)}
<Button
variant="light"
size="xs"
loading={isRefreshing}
leftSection={<IconRefresh size={14} />}
onClick={() => refreshInventory.mutate()}
>
{t("action.refresh")}
</Button>
</Group>
</Group>
<Group justify="space-between"> {unavailableEndpoints.length > 0 && (
<SegmentedControl <Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
size="xs" {t("endpointUnavailable", { names: unavailableEndpoints.map(({ name }) => name).join(", ") })}
value={filter} </Alert>
onChange={(value) => setFilter(value as DockerReconciliationInboxFilter)} )}
data={[
{ value: "attention", label: t("filter.attention") }, {candidates.length === 0 ? (
{ value: "represented", label: t("filter.represented") }, <Text c="dimmed" size="sm">
{ value: "all", label: t("filter.all") }, {reconciliation.data.candidates.length === 0 ? t("empty") : t("emptyFilter")}
]} </Text>
) : (
<SimpleGrid cols={{ base: 1, lg: 2 }}>
{candidates.map((candidate) => (
<DockerReconciliationCandidate
key={candidate.candidateKey}
candidate={candidate}
health={health.data?.services.find(({ key }) => key === candidate.candidateKey)}
onDismiss={() =>
setDismissedCandidateKeys((current) =>
dismissDockerReconciliationCandidate(current, candidate.candidateKey),
)
}
/> />
<Group gap="xs"> ))}
{dismissedCandidateKeys.length > 0 && ( </SimpleGrid>
<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> </Stack>
</Paper> </Card>
); );
}; };
const DockerReconciliationCandidate = ({ const DockerReconciliationCandidate = ({
candidate, candidate,
health, health,
serverOrigin,
urlMode,
onDismiss, onDismiss,
}: { }: {
candidate: ReconciliationCandidate; candidate: ReconciliationCandidate;
health: ServiceHealth | undefined; health: ServiceHealth | undefined;
serverOrigin: string;
urlMode: UrlTemplateMode;
onDismiss: () => void; onDismiss: () => void;
}) => { }) => {
const t = useScopedI18n("docker.reconciliation"); const t = useScopedI18n("docker.reconciliation");
const tGlobal = useI18n(); const { openModal } = useModalAction(AddDockerAppToHomarr);
const { openModal: openAppModal } = useModalAction(AddDockerAppToHomarr); const initialUrlCandidate =
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal); candidate.urlCandidates.find(
const templateUrl = getTemplateUrl(candidate, serverOrigin, urlMode); ({ url, scopes }) => url.length > 0 && (candidate.match ? true : scopes.includes("browser")),
const urlSuggestions = Array.from( ) ?? candidate.urlCandidates.find(({ source }) => source === "manual");
new Set([templateUrl, ...candidate.urlCandidates.map(({ url }) => url)].filter((url) => url.length > 0)), const [selectedCandidateId, setSelectedCandidateId] = useState(initialUrlCandidate?.id ?? null);
); const [url, setUrl] = useState(initialUrlCandidate?.url ?? "");
const initialUrl = urlSuggestions[0] ?? ""; const selectedCandidate = candidate.urlCandidates.find(({ id }) => id === selectedCandidateId);
const [url, setUrl] = useState(initialUrl); const validatedUrl = getValidDockerServiceUrl(url);
const isUrlEdited = useRef(false); const isInvalidUrl = url.length > 0 && validatedUrl === null;
const target = getCandidateTarget(candidate); const target = getCandidateTarget(candidate, validatedUrl ?? "");
const actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration"; const actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration";
const visibleHealthLayers = const isActionDisabled = actionNeedsUrl && validatedUrl === null;
health?.layers.filter(({ status }) => status !== "notApplicable" && status !== "notObserved") ?? [];
useEffect(() => { useEffect(() => {
if (isUrlEdited.current) return; setSelectedCandidateId(initialUrlCandidate?.id ?? null);
setUrl(initialUrl); setUrl(initialUrlCandidate?.url ?? "");
}, [initialUrl]); }, [initialUrlCandidate?.id, initialUrlCandidate?.url]);
return ( return (
<Accordion.Item value={candidate.candidateKey}> <Card withBorder padding="md">
<Group wrap="nowrap" gap={4} pr="sm"> <Stack gap="sm">
<Accordion.Control <Group justify="space-between" align="start" wrap="nowrap">
style={{ flex: 1 }} <Group wrap="nowrap">
icon={ <Avatar src={candidate.container.iconUrl} radius="sm">
<Avatar src={candidate.container.iconUrl} radius="sm" size="sm">
{candidate.container.name.at(0)?.toUpperCase()} {candidate.container.name.at(0)?.toUpperCase()}
</Avatar> </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} {candidate.container.name}
</Text> </Text>
<Badge color={stateColor(candidate.state)} variant="light" size="xs"> <Text c="dimmed" size="xs">
{t(`state.${candidate.state}`)} {candidate.endpointName}
</Badge> </Text>
</Group> </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"> <Text c="dimmed" size="xs">
{candidate.endpointName} {t("match", { confidence: t(`confidence.${candidate.match.confidence}`) })}
</Text> </Text>
</Group>
)}
{candidate.representation.signals.ambiguous && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("ambiguous")}
</Alert>
)}
{health && (
<div>
<Text c="dimmed" size="xs" mb={4}>
{t("health.title")}
</Text>
<Group gap={4}>
{health.layers.map((layer) => (
<Badge key={layer.layer} color={healthStatusColor(layer.status)} variant="dot" size="sm">
{t(`health.layer.${layer.layer}`)}: {t(`health.status.${layer.status}`)}
</Badge>
))}
</Group>
</div> </div>
</Accordion.Control> )}
<Group gap={4} wrap="nowrap">
{target.kind === "createApp" && ( <Select
label={t("url.label")}
description={selectedCandidate ? t(`reason.${selectedCandidate.reason}`) : t("reason.manualHostRequired")}
value={selectedCandidateId}
data={candidate.urlCandidates.map((item) => ({
value: item.id,
label: item.url || t("url.manual"),
}))}
onChange={(value) => {
setSelectedCandidateId(value);
setUrl(candidate.urlCandidates.find(({ id }) => id === value)?.url ?? "");
}}
/>
<TextInput
label={t("url.inputLabel")}
aria-label={t("url.inputLabel")}
value={url}
placeholder="https://service.example.com"
error={isInvalidUrl ? t("url.invalid") : undefined}
onChange={(event) => setUrl(event.currentTarget.value)}
/>
<Group justify="space-between">
<Button variant="subtle" color="gray" leftSection={<IconEyeOff size={16} />} onClick={onDismiss}>
{t("action.dismiss")}
</Button>
{target.kind === "createApp" ? (
<Button <Button
variant="light" variant="light"
size="compact-xs" rightSection={<IconArrowRight size={16} />}
leftSection={<IconApps size={14} />} disabled={isActionDisabled}
onClick={() => onClick={() =>
openAppModal({ openModal({
selectedContainers: [candidate.container], selectedContainers: [candidate.container],
initialUrls: [(normalizeServiceUrl(url) ?? url) || null], initialUrls: validatedUrl ? [validatedUrl] : undefined,
}) })
} }
> >
{tGlobal("integration.field.createApp.label")} {t("action.createApp")}
</Button> </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 <Button
component={Link} component={Link}
href={target.href} href={target.href}
variant="light" variant="light"
size="compact-xs" rightSection={<IconArrowRight size={16} />}
leftSection={target.kind === "reviewIntegration" ? <IconSettings size={14} /> : <IconEye size={14} />} disabled={isActionDisabled}
onClick={(event) => {
if (isActionDisabled) event.preventDefault();
}}
> >
{t(`action.${target.kind}`)} {t(`action.${target.kind}`)}
</Button> </Button>
)} )}
<Tooltip label={t("action.dismiss")} events={{ hover: true, focus: true, touch: false }}>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("action.dismiss")} onClick={onDismiss}>
<IconEyeOff size={15} />
</ActionIcon>
</Tooltip>
</Group> </Group>
</Group> </Stack>
</Card>
<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) => { const getCandidateTarget = (candidate: ReconciliationCandidate, url: string) => {
if (candidate.representation.signals.ambiguous) { if (candidate.representation.signals.ambiguous) {
if (candidate.nextAction === "reviewIntegration" && candidate.match) { if (candidate.nextAction === "reviewIntegration" && candidate.match) {
const params = new URLSearchParams({ search: getIntegrationName(candidate.match.kind) }); const params = new URLSearchParams({ search: getIntegrationName(candidate.match.kind) });
@@ -389,7 +312,9 @@ const getCandidateTarget = (candidate: ReconciliationCandidate) => {
return { kind: "viewRepresentation" as const, href: "/manage/apps" }; return { kind: "viewRepresentation" as const, href: "/manage/apps" };
} }
if (candidate.state === "newRecognized" && candidate.match) { if (candidate.state === "newRecognized" && candidate.match) {
return { kind: "setupIntegration" as const, integrationKind: candidate.match.kind }; const params = new URLSearchParams({ kind: candidate.match.kind, name: candidate.container.name });
if (url) params.set("url", url);
return { kind: "setupIntegration" as const, href: `/manage/integrations/new?${params.toString()}` };
} }
if (candidate.state === "newApp") return { kind: "createApp" as const }; if (candidate.state === "newApp") return { kind: "createApp" as const };
@@ -412,21 +337,8 @@ const stateColor = (state: ReconciliationCandidate["state"]) => {
return "blue"; return "blue";
}; };
const HealthStatusIcon = ({ status }: { status: ServiceHealth["layers"][number]["status"] }) => { const healthStatusColor = (status: ServiceHealth["layers"][number]["status"]) => {
let color = "gray"; if (["available", "configured", "linked"].includes(status)) return "green";
let icon = <IconMinus size={12} />; if (["missing", "changed", "unused"].includes(status)) return "yellow";
return "gray";
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,10 +1,8 @@
import { headers } from "next/headers";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core"; import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { env } from "@homarr/docker/env"; import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
@@ -18,10 +16,7 @@ export default async function DockerPage() {
notFound(); notFound();
} }
const [{ containers, endpoints, timestamp }, requestHeaders] = await Promise.all([ const { containers, endpoints, timestamp } = await api.docker.getContainers();
api.docker.getContainers(),
headers(),
]);
const tDocker = await getScopedI18n("docker"); const tDocker = await getScopedI18n("docker");
return ( return (
@@ -29,7 +24,7 @@ export default async function DockerPage() {
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title order={1}>{tDocker("title")}</Title> <Title order={1}>{tDocker("title")}</Title>
<DockerReconciliation defaultServerOrigin={extractBaseUrlFromHeaders(requestHeaders)} /> <DockerReconciliation />
<DockerTable initialData={{ containers, endpoints, timestamp }} /> <DockerTable initialData={{ containers, endpoints, timestamp }} />
</Stack> </Stack>
</> </>
@@ -3,16 +3,16 @@
import { Suspense, use } from "react"; import { Suspense, use } from "react";
import { Center, Loader } from "@mantine/core"; import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import type { FallbackProps } from "react-error-boundary";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals"; import { createModal, modalSizeForm } from "@homarr/modals";
import { useModalAction } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import type * as WidgetModalsModule from "@homarr/widgets/modals"; import type * as WidgetModalsModule from "@homarr/widgets/modals";
import type { WidgetEditModalProps } from "@homarr/widgets/modals"; import type { WidgetEditModalProps } from "@homarr/widgets/modals";
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form"; import type * as IntegrationEditModalModule from "~/components/integration/embedded-integration-edit-modal";
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form"; import type { EmbeddedIntegrationEditModalProps } from "~/components/integration/embedded-integration-edit-modal";
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined; let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
@@ -29,38 +29,42 @@ export const loadWidgetEditModal = () => {
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined); export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
let integrationEditFormPromise: Promise<typeof IntegrationEditFormModule> | undefined; let integrationEditModalPromise: Promise<typeof IntegrationEditModalModule> | undefined;
const loadIntegrationEditForm = () => { const loadIntegrationEditModal = () => {
if (integrationEditFormPromise) return integrationEditFormPromise; if (integrationEditModalPromise) return integrationEditModalPromise;
const currentPromise = import("~/components/integration/embedded-integration-edit-form"); const currentPromise = import("~/components/integration/embedded-integration-edit-modal");
integrationEditFormPromise = currentPromise; integrationEditModalPromise = currentPromise;
void currentPromise.catch(() => { void currentPromise.catch(() => {
if (integrationEditFormPromise === currentPromise) integrationEditFormPromise = undefined; if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined;
}); });
return currentPromise; return currentPromise;
}; };
const LazyIntegrationEditFormContent = (props: EmbeddedIntegrationEditFormProps) => { const LazyIntegrationEditModalContent = (props: {
const { EmbeddedIntegrationEditForm } = use(loadIntegrationEditForm()); actions: { closeModal: () => void };
return <EmbeddedIntegrationEditForm {...props} />; innerProps: EmbeddedIntegrationEditModalProps;
}) => {
const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal());
const Component = EmbeddedIntegrationEditModal.component;
return <Component {...props} />;
}; };
const IntegrationEditErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => ( const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>((props) => (
<Center py="xl"> <ErrorBoundary
<WidgetError fallbackRender={({ error, resetErrorBoundary }) => (
error={error} <Center py="xl">
resetErrorBoundary={() => { <WidgetError
integrationEditFormPromise = undefined; error={error}
resetErrorBoundary(); resetErrorBoundary={() => {
}} integrationEditModalPromise = undefined;
/> resetErrorBoundary();
</Center> }}
); />
</Center>
const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => ( )}
<ErrorBoundary FallbackComponent={IntegrationEditErrorFallback}> >
<Suspense <Suspense
fallback={ fallback={
<Center py="xl"> <Center py="xl">
@@ -68,10 +72,15 @@ const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => (
</Center> </Center>
} }
> >
<LazyIntegrationEditFormContent {...props} /> <LazyIntegrationEditModalContent {...props} />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
); )).withOptions({
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: true,
});
interface LazyWidgetEditModalContentProps { interface LazyWidgetEditModalContentProps {
actions: { closeModal: () => void }; actions: { closeModal: () => void };
@@ -81,12 +90,17 @@ interface LazyWidgetEditModalContentProps {
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal()); const { WidgetEditModal } = use(loadWidgetEditModal());
const Component = WidgetEditModal.component; const Component = WidgetEditModal.component;
const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal);
return ( return (
<Component <Component
{...props} {...props}
innerProps={{ innerProps={{
...props.innerProps, ...props.innerProps,
integrationEditForm: LazyIntegrationEditForm, onEditIntegration: (integrationId) =>
openIntegrationEditModal({
integrationId,
onSuccess: props.innerProps.onIntegrationSaved,
}),
}} }}
/> />
); );
@@ -1,140 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Button, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getCustomWidgetSourceSetups } from "@homarr/custom-widgets/core";
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
import {
createCustomWidgetSourceSetupValues,
CustomWidgetSourceSetupPanel,
isCustomWidgetSourceSetupReady,
} from "@homarr/custom-widgets/workbench";
import type {
CustomWidgetSourceSetupMessages,
CustomWidgetSourceSetupValue,
} from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface CustomWidgetSourceSetupDialogProps {
definitionId: string;
opened: boolean;
onClose(): void;
}
export function CustomWidgetSourceSetupDialog({
definitionId,
opened,
onClose,
}: CustomWidgetSourceSetupDialogProps) {
const t = useScopedI18n("customWidget");
const router = useRouter();
const utils = clientApi.useUtils();
const definitionQuery = clientApi.customWidget.get.useQuery({ id: definitionId }, { enabled: opened });
const configureMutation = clientApi.customWidget.sourceConfigure.useMutation();
const [saving, setSaving] = useState(false);
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
const setups = useMemo(
() => getCustomWidgetSourceSetups(definitionQuery.data?.sources ?? {}, definitionQuery.data?.secrets ?? []),
[definitionQuery.data],
);
useEffect(() => {
if (opened && definitionQuery.data) setValues(createCustomWidgetSourceSetupValues(setups));
}, [definitionQuery.data, opened, setups]);
const messages: CustomWidgetSourceSetupMessages = {
title: t("importReview.sourceSetup.title"),
description: t("sourceSetupDialog.description"),
suggestedUrl: t("importReview.sourceSetup.suggestedUrl"),
baseUrl: t("workbench.sources.baseUrl"),
networkScope: t("workbench.sources.networkScope"),
authentication: t("workbench.sources.authentication"),
confirmUrl: t("importReview.sourceSetup.confirmUrl"),
ready: t("importReview.sourceSetup.ready"),
needsUrl: t("importReview.sourceSetup.needsUrl"),
credentialsMissing: t("importReview.sourceSetup.credentialsMissing"),
credentialsOptional: t("sourceSetupDialog.credentialsRequired"),
configured: t("workbench.sources.configured"),
secret: (kind) => t(`importReview.credentials.field.${kind}`),
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
};
const save = async () => {
if (!isCustomWidgetSourceSetupReady(setups, values) || saving) return;
setSaving(true);
try {
for (const setup of setups) {
const value = values[setup.sourceId];
if (!value) continue;
const secrets = Object.entries(value.secrets).flatMap(([kind, secret]) =>
secret?.trim() ? [{ sourceId: setup.sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
);
await configureMutation.mutateAsync({
definitionId,
sourceId: setup.sourceId,
baseUrl: value.baseUrl,
networkScope: value.networkScope,
secrets,
});
}
showSuccessNotification({
title: t("action.configureSources"),
message: t("notification.sourcesConfigured"),
});
await utils.customWidget.get.invalidate({ id: definitionId });
await utils.customWidget.list.invalidate();
await utils.customWidget.available.invalidate();
await utils.widget.customApi.getData.invalidate();
await revalidatePathActionAsync("/manage/custom-widgets");
router.refresh();
onClose();
} catch (error) {
showErrorNotification({
title: t("action.configureSources"),
message: error instanceof Error ? error.message : t("notification.sourceConfigurationError"),
});
} finally {
setSaving(false);
}
};
return (
<Modal opened={opened} onClose={onClose} title={t("sourceSetupDialog.title")} size="lg">
{definitionQuery.isPending ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : definitionQuery.error ? (
<Text c="red" size="sm">
{definitionQuery.error.message}
</Text>
) : (
<Stack gap="md">
<CustomWidgetSourceSetupPanel
setups={setups}
values={values}
messages={messages}
onChange={(sourceId, value) => setValues((current) => ({ ...current, [sourceId]: value }))}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={saving}>
{t("sourceSetupDialog.cancel")}
</Button>
<Button
onClick={() => void save()}
loading={saving}
disabled={!isCustomWidgetSourceSetupReady(setups, values)}
>
{t("sourceSetupDialog.save")}
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
@@ -1,90 +0,0 @@
"use client";
import { useImperativeHandle, useRef } from "react";
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
import type { EditIntegrationFormHandle } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
export type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
export const EmbeddedIntegrationEditForm = ({
integrationId,
handleRef,
onSuccess,
}: EmbeddedIntegrationEditFormProps) => {
const t = useI18n();
const integrationFormRef = useRef<EditIntegrationFormHandle>(null);
const {
data: integration,
isPending,
isError,
refetch,
} = clientApi.integration.byId.useQuery({
id: integrationId,
});
useImperativeHandle(
handleRef,
() => ({
submitIfDirty: async () => {
if (!integrationFormRef.current?.isDirty()) {
return true;
}
return integrationFormRef.current.submit();
},
}),
[],
);
if (isPending) {
return (
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
<Skeleton height={36} width="60%" />
<Skeleton height={60} />
<Skeleton height={120} />
</Stack>
);
}
if (isError || !integration) {
return (
<Alert color="red" title={t("common.error")}>
<Stack gap="sm">
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
return (
<Stack>
<Group gap="sm" wrap="nowrap">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{getIntegrationName(integration.kind)}
</Text>
</Stack>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.integration.propagationNotice")}
</Alert>
<EditIntegrationForm integration={integration} hideButtons formRef={integrationFormRef} onSuccess={onSuccess} />
</Stack>
);
};
@@ -0,0 +1,86 @@
"use client";
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
export interface EmbeddedIntegrationEditModalProps {
integrationId: string;
onSuccess?: () => void;
}
export const EmbeddedIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
({ actions, innerProps }) => {
const t = useI18n();
const {
data: integration,
isPending,
isError,
refetch,
} = clientApi.integration.byId.useQuery({
id: innerProps.integrationId,
});
if (isPending) {
return (
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
<Skeleton height={36} width="60%" />
<Skeleton height={60} />
<Skeleton height={120} />
</Stack>
);
}
if (isError || !integration) {
return (
<Alert color="red" title={t("common.error")}>
<Stack gap="sm">
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
return (
<Stack>
<Group gap="sm" wrap="nowrap">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{getIntegrationName(integration.kind)}
</Text>
</Stack>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.integration.propagationNotice")}
</Alert>
<EditIntegrationForm
integration={integration}
embedded
onSuccess={() => {
innerProps.onSuccess?.();
actions.closeModal();
}}
/>
</Stack>
);
},
).withOptions({
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: true,
});
@@ -1,13 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core"; import { Group, ScrollArea, Title } from "@mantine/core";
import { IconArrowLeft, IconX } from "@tabler/icons-react"; import { IconArrowLeft } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions"; import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
@@ -20,15 +19,11 @@ interface IntegrationSelectModalProps {
enableMockIntegration?: boolean; enableMockIntegration?: boolean;
allowedKinds?: readonly IntegrationKind[]; allowedKinds?: readonly IntegrationKind[];
completionBoardId?: string; completionBoardId?: string;
initialKind?: IntegrationKind;
initialUrl?: string;
initialName?: string;
} }
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => { export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
const t = useI18n(); const [step, setStep] = useState<"select" | "form">("select");
const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select"); const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(null);
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? null);
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal); const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
const handleSelect = (kind: IntegrationKind) => { const handleSelect = (kind: IntegrationKind) => {
@@ -40,7 +35,6 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
setStep("select"); setStep("select");
setSelectedKind(null); setSelectedKind(null);
}; };
const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
const handleSuccess = (result?: CreatedIntegrationResult) => { const handleSuccess = (result?: CreatedIntegrationResult) => {
actions.closeModal(); actions.closeModal();
@@ -52,29 +46,14 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
}; };
if (step === "form" && selectedKind) { if (step === "form" && selectedKind) {
let HeaderIcon = IconArrowLeft;
let headerLabel = t("common.action.previous");
if (innerProps.initialKind) {
HeaderIcon = IconX;
headerLabel = t("common.action.close");
}
return ( return (
<ScrollArea.Autosize mah="80vh"> <ScrollArea.Autosize mah="80vh">
<UnstyledButton mb="md" onClick={handleFormBack} aria-label={headerLabel}> <Group gap="xs" mb="md" style={{ cursor: "pointer" }} onClick={handleBack}>
<Group gap="xs"> <IconArrowLeft size={18} />
<HeaderIcon size={18} /> <IntegrationAvatar kind={selectedKind} size="sm" />
<IntegrationAvatar kind={selectedKind} size="sm" /> <Title order={4}>{getIntegrationName(selectedKind)}</Title>
<Title order={4}>{getIntegrationName(selectedKind)}</Title> </Group>
</Group> <NewIntegrationForm kind={selectedKind} onSuccess={handleSuccess} onCancel={handleBack} />
</UnstyledButton>
<NewIntegrationForm
kind={selectedKind}
initialUrl={innerProps.initialUrl}
initialName={innerProps.initialName}
onSuccess={handleSuccess}
onCancel={handleFormBack}
/>
</ScrollArea.Autosize> </ScrollArea.Autosize>
); );
} }
@@ -37,7 +37,6 @@ export interface ManageCollectionItemProps {
metadata?: ReactNode; metadata?: ReactNode;
badges?: ReactNode; badges?: ReactNode;
actions?: ReactNode; actions?: ReactNode;
actionsAlignment?: "flex-start" | "center";
} }
export const ManageCollectionItem = ({ export const ManageCollectionItem = ({
@@ -47,7 +46,6 @@ export const ManageCollectionItem = ({
metadata, metadata,
badges, badges,
actions, actions,
actionsAlignment = "flex-start",
}: ManageCollectionItemProps) => ( }: ManageCollectionItemProps) => (
<Paper component="li" p="sm" withBorder radius="md"> <Paper component="li" p="sm" withBorder radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm"> <Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
@@ -61,7 +59,7 @@ export const ManageCollectionItem = ({
{metadata} {metadata}
</Stack> </Stack>
{actions && ( {actions && (
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0, alignSelf: actionsAlignment }}> <Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
{actions} {actions}
</Group> </Group>
)} )}
@@ -23,11 +23,6 @@ const dockerContainerTargetSchema = z.object({
}); });
const dockerContainerTargetsSchema = z.array(dockerContainerTargetSchema).min(1).max(100); 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({ export const dockerRouter = createTRPCRouter({
reconcileServices: permissionRequiredProcedure reconcileServices: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
@@ -70,14 +65,12 @@ export const dockerRouter = createTRPCRouter({
.meta({ .meta({
mcp: { mcp: {
enabled: true, enabled: true,
description: description: "List all Docker containers with their state, image, CPU/memory usage, and ports",
"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()) .concat(dockerMiddleware())
.input(z.object({ endpointIds: z.array(z.string().min(1)).max(100).optional() }).optional()) .query(async () => {
.query(async ({ input }) => { const innerHandler = dockerContainersRequestHandler.handler({});
const innerHandler = dockerContainersRequestHandler.handler(normalizeGetContainersInput(input));
const result = await innerHandler.getDataAsync(); const result = await innerHandler.getDataAsync();
const { data, timestamp } = result; const { data, timestamp } = result;
+1 -1
View File
@@ -124,7 +124,7 @@ export const integrationDefs = {
readarr: { readarr: {
name: "Readarr", name: "Readarr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/png/readarr.png", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg",
category: ["calendar"], category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/readarr"), documentationUrl: createDocumentationLink("/docs/integrations/readarr"),
defaultPort: 8787, defaultPort: 8787,
@@ -20,9 +20,7 @@ const sessionSchema = z.object({
SeriesName: z.string().nullish(), SeriesName: z.string().nullish(),
Name: z.string().nullish(), Name: z.string().nullish(),
SeasonName: z.string().nullish(), SeasonName: z.string().nullish(),
ParentIndexNumber: z.number().nullish(),
EpisodeTitle: z.string().nullish(), EpisodeTitle: z.string().nullish(),
IndexNumber: z.number().nullish(),
Album: z.string().nullish(), Album: z.string().nullish(),
EpisodeCount: z.number().nullish(), EpisodeCount: z.number().nullish(),
}) })
@@ -112,15 +110,11 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null; let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) { if (sessionInfo.NowPlayingItem) {
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
currentlyPlaying = { currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "", name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "", seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null, episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
albumName: sessionInfo.NowPlayingItem.Album ?? "", albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: { playback: {
@@ -10,9 +10,7 @@ export interface StreamSession {
type: "audio" | "video" | "tv" | "movie"; type: "audio" | "video" | "tv" | "movie";
name: string; name: string;
seasonName: string | undefined; seasonName: string | undefined;
seasonNumber?: number | null;
episodeName?: string | null; episodeName?: string | null;
episodeNumber?: number | null;
albumName?: string | null; albumName?: string | null;
episodeCount?: number | null; episodeCount?: number | null;
playback: { playback: {
@@ -1,65 +0,0 @@
import { describe, expect, test } from "vitest";
import { extractHost, parseLocation } from "./jellyfin-integration";
describe("extractHost", () => {
test("strips the port from an IPv4 endpoint", () => {
expect(extractHost("192.168.1.100:54321")).toBe("192.168.1.100");
});
test("returns an IPv4 address unchanged when there's no port", () => {
expect(extractHost("8.8.8.8")).toBe("8.8.8.8");
});
test("strips brackets and the port from a bracketed IPv6 endpoint", () => {
expect(extractHost("[::1]:8096")).toBe("::1");
});
test("returns a bare IPv6 address unchanged when there's no port", () => {
expect(extractHost("::1")).toBe("::1");
});
});
describe("parseLocation", () => {
test("returns null when there's no endpoint to classify", () => {
expect(parseLocation(null)).toBeNull();
expect(parseLocation(undefined)).toBeNull();
expect(parseLocation("")).toBeNull();
});
test("returns null for a value that isn't an IP", () => {
expect(parseLocation("not-an-ip:1234")).toBeNull();
});
test("classifies private IPv4 ranges as lan", () => {
expect(parseLocation("192.168.1.100:54321")).toBe("lan");
expect(parseLocation("10.0.0.5:1234")).toBe("lan");
expect(parseLocation("172.16.5.5:1234")).toBe("lan");
expect(parseLocation("127.0.0.1:8096")).toBe("lan");
});
test("classifies a public IPv4 address as wan", () => {
expect(parseLocation("8.8.8.8:1234")).toBe("wan");
});
test("classifies IPv6 loopback and unique-local addresses as lan", () => {
expect(parseLocation("[::1]:8096")).toBe("lan");
expect(parseLocation("[fd12:3456:789a::1]:8096")).toBe("lan");
});
test("classifies a public IPv6 address as wan", () => {
expect(parseLocation("[2606:4700:4700::1111]:8096")).toBe("wan");
});
test("unwraps an IPv4-mapped IPv6 address before classifying it", () => {
expect(parseLocation("[::ffff:192.168.1.5]:8096")).toBe("lan");
expect(parseLocation("[::ffff:8.8.8.8]:8096")).toBe("wan");
});
test("classifies link-local addresses as lan", () => {
// Link-local traffic can't traverse a router, so it's always LAN even though
// it isn't a private range in the traditional (RFC 1918 / ULA) sense.
expect(parseLocation("169.254.1.5:8096")).toBe("lan");
expect(parseLocation("[fe80::1]:8096")).toBe("lan");
});
});
@@ -1,4 +1,3 @@
import { BlockList, isIP } from "node:net";
import { Jellyfin } from "@jellyfin/sdk"; import { Jellyfin } from "@jellyfin/sdk";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
@@ -22,42 +21,6 @@ function ticksToMs(ticks: number | null | undefined): number | null {
return ticks ? Math.round(ticks / 10_000) : null; 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]) @HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({ private readonly jellyfin: Jellyfin = new Jellyfin({
@@ -93,27 +56,15 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
if (sessionInfo.NowPlayingItem) { if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks); const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks); 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 = const bitrateBps =
sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null;
sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ??
(mediaStreamsBitrateBps || null);
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null; const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
currentlyPlaying = { currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "", name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "", seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null, episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
albumName: sessionInfo.NowPlayingItem.Album ?? "", albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: { playback: {
@@ -121,7 +72,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
positionMs, positionMs,
durationMs, durationMs,
}, },
location: parseLocation(sessionInfo.RemoteEndPoint), location: null,
metadata: { metadata: {
video: { video: {
resolution: resolution:
@@ -26,9 +26,7 @@ const sessions: StreamSession[] = [
type: "video", type: "video",
name: "Breaking Bad", name: "Breaking Bad",
seasonName: "Season 5", seasonName: "Season 5",
seasonNumber: 5,
episodeName: "Ozymandias", episodeName: "Ozymandias",
episodeNumber: 14,
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null }, playback: { state: null, positionMs: null, durationMs: null },
@@ -60,9 +58,7 @@ const sessions: StreamSession[] = [
type: "video", type: "video",
name: "Severance", name: "Severance",
seasonName: "Season 2", seasonName: "Season 2",
seasonNumber: 2,
episodeName: "The We We Are", episodeName: "The We We Are",
episodeNumber: 10,
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null }, playback: { state: null, positionMs: null, durationMs: null },
+1 -2
View File
@@ -80,9 +80,8 @@ interface Session {
$: { $: {
grandparentTitle?: string; grandparentTitle?: string;
parentTitle?: string; parentTitle?: string;
parentIndex?: string;
title?: string; title?: string;
index?: string; index?: number;
type: string; type: string;
live?: string; live?: string;
viewOffset?: string; viewOffset?: string;
@@ -115,11 +115,9 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type), type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type),
name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown", name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown",
seasonName: mediaElement.$.parentTitle, seasonName: mediaElement.$.parentTitle,
seasonNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.parentIndex) : null, episodeName: mediaElement.$.title ?? 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, albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
episodeCount: parseOptionalNumber(mediaElement.$.index), episodeCount: mediaElement.$.index ?? null,
playback: { playback: {
state: playbackState, state: playbackState,
positionMs, positionMs,
@@ -8,35 +8,35 @@ import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals"; import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { appHrefSchema } from "@homarr/validation/app";
interface AddDockerAppToHomarrProps { interface AddDockerAppToHomarrProps {
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"]; selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
initialUrls?: (string | null)[]; initialUrls?: string[];
} }
const formSchema = z.object({
containerUrls: z.array(appHrefSchema),
});
export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(({ actions, innerProps }) => { export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(({ actions, innerProps }) => {
const t = useI18n(); const t = useI18n();
const form = useZodForm(formSchema, { const form = useZodForm(
initialValues: { z.object({
containerUrls: innerProps.selectedContainers.map((container, index) => { containerUrls: z.array(z.string().url().nullable()),
const initialUrl = innerProps.initialUrls?.[index]; }),
if (initialUrl !== undefined) return initialUrl; {
initialValues: {
containerUrls: innerProps.selectedContainers.map((container, index) => {
const initialUrl = innerProps.initialUrls?.[index];
if (initialUrl) return initialUrl;
const candidates = buildDockerServiceUrlCandidates({ const candidates = buildDockerServiceUrlCandidates({
containerName: container.name, containerName: container.name,
endpointHost: container.host, endpointHost: container.host,
ports: container.ports, ports: container.ports,
}).filter(({ url }) => url.length > 0); }).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({ const { mutate, isPending } = clientApi.app.createMany.useMutation({
onSuccess() { onSuccess() {
actions.closeModal(); actions.closeModal();
@@ -52,13 +52,13 @@ export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(
}); });
}, },
}); });
const handleSubmit = ({ containerUrls }: z.infer<typeof formSchema>) => { const handleSubmit = () => {
mutate( mutate(
innerProps.selectedContainers.map((container, index) => ({ innerProps.selectedContainers.map((container, index) => ({
name: container.name, name: container.name,
iconUrl: container.iconUrl, iconUrl: container.iconUrl,
description: null, description: null,
href: containerUrls[index] || null, href: form.values.containerUrls[index] ?? null,
pingUrl: null, pingUrl: null,
})), })),
); );
@@ -68,31 +68,28 @@ export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(
<LoadingOverlay visible={isPending} /> <LoadingOverlay visible={isPending} />
<Stack> <Stack>
<List spacing={"xs"}> <List spacing={"xs"}>
{innerProps.selectedContainers.map((container, index) => { {innerProps.selectedContainers.map((container, index) => (
const inputProps = form.getInputProps(`containerUrls.${index}`); <List.Item
return ( styles={{ itemWrapper: { width: "100%" }, itemLabel: { flex: 1 } }}
<List.Item icon={
styles={{ itemWrapper: { width: "100%" }, itemLabel: { flex: 1 } }} <Avatar
icon={ variant="outline"
<Avatar radius={container.iconUrl ? "sm" : "md"}
variant="outline" size={30}
radius={container.iconUrl ? "sm" : "md"} styles={{ image: { objectFit: "contain" } }}
size={30} src={container.iconUrl}
styles={{ image: { objectFit: "contain" } }} >
src={container.iconUrl} {container.name.at(0)?.toUpperCase()}
> </Avatar>
{container.name.at(0)?.toUpperCase()} }
</Avatar> key={container.id}
} >
key={container.id} <Group justify="space-between" wrap={"nowrap"}>
> <Text lineClamp={1}>{container.name}</Text>
<Group justify="space-between" wrap={"nowrap"}> <TextInput {...form.getInputProps(`containerUrls.${index}`)} />
<Text lineClamp={1}>{container.name}</Text> </Group>
<TextInput {...inputProps} value={inputProps.value ?? ""} /> </List.Item>
</Group> ))}
</List.Item>
);
})}
</List> </List>
<Group justify="end"> <Group justify="end">
<Button onClick={actions.closeModal} variant="light" px={"xl"}> <Button onClick={actions.closeModal} variant="light" px={"xl"}>
-2
View File
@@ -1,5 +1,3 @@
export { OnboardingAuthShell } from "./onboarding-auth-shell"; export { OnboardingAuthShell } from "./onboarding-auth-shell";
export { OnboardingStudio } from "./onboarding-studio"; export { OnboardingStudio } from "./onboarding-studio";
export { normalizeServiceUrl } from "./discovery-selection";
export { ServiceUrlTemplate } from "./service-url-template";
export type { OnboardingEnvironment, OnboardingStudioProps } from "./types"; export type { OnboardingEnvironment, OnboardingStudioProps } from "./types";
@@ -358,7 +358,6 @@
display: flex; display: flex;
width: max-content; width: max-content;
min-width: 100%; min-width: 100%;
margin: 0;
padding: 0.2rem; padding: 0.2rem;
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
background: var(--mantine-color-default); background: var(--mantine-color-default);
@@ -1,106 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useRef, useState } from "react";
import { FloatingIndicator, Stack, TextInput, UnstyledButton } from "@mantine/core";
import type { UrlTemplateMode } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useOnboardingSounds } from "./use-onboarding-sounds";
import classes from "./onboarding-studio.module.css";
interface FloatingControlProps<T extends string> {
value: T;
onChange: (value: T) => void;
options: readonly { value: T; label: ReactNode }[];
ariaLabel: string;
}
export const OnboardingFloatingControl = <T extends string>({
value,
onChange,
options,
ariaLabel,
}: FloatingControlProps<T>) => {
const [rootRef, setRootRef] = useState<HTMLFieldSetElement | null>(null);
const controlRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const sounds = useOnboardingSounds();
return (
<div className={classes.floatingControlScroller}>
<fieldset ref={setRootRef} className={classes.floatingControlRoot} aria-label={ariaLabel}>
<FloatingIndicator
target={controlRefs.current[value] ?? null}
parent={rootRef}
className={classes.floatingControlIndicator}
/>
{options.map((option) => {
const selected = value === option.value;
return (
<UnstyledButton
key={option.value}
ref={(node) => {
controlRefs.current[option.value] = node;
}}
type="button"
className={classes.floatingControl}
data-active={selected}
aria-pressed={selected}
onClick={() => {
sounds.click();
onChange(option.value);
}}
>
{option.label}
</UnstyledButton>
);
})}
</fieldset>
</div>
);
};
interface ServiceUrlTemplateProps {
serverOrigin: string;
onServerOriginChange: (value: string) => void;
mode: UrlTemplateMode;
onModeChange: (value: UrlTemplateMode) => void;
readOnly?: boolean;
required?: boolean;
}
export const ServiceUrlTemplate = ({
serverOrigin,
onServerOriginChange,
mode,
onModeChange,
readOnly,
required,
}: ServiceUrlTemplateProps) => {
const t = useScopedI18n("common.serviceUrlTemplate");
return (
<Stack gap="sm">
<TextInput
label={t("serverTitle")}
placeholder="home.lan · 192.168.1.10 · https://homarr.example.com"
value={serverOrigin}
onChange={(event) => onServerOriginChange(event.currentTarget.value)}
readOnly={readOnly}
required={required}
withAsterisk={required}
/>
<OnboardingFloatingControl
ariaLabel={t("urlModeLabel")}
value={mode}
onChange={onModeChange}
options={[
{ value: "hostPort", label: t("hostPort") },
{ value: "subdomain", label: t("subdomain") },
{ value: "path", label: t("path") },
]}
/>
</Stack>
);
};
+68 -6
View File
@@ -15,6 +15,7 @@ import {
ColorSwatch, ColorSwatch,
CopyButton, CopyButton,
Fieldset, Fieldset,
FloatingIndicator,
Group, Group,
Paper, Paper,
PasswordInput, PasswordInput,
@@ -98,7 +99,6 @@ import type { OnboardingStudioProps } from "./types";
import { getBoardValidationErrors } from "./board-validation"; import { getBoardValidationErrors } from "./board-validation";
import { OnboardingBackdrop } from "./onboarding-backdrop"; import { OnboardingBackdrop } from "./onboarding-backdrop";
import { OnboardingWordmark } from "./onboarding-wordmark"; import { OnboardingWordmark } from "./onboarding-wordmark";
import { OnboardingFloatingControl, ServiceUrlTemplate } from "./service-url-template";
import { useOnboardingSounds } from "./use-onboarding-sounds"; import { useOnboardingSounds } from "./use-onboarding-sounds";
import { import {
normalizeServiceUrl, normalizeServiceUrl,
@@ -152,6 +152,57 @@ const initialSecondaryColor = "#fd7e14";
const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = []; const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = [];
const emptyDiscoveredApps: DockerDiscoveryData["apps"] = []; 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) => { export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingStudioProps) => {
const t = useScopedI18n("init.studio"); const t = useScopedI18n("init.studio");
const tCommon = useScopedI18n("common.action"); const tCommon = useScopedI18n("common.action");
@@ -873,13 +924,24 @@ const Essentials = (props: StudioSectionProps) => {
<Alert variant="light" icon={<IconInfoCircle size={18} />} my="xs"> <Alert variant="light" icon={<IconInfoCircle size={18} />} my="xs">
{t("serverOriginHelp")} {t("serverOriginHelp")}
</Alert> </Alert>
<ServiceUrlTemplate <TextInput
serverOrigin={props.serverOrigin} label={t("serverTitle")}
onServerOriginChange={props.setServerOrigin} placeholder="home.lan · 192.168.1.10 · https://homarr.example.com"
mode={props.urlMode} value={props.serverOrigin}
onModeChange={props.setUrlMode} onChange={(event) => props.setServerOrigin(event.currentTarget.value)}
readOnly={!isHydrated} readOnly={!isHydrated}
required 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 ? ( {props.serverOrigin ? (
<Code block>{buildIntegrationUrl("sonarr", props.serverOrigin, props.urlMode)}</Code> <Code block>{buildIntegrationUrl("sonarr", props.serverOrigin, props.urlMode)}</Code>
+17 -32
View File
@@ -195,24 +195,9 @@ const mockContainers: {
}, },
]; ];
const demoDockerEndpoint = {
id: "demo",
name: "Demo Docker",
status: "available",
kind: "docker",
transport: "socket",
capabilities: ["inventory"],
source: "default",
scope: "admin",
} satisfies DockerEndpointStatus;
export const dockerContainersRequestHandler = createWidgetRequestHandler({ export const dockerContainersRequestHandler = createWidgetRequestHandler({
async requestAsync({ endpointIds }: { endpointIds?: string[] }) { async requestAsync() {
if (isDemoMode()) { if (isDemoMode()) {
const includesDemoEndpoint =
endpointIds === undefined || endpointIds.length === 0 || endpointIds.includes("demo");
if (!includesDemoEndpoint) return { containers: [], endpoints: [] };
return { return {
containers: mockContainers.map((container) => ({ containers: mockContainers.map((container) => ({
...container, ...container,
@@ -220,10 +205,21 @@ export const dockerContainersRequestHandler = createWidgetRequestHandler({
endpointName: "Demo Docker", endpointName: "Demo Docker",
resourceId: `demo:${container.id}`, resourceId: `demo:${container.id}`,
})), })),
endpoints: [demoDockerEndpoint], endpoints: [
{
id: "demo",
name: "Demo Docker",
status: "available",
kind: "docker",
transport: "socket",
capabilities: ["inventory"],
source: "default",
scope: "admin",
},
] satisfies DockerEndpointStatus[],
}; };
} }
return await getContainersWithStatsAsync(dockerWidgetEndpointTimeoutMs, endpointIds); return await getContainersWithStatsAsync();
}, },
}); });
@@ -308,8 +304,6 @@ export const streamContainerLogsAsync = async (
}; };
export const getDockerEndpointsAsync = (): DockerEndpointStatus[] => { export const getDockerEndpointsAsync = (): DockerEndpointStatus[] => {
if (isDemoMode()) return [demoDockerEndpoint];
const dockerInstances = DockerSingleton.getInstances(); const dockerInstances = DockerSingleton.getInstances();
const initializationFailures = DockerSingleton.getInitializationFailures(); const initializationFailures = DockerSingleton.getInitializationFailures();
@@ -337,18 +331,9 @@ export const getDockerEndpointsAsync = (): DockerEndpointStatus[] => {
]; ];
}; };
export async function getContainersWithStatsAsync( export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpointTimeoutMs) {
timeoutMs = dockerWidgetEndpointTimeoutMs, const dockerInstances = DockerSingleton.getInstances();
endpointIds: string[] = [], const initializationFailures = DockerSingleton.getInitializationFailures();
) {
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( const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host, endpointId, endpointName }) => { dockerInstances.map(async ({ instance, host, endpointId, endpointName }) => {
const instanceContainers = await withDockerTimeoutAsync( const instanceContainers = await withDockerTimeoutAsync(
@@ -7,8 +7,6 @@ import { DockerSingleton } from "@homarr/docker";
import { import {
calculateCpuUsage, calculateCpuUsage,
calculateMemoryUsage, calculateMemoryUsage,
dockerContainersRequestHandler,
getDockerEndpointsAsync,
getContainersWithStatsAsync, getContainersWithStatsAsync,
hasDockerEndpointCapability, hasDockerEndpointCapability,
} from "../docker"; } from "../docker";
@@ -129,31 +127,6 @@ describe("calculateMemoryUsage", () => {
}); });
describe("getContainersWithStatsAsync", () => { 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 () => { test("marks a timed-out endpoint unavailable without blocking healthy endpoints", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
@@ -238,26 +211,14 @@ describe("getContainersWithStatsAsync", () => {
}); });
}); });
test("demo mode advertises and filters the same inventory-only endpoint", async () => { test("demo capabilities match the advertised inventory-only endpoint", () => {
const previousDemoMode = process.env.DEMO_MODE; const previousDemoMode = process.env.DEMO_MODE;
process.env.DEMO_MODE = "true"; process.env.DEMO_MODE = "true";
try { 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", "inventory")).toBe(true);
expect(hasDockerEndpointCapability("demo", "logs")).toBe(false); expect(hasDockerEndpointCapability("demo", "logs")).toBe(false);
expect(hasDockerEndpointCapability("other", "inventory")).toBe(false); expect(hasDockerEndpointCapability("other", "inventory")).toBe(false);
} finally { } finally {
dockerContainersRequestHandler.invalidateCache();
if (previousDemoMode === undefined) delete process.env.DEMO_MODE; if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
else process.env.DEMO_MODE = previousDemoMode; else process.env.DEMO_MODE = previousDemoMode;
} }
+28 -30
View File
@@ -66,10 +66,15 @@
"themeAuto": "System", "themeAuto": "System",
"themeLight": "Light", "themeLight": "Light",
"themeDark": "Dark", "themeDark": "Dark",
"serverTitle": "Usual server address",
"serverOriginHelp": "Homarr uses this address to prefill detected service URLs. You can still edit every service individually.", "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.", "serverDescription": "This is a shortcut, not a restriction. Every detected service remains editable.",
"serverOrigin": "Domain, IP, or reverse-proxy path", "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.", "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", "analytics": "Share anonymous product analytics",
"analyticsDescription": "Helps Homarr improve feature reliability. No service credentials or dashboard content are sent." "analyticsDescription": "Helps Homarr improve feature reliability. No service credentials or dashboard content are sent."
}, },
@@ -896,7 +901,7 @@
"migrationDescription": "This v1 custom widget is preserved but cannot run until it is migrated to Custom JSX v2.", "migrationDescription": "This v1 custom widget is preserved but cannot run until it is migrated to Custom JSX v2.",
"migrationInstructions": "Open Actions to copy or export the original v1 definition for manual recreation. You can also use the migration prompt with an LLM. Existing board placements keep working after migration.", "migrationInstructions": "Open Actions to copy or export the original v1 definition for manual recreation. You can also use the migration prompt with an LLM. Existing board placements keep working after migration.",
"missingCredentials": "{count, plural, one {Credential missing} other {# credentials missing}}", "missingCredentials": "{count, plural, one {Credential missing} other {# credentials missing}}",
"missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Select this badge to configure the URL and encrypted values." "missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Edit it to configure the encrypted values."
}, },
"create": { "create": {
"title": "Create Custom Widget", "title": "Create Custom Widget",
@@ -929,7 +934,6 @@
"downloadSkill": "View Custom Widget Skill", "downloadSkill": "View Custom Widget Skill",
"publishWorkshop": "Publish to Workshop", "publishWorkshop": "Publish to Workshop",
"migrate": "Migrate Custom Widget", "migrate": "Migrate Custom Widget",
"configureSources": "Configure sources",
"copyLegacyDefinition": "Copy original widget content", "copyLegacyDefinition": "Copy original widget content",
"exportLegacyDefinition": "Export original widget content", "exportLegacyDefinition": "Export original widget content",
"copyMigrationPrompt": "Copy LLM migration prompt", "copyMigrationPrompt": "Copy LLM migration prompt",
@@ -943,13 +947,6 @@
"removeItem": "Remove item", "removeItem": "Remove item",
"addBar": "Add Bar" "addBar": "Add Bar"
}, },
"sourceSetupDialog": {
"title": "Configure widget sources",
"description": "Review where this widget connects and add or replace its encrypted credentials.",
"credentialsRequired": "Requests will fail until the required credentials are added.",
"cancel": "Cancel",
"save": "Save configuration"
},
"field": { "field": {
"name": "Name", "name": "Name",
"description": "Description", "description": "Description",
@@ -1301,8 +1298,6 @@
"legacyDefinitionCopyError": "Failed to copy the original widget content.", "legacyDefinitionCopyError": "Failed to copy the original widget content.",
"migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, copy its single JSON response, then choose Paste migrated widget here.", "migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, copy its single JSON response, then choose Paste migrated widget here.",
"migrationPromptCopyError": "Failed to copy the migration prompt.", "migrationPromptCopyError": "Failed to copy the migration prompt.",
"sourcesConfigured": "Widget sources and credentials saved.",
"sourceConfigurationError": "Failed to configure widget sources.",
"aiPromptCopied": "AI prompt copied to clipboard", "aiPromptCopied": "AI prompt copied to clipboard",
"aiPromptCopiedWithCount": "AI prompt copied to clipboard ({count, number} characters).", "aiPromptCopiedWithCount": "AI prompt copied to clipboard ({count, number} characters).",
"aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.", "aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.",
@@ -2132,13 +2127,6 @@
"undo": "Undo" "undo": "Undo"
}, },
"here": "here", "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": { "iconPicker": {
"label": "Icon URL", "label": "Icon URL",
"description": "Search the library, paste an image URL, or upload a file.", "description": "Search the library, paste an image URL, or upload a file.",
@@ -3739,11 +3727,6 @@
"name": "Docker stats", "name": "Docker stats",
"description": "Stats of your containers (This widget can only be added with administrator privileges)", "description": "Stats of your containers (This widget can only be added with administrator privileges)",
"option": { "option": {
"endpointIds": {
"label": "Docker environments",
"description": "Leave empty to show containers from every Docker environment",
"placeholder": "All Docker environments"
},
"columns": { "columns": {
"label": "Columns to show" "label": "Columns to show"
}, },
@@ -4137,7 +4120,6 @@
"transcoding": "Transcoding", "transcoding": "Transcoding",
"paused": "Paused", "paused": "Paused",
"remaining": "{minutes} min left", "remaining": "{minutes} min left",
"seasonEpisode": "S{season} E{episode}",
"metadata": { "metadata": {
"title": "Stats for nerds", "title": "Stats for nerds",
"video": { "video": {
@@ -5692,7 +5674,6 @@
}, },
"dynamicSelect": { "dynamicSelect": {
"noResults": "No results found", "noResults": "No results found",
"loading": "Loading...",
"loadError": "Failed to load options" "loadError": "Failed to load options"
}, },
"assistant": { "assistant": {
@@ -7080,18 +7061,23 @@
"docker": { "docker": {
"title": "Containers", "title": "Containers",
"reconciliation": { "reconciliation": {
"title": "Assisted Docker setup", "title": "Service setup",
"suggestions": "{count, plural, =0 {No suggestions} one {# suggestion} other {# suggestions}}", "description": "Docker services are compared with your apps and integrations. Review every suggestion before Homarr changes anything.",
"attention": "{count} need attention", "attention": "{count} need attention",
"endpointUnavailable": "Some services could not be checked because these endpoints are unavailable: {names}", "endpointUnavailable": "Some services could not be checked because these endpoints are unavailable: {names}",
"empty": "No visible Docker services were found.", "empty": "No visible Docker services were found.",
"emptyFilter": "No services match this view.", "emptyFilter": "No services match this view.",
"ambiguous": "More than one existing item could match this service. Homarr will not choose one automatically.", "ambiguous": "More than one existing item could match this service. Homarr will not choose one automatically.",
"match": "{confidence} confidence match",
"filter": { "filter": {
"attention": "Needs attention", "attention": "Needs attention",
"represented": "Already set up", "represented": "Already set up",
"all": "All" "all": "All"
}, },
"confidence": {
"high": "High",
"medium": "Medium"
},
"state": { "state": {
"newRecognized": "Ready to connect", "newRecognized": "Ready to connect",
"newApp": "New service", "newApp": "New service",
@@ -7100,9 +7086,19 @@
"moved": "Address changed" "moved": "Address changed"
}, },
"url": { "url": {
"manual": "Enter an address manually" "label": "Service address",
"inputLabel": "Service URL",
"manual": "Enter an address manually",
"invalid": "Enter a valid HTTP or HTTPS URL"
},
"reason": {
"publishedAddress": "Published address reported by Docker",
"dockerEndpointHost": "Docker endpoint host combined with the published port",
"sharedContainerNetwork": "Server-only address for services sharing the container network",
"manualHostRequired": "Enter the address that your browser or Homarr server can reach"
}, },
"health": { "health": {
"title": "Observed service layers",
"layer": { "layer": {
"docker": "Docker", "docker": "Docker",
"integrationConfiguration": "Integration", "integrationConfiguration": "Integration",
@@ -7124,15 +7120,17 @@
} }
}, },
"action": { "action": {
"review": "Review", "refresh": "Refresh",
"retry": "Try again",
"dismiss": "Dismiss", "dismiss": "Dismiss",
"restoreDismissed": "Restore dismissed ({count})", "restoreDismissed": "Restore dismissed ({count})",
"setupIntegration": "Set up integration", "setupIntegration": "Set up integration",
"createApp": "Create app",
"reviewIntegration": "Review integration", "reviewIntegration": "Review integration",
"viewRepresentation": "View setup" "viewRepresentation": "View setup"
}, },
"loadError": { "loadError": {
"title": "Docker setup assistant could not be loaded", "title": "Service setup could not be loaded",
"message": "Docker service information is unavailable. Try again without leaving this page." "message": "Docker service information is unavailable. Try again without leaving this page."
}, },
"refreshError": { "refreshError": {
-2
View File
@@ -4,7 +4,6 @@ import { WidgetAppInput } from "./widget-app-input";
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input"; import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-input"; import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-input";
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input"; import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
import { WidgetDynamicMultiSelectInput } from "./widget-dynamic-multiselect-input";
import { WidgetLocationInput } from "./widget-location-input"; import { WidgetLocationInput } from "./widget-location-input";
import { WidgetMultiTextInput } from "./widget-multi-text-input"; import { WidgetMultiTextInput } from "./widget-multi-text-input";
import { WidgetMultiReleasesRepositoriesInput } from "./widget-multiReleasesRepositories-input"; import { WidgetMultiReleasesRepositoriesInput } from "./widget-multiReleasesRepositories-input";
@@ -37,7 +36,6 @@ const mapping = {
sortableItemList: WidgetSortedItemListInput, sortableItemList: WidgetSortedItemListInput,
multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput, multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput,
dynamicSelect: WidgetDynamicSelectInput, dynamicSelect: WidgetDynamicSelectInput,
dynamicMultiSelect: WidgetDynamicMultiSelectInput,
umamiEventName: WidgetUmamiEventInput, umamiEventName: WidgetUmamiEventInput,
umamiEventNames: WidgetUmamiEventNamesInput, umamiEventNames: WidgetUmamiEventNamesInput,
umamiWebsite: WidgetUmamiWebsiteInput, umamiWebsite: WidgetUmamiWebsiteInput,
@@ -314,7 +314,7 @@ export function JsonPathTreePicker({
{hasData && ( {hasData && (
<Combobox.Header> <Combobox.Header>
<Group gap="xs" px="xs" pt="xs" pb={4}> <Group gap="xs" px="xs" pt="xs" pb={4}>
<Badge size="sm" color="teal" variant="light" leftSection={<IconCircleCheck size="var(--mantine-font-size-xs)" />}> <Badge size="sm" color="teal" variant="light" leftSection={<IconCircleCheck size={12} />}>
{loadedHint} {loadedHint}
</Badge> </Badge>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
@@ -353,7 +353,7 @@ function TreeNodeOption({ node, selected, isSearching }: { node: FlatNode; selec
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}> <Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
{node.hasChildren && ( {node.hasChildren && (
<IconChevronDown <IconChevronDown
size="var(--mantine-font-size-sm)" size={14}
style={{ style={{
flexShrink: 0, flexShrink: 0,
color: "var(--mantine-color-dimmed)", color: "var(--mantine-color-dimmed)",
@@ -362,7 +362,7 @@ function TreeNodeOption({ node, selected, isSearching }: { node: FlatNode; selec
}} }}
/> />
)} )}
<TypeIcon size="var(--mantine-font-size-sm)" style={{ flexShrink: 0, opacity: 0.5 }} /> <TypeIcon size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
<Text <Text
size="sm" size="sm"
fw={selected ? 600 : 400} fw={selected ? 600 : 400}
@@ -76,7 +76,7 @@ export const WidgetCustomWidgetConfigurationInput = ({
return ( return (
<Stack gap="md"> <Stack gap="md">
{issues[0] && ( {issues[0] && (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size="var(--mantine-font-size-md)" />}> <Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
<Text size="sm">{labels("needsAttention", { message: issues[0].message })}</Text> <Text size="sm">{labels("needsAttention", { message: issues[0].message })}</Text>
</Alert> </Alert>
)} )}
@@ -115,7 +115,7 @@ export const WidgetCustomWidgetSelectInput = ({
> >
<Group wrap="nowrap" gap="sm"> <Group wrap="nowrap" gap="sm">
<Avatar src={def.iconUrl} size={36} radius="md" color="blue"> <Avatar src={def.iconUrl} size={36} radius="md" color="blue">
<IconApi size="var(--mantine-font-size-lg)" /> <IconApi size={18} />
</Avatar> </Avatar>
<Stack gap={1} style={{ minWidth: 0 }}> <Stack gap={1} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate> <Text size="sm" fw={600} truncate>
@@ -152,7 +152,7 @@ export const WidgetCustomWidgetSelectInput = ({
<Alert <Alert
color="yellow" color="yellow"
variant="light" variant="light"
icon={<IconAlertTriangle size="var(--mantine-font-size-lg)" />} icon={<IconAlertTriangle size={18} />}
title={labels("migrationRequired")} title={labels("migrationRequired")}
> >
<Text size="xs">{labels("migrationDescription")}</Text> <Text size="xs">{labels("migrationDescription")}</Text>
@@ -1,45 +0,0 @@
"use client";
import { Loader, MultiSelect } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetDynamicMultiSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"dynamicMultiSelect">) => {
const t = useScopedI18n("widget.dynamicSelect");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
const selectedValues = (inputProps.value as string[] | undefined) ?? [];
const { data: selectData, isPending, isError } = options.useOptions();
const data = [
...selectData,
...selectedValues
.filter((selectedValue) => !selectData.some((option) => option.value === selectedValue))
.map((selectedValue) => ({ value: selectedValue, label: selectedValue })),
];
return (
<MultiSelect
{...inputProps}
label={tInput("label")}
description={options.withDescription ? tInput("description") : undefined}
placeholder={isPending ? t("loading") : tInput("placeholder")}
leftSection={isPending ? <Loader size="xs" /> : undefined}
searchable
clearable
maxValues={options.maxValues}
nothingFoundMessage={t("noResults")}
data={data}
disabled={isError}
error={isError ? t("loadError") : inputProps.error}
/>
);
};
@@ -89,7 +89,7 @@ export const WidgetDynamicSelectInput = ({
style={{ marginInlineStart: "auto" }} style={{ marginInlineStart: "auto" }}
color="currentColor" color="currentColor"
opacity={0.6} opacity={0.6}
size="var(--mantine-font-size-lg)" size={18}
stroke={1.5} stroke={1.5}
/> />
)} )}
@@ -97,7 +97,7 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp
disabled={!selectionEnabled} disabled={!selectionEnabled}
onClick={onSearch} onClick={onSearch}
variant="light" variant="light"
leftSection={<IconListSearch size="var(--mantine-font-size-md)" />} leftSection={<IconListSearch size={16} />}
> >
{tLocation("search")} {tLocation("search")}
</Button> </Button>
@@ -257,7 +257,7 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
})} })}
> >
<ActionIcon color="red" variant="subtle" onClick={onSelect}> <ActionIcon color="red" variant="subtle" onClick={onSelect}>
<IconClick size="var(--mantine-font-size-md)" /> <IconClick size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Table.Td> </Table.Td>
@@ -229,19 +229,19 @@ export const WidgetMultiReleasesRepositoriesInput = ({
}) })
} }
variant="light" variant="light"
leftSection={<IconEdit size="var(--mantine-font-size-sm)" />} leftSection={<IconEdit size={15} />}
size="xs" size="xs"
> >
{tRepository("edit.label")} {tRepository("edit.label")}
</Button> </Button>
<ActionIcon variant="transparent" color="red" onClick={() => onRepositoryRemove(index)}> <ActionIcon variant="transparent" color="red" onClick={() => onRepositoryRemove(index)}>
<IconTrash size="var(--mantine-font-size-sm)" /> <IconTrash size={15} />
</ActionIcon> </ActionIcon>
</Group> </Group>
{Object.keys(form.errors).filter((key) => key.startsWith(`options.${property}.${index}.`)).length > 0 && ( {Object.keys(form.errors).filter((key) => key.startsWith(`options.${property}.${index}.`)).length > 0 && (
<Group align="center" justify="center" gap="xs" bg="red.1"> <Group align="center" justify="center" gap="xs" bg="red.1">
<IconTriangleFilled size="var(--mantine-font-size-sm)" color="var(--mantine-color-red-filled)" /> <IconTriangleFilled size={15} color="var(--mantine-color-red-filled)" />
<Text size="sm" c="red"> <Text size="sm" c="red">
{tRepository("invalid")} {tRepository("invalid")}
</Text> </Text>
@@ -371,7 +371,7 @@ const ProviderTokenInput = ({
}} }}
style={{ flex: 1 }} style={{ flex: 1 }}
size="xs" size="xs"
leftSection={<IconKey size="var(--mantine-font-size-sm)" />} leftSection={<IconKey size={14} />}
/> />
{editing && value.trim() && ( {editing && value.trim() && (
<Button size="xs" onClick={handleSave} loading={saving}> <Button size="xs" onClick={handleSave} loading={saving}>
@@ -380,7 +380,7 @@ const ProviderTokenInput = ({
)} )}
{hasToken && !editing && ( {hasToken && !editing && (
<ActionIcon variant="light" color="red" size="sm" onClick={handleDelete} loading={saving}> <ActionIcon variant="light" color="red" size="sm" onClick={handleDelete} loading={saving}>
<IconTrash size="var(--mantine-font-size-sm)" /> <IconTrash size={14} />
</ActionIcon> </ActionIcon>
)} )}
</Group> </Group>
@@ -51,20 +51,20 @@ export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInput
label: translateIfNecessary(t, option.label) ?? option.value, label: translateIfNecessary(t, option.label) ?? option.value,
}, },
)} )}
leftSection={CurrentIcon && <CurrentIcon size="var(--mantine-font-size-md)" stroke={1.5} />} leftSection={CurrentIcon && <CurrentIcon size={16} stroke={1.5} />}
renderOption={({ option, checked }) => { renderOption={({ option, checked }) => {
const Icon = getIconFor(options.options, String(option.value)); const Icon = getIconFor(options.options, String(option.value));
return ( return (
<Group flex="1" gap="xs"> <Group flex="1" gap="xs">
{Icon && <Icon color="currentColor" opacity={0.6} size="var(--mantine-font-size-lg)" stroke={1.5} />} {Icon && <Icon color="currentColor" opacity={0.6} size={18} stroke={1.5} />}
{option.label} {option.label}
{checked && ( {checked && (
<IconCheck <IconCheck
style={{ marginInlineStart: "auto" }} style={{ marginInlineStart: "auto" }}
color="currentColor" color="currentColor"
opacity={0.6} opacity={0.6}
size="var(--mantine-font-size-lg)" size={18}
stroke={1.5} stroke={1.5}
/> />
)} )}
@@ -263,7 +263,7 @@ const AnchorNoteWidgetContent = ({
onClick={handleEdit} onClick={handleEdit}
disabled={isUpdating} disabled={isUpdating}
> >
<IconEdit size="var(--mantine-font-size-md)" /> <IconEdit size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
+1 -1
View File
@@ -18,7 +18,7 @@ export default function AssistantWidget(props: WidgetComponentProps<"assistant">
<Center h="100%" p="md"> <Center h="100%" p="md">
<Stack align="center" gap="xs" ta="center"> <Stack align="center" gap="xs" ta="center">
<ThemeIcon variant="light" color="red" radius="xl" size="lg"> <ThemeIcon variant="light" color="red" radius="xl" size="lg">
<IconRobot size="var(--mantine-font-size-xl)" /> <IconRobot size={20} />
</ThemeIcon> </ThemeIcon>
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{t("title")} {t("title")}
@@ -201,7 +201,7 @@ export default function BeszelSystemStatsWidget({
<Button <Button
variant="default" variant="default"
size="compact-xs" size="compact-xs"
leftSection={<IconServer size="var(--mantine-font-size-sm)" />} leftSection={<IconServer size={14} />}
className={classes.beszelStatsSystemToggle} className={classes.beszelStatsSystemToggle}
disabled={isSelectionSavePending} disabled={isSelectionSavePending}
> >
+1 -1
View File
@@ -49,7 +49,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
</Group> </Group>
<ActionIcon variant="transparent" color="red" onClick={removeItem}> <ActionIcon variant="transparent" color="red" onClick={removeItem}>
<IconX size="var(--mantine-font-size-xl)" /> <IconX size={20} />
</ActionIcon> </ActionIcon>
</Group> </Group>
); );
+2 -2
View File
@@ -303,7 +303,7 @@ const CalendarAgenda = ({
disabled={isEditMode} disabled={isEditMode}
onClick={() => setMonth(moveCalendarMonth(month, -1))} onClick={() => setMonth(moveCalendarMonth(month, -1))}
> >
<IconChevronLeft size="var(--mantine-font-size-lg)" aria-hidden /> <IconChevronLeft size={18} aria-hidden />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Stack gap={0} miw={0}> <Stack gap={0} miw={0}>
@@ -323,7 +323,7 @@ const CalendarAgenda = ({
disabled={isEditMode} disabled={isEditMode}
onClick={() => setMonth(moveCalendarMonth(month, 1))} onClick={() => setMonth(moveCalendarMonth(month, 1))}
> >
<IconChevronRight size="var(--mantine-font-size-lg)" aria-hidden /> <IconChevronRight size={18} aria-hidden />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Group> </Group>
@@ -25,7 +25,7 @@ export function IntegrationErrorIndicator({ results }: IntegrationErrorIndicator
return ( return (
<Tooltip label={label} position="left" withArrow> <Tooltip label={label} position="left" withArrow>
<ActionIcon variant="transparent" color="orange" size={28} aria-label={label} style={{ cursor: "help" }}> <ActionIcon variant="transparent" color="orange" size={28} aria-label={label} style={{ cursor: "help" }}>
<IconAlertTriangle aria-hidden size="var(--mantine-font-size-sm)" /> <IconAlertTriangle aria-hidden size={14} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
); );
@@ -28,7 +28,7 @@ export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndi
tabIndex={0} tabIndex={0}
style={{ cursor: "help" }} style={{ cursor: "help" }}
> >
<IconAlertTriangle aria-hidden size="var(--mantine-font-size-sm)" /> <IconAlertTriangle aria-hidden size={14} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
); );
@@ -22,7 +22,7 @@ export function ApplicationsSection({ applications, baseUrl, isTiny }: Applicati
return ( return (
<Accordion.Item value="applications"> <Accordion.Item value="applications">
<Accordion.Control icon={isTiny ? null : <IconCloud size="var(--mantine-font-size-md)" />}> <Accordion.Control icon={isTiny ? null : <IconCloud size={16} />}>
<Group gap="xs"> <Group gap="xs">
<Text size="xs">{tCommon("applications")}</Text> <Text size="xs">{tCommon("applications")}</Text>
<Badge variant="dot" color={getBadgeColor(runningApps, applications.length)} size="xs"> <Badge variant="dot" color={getBadgeColor(runningApps, applications.length)} size="xs">
@@ -106,7 +106,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
variant="subtle" variant="subtle"
c="dimmed" c="dimmed"
> >
<IconLink size="var(--mantine-font-size-xs)" /> <IconLink size={12} />
</ActionIcon> </ActionIcon>
)} )}
{!isTiny && logsUrl && ( {!isTiny && logsUrl && (
@@ -121,7 +121,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
variant="subtle" variant="subtle"
c="dimmed" c="dimmed"
> >
<IconFileText size="var(--mantine-font-size-xs)" /> <IconFileText size={12} />
</ActionIcon> </ActionIcon>
)} )}
{!isTiny && ( {!isTiny && (
@@ -43,7 +43,7 @@ export function ServersSection({
return ( return (
<Accordion.Item value="servers"> <Accordion.Item value="servers">
<Group gap={0} wrap="nowrap"> <Group gap={0} wrap="nowrap">
<Accordion.Control icon={isTiny ? null : <IconServer size="var(--mantine-font-size-md)" />} style={{ flex: 1, minWidth: 0 }}> <Accordion.Control icon={isTiny ? null : <IconServer size={16} />} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs"> <Group gap="xs">
<Text size="xs">{tCommon("servers")}</Text> <Text size="xs">{tCommon("servers")}</Text>
<Badge variant="dot" color={getBadgeColor(onlineServers, servers.length)} size="xs"> <Badge variant="dot" color={getBadgeColor(onlineServers, servers.length)} size="xs">
@@ -61,7 +61,7 @@ export function ServersSection({
mr="xs" mr="xs"
onClick={onToggleIp} onClick={onToggleIp}
> >
{showIp ? <IconEye size="var(--mantine-font-size-xs)" /> : <IconEyeOff size="var(--mantine-font-size-xs)" />} {showIp ? <IconEye size={12} /> : <IconEyeOff size={12} />}
</ActionIcon> </ActionIcon>
</Group> </Group>
<Accordion.Panel p={4}> <Accordion.Panel p={4}>
@@ -25,7 +25,7 @@ export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: Servi
return ( return (
<Accordion.Item value="services"> <Accordion.Item value="services">
<Accordion.Control icon={isTiny ? null : <IconStack2 size="var(--mantine-font-size-md)" />}> <Accordion.Control icon={isTiny ? null : <IconStack2 size={16} />}>
<Group gap="xs"> <Group gap="xs">
<Text size="xs">{tCommon("services")}</Text> <Text size="xs">{tCommon("services")}</Text>
<Badge variant="dot" color={getBadgeColor(runningServices, services.length)} size="xs"> <Badge variant="dot" color={getBadgeColor(runningServices, services.length)} size="xs">
@@ -90,7 +90,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
<Button <Button
variant="light" variant="light"
color="red" color="red"
leftSection={<IconClockPause size="var(--mantine-font-size-xl)" />} leftSection={<IconClockPause size={20} />}
h="2rem" h="2rem"
w="12rem" w="12rem"
onClick={handleSetTimer} onClick={handleSetTimer}
@@ -184,7 +184,7 @@ export default function DnsHoleControlsWidget({
radius={board.itemRadius} radius={board.itemRadius}
flex={1} flex={1}
> >
<IconPlayerPlay className="dns-hole-controls-enable-all-icon" size="var(--mantine-font-size-md)" /> <IconPlayerPlay className="dns-hole-controls-enable-all-icon" size={16} />
</Button> </Button>
</Tooltip> </Tooltip>
@@ -205,7 +205,7 @@ export default function DnsHoleControlsWidget({
radius={board.itemRadius} radius={board.itemRadius}
flex={1} flex={1}
> >
<IconClockPause className="dns-hole-controls-timer-all-icon" size="var(--mantine-font-size-md)" /> <IconClockPause className="dns-hole-controls-timer-all-icon" size={16} />
</Button> </Button>
</Tooltip> </Tooltip>
@@ -223,7 +223,7 @@ export default function DnsHoleControlsWidget({
radius={board.itemRadius} radius={board.itemRadius}
flex={1} flex={1}
> >
<IconPlayerStop className="dns-hole-controls-disable-all-icon" size="var(--mantine-font-size-md)" /> <IconPlayerStop className="dns-hole-controls-disable-all-icon" size={16} />
</Button> </Button>
</Tooltip> </Tooltip>
</Flex> </Flex>
@@ -378,7 +378,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
color="green" color="green"
variant="light" variant="light"
> >
<IconPlayerPlay size="var(--mantine-font-size-xs)" /> <IconPlayerPlay size={12} />
</ActionIcon> </ActionIcon>
) : ( ) : (
<ActionIcon <ActionIcon
@@ -390,7 +390,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
color="red" color="red"
variant="light" variant="light"
> >
<IconPlayerStop size="var(--mantine-font-size-xs)" /> <IconPlayerStop size={12} />
</ActionIcon> </ActionIcon>
)} )}
<ActionIcon <ActionIcon
@@ -406,7 +406,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
display={isInteractPermitted ? undefined : "none"} display={isInteractPermitted ? undefined : "none"}
disabled={!controlEnabled || !isEnabled} disabled={!controlEnabled || !isEnabled}
> >
<IconClockPause size="var(--mantine-font-size-xs)" /> <IconClockPause size={12} />
</ActionIcon> </ActionIcon>
</Group> </Group>
)} )}
@@ -436,7 +436,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
<IconCircleFilled <IconCircleFilled
className="dns-hole-controls-item-status-icon" className="dns-hole-controls-item-status-icon"
color={dnsLightStatus(isEnabled)} color={dnsLightStatus(isEnabled)}
size="var(--mantine-font-size-md)" size={16}
/> />
) )
} }
@@ -474,7 +474,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
open(); open();
}} }}
> >
<IconClockPause className="dns-hole-controls-item-timer-icon" size="var(--mantine-font-size-xl)" /> <IconClockPause className="dns-hole-controls-item-timer-icon" size={20} />
</ActionIcon> </ActionIcon>
)} )}
</Flex> </Flex>
+9 -14
View File
@@ -74,11 +74,6 @@ const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" |
const getContainerTarget = ({ endpointId, id }: DockerContainer) => ({ endpointId, id }); const getContainerTarget = ({ endpointId, id }: DockerContainer) => ({ endpointId, id });
const getContainersQueryInput = (endpointIds: string[]) => {
if (endpointIds.length === 0) return undefined;
return { endpointIds };
};
const ContainerStateBadge = ({ state }: { state: ContainerState }) => { const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option"); const t = useScopedI18n("docker.field.state.option");
@@ -253,7 +248,7 @@ export default function DockerWidget({
const isAdvanced = displayMode === "advanced"; const isAdvanced = displayMode === "advanced";
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const containersQuery = clientApi.docker.getContainers.useQuery(getContainersQueryInput(options.endpointIds)); const containersQuery = clientApi.docker.getContainers.useQuery();
const data = getUsableWidgetQueryData(containersQuery); const data = getUsableWidgetQueryData(containersQuery);
const { isFetching } = containersQuery; const { isFetching } = containersQuery;
const refreshInventory = clientApi.docker.refreshInventory.useMutation({ const refreshInventory = clientApi.docker.refreshInventory.useMutation({
@@ -435,7 +430,7 @@ export default function DockerWidget({
wrap="nowrap" wrap="nowrap"
> >
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<IconBrandDocker size="var(--mantine-font-size-xl)" style={{ flexShrink: 0 }} /> <IconBrandDocker size={20} style={{ flexShrink: 0 }} />
<Text size="sm" truncate> <Text size="sm" truncate>
{t("table.footer", { count: containers.length.toString() })} {t("table.footer", { count: containers.length.toString() })}
</Text> </Text>
@@ -462,7 +457,7 @@ export default function DockerWidget({
onClick={() => refreshInventory.mutate()} onClick={() => refreshInventory.mutate()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })} aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
> >
<IconRefresh size="var(--mantine-font-size-md)" /> <IconRefresh size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Group> </Group>
@@ -496,7 +491,7 @@ function ContainerMenuButton({
aria-label={t("title")} aria-label={t("title")}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<IconDots size="var(--mantine-font-size-md)" /> <IconDots size={16} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown w={containerMenuWidth} miw={containerMenuWidth} maw={containerMenuWidth}> <Menu.Dropdown w={containerMenuWidth} miw={containerMenuWidth} maw={containerMenuWidth}>
@@ -530,7 +525,7 @@ function ContainerActionItems({
{container.name} {container.name}
</Menu.Label> </Menu.Label>
<Menu.Item <Menu.Item
leftSection={<IconFileText size="var(--mantine-font-size-sm)" />} leftSection={<IconFileText size={14} />}
disabled={!handlers.canUse(container, "logs")} disabled={!handlers.canUse(container, "logs")}
onClick={() => { onClick={() => {
handlers.onOpenLogs(container); handlers.onOpenLogs(container);
@@ -542,7 +537,7 @@ function ContainerActionItems({
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item
color={stateAction === "start" ? "green" : "red"} color={stateAction === "start" ? "green" : "red"}
leftSection={<StateIcon size="var(--mantine-font-size-sm)" />} leftSection={<StateIcon size={14} />}
disabled={!handlers.canUse(container, "lifecycle")} disabled={!handlers.canUse(container, "lifecycle")}
onClick={() => invokeAction(stateAction)} onClick={() => invokeAction(stateAction)}
> >
@@ -550,7 +545,7 @@ function ContainerActionItems({
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
color="orange" color="orange"
leftSection={<IconRotateClockwise size="var(--mantine-font-size-sm)" />} leftSection={<IconRotateClockwise size={14} />}
disabled={!handlers.canUse(container, "lifecycle")} disabled={!handlers.canUse(container, "lifecycle")}
onClick={() => invokeAction("restart")} onClick={() => invokeAction("restart")}
> >
@@ -558,7 +553,7 @@ function ContainerActionItems({
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
color="red" color="red"
leftSection={<IconTrash size="var(--mantine-font-size-sm)" />} leftSection={<IconTrash size={14} />}
disabled={!handlers.canUse(container, "remove")} disabled={!handlers.canUse(container, "remove")}
onClick={() => invokeAction("remove")} onClick={() => invokeAction("remove")}
> >
@@ -566,7 +561,7 @@ function ContainerActionItems({
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item
leftSection={<IconCategoryPlus size="var(--mantine-font-size-sm)" />} leftSection={<IconCategoryPlus size={14} />}
onClick={() => { onClick={() => {
handlers.onAddToHomarr(container); handlers.onAddToHomarr(container);
onClose(); onClose();
-14
View File
@@ -1,7 +1,6 @@
import { IconBrandDocker, IconServerOff } from "@tabler/icons-react"; import { IconBrandDocker, IconServerOff } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
@@ -31,19 +30,6 @@ export const { definition, componentLoader } = createWidgetDefinition("dockerCon
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
(factory) => ({ (factory) => ({
endpointIds: factory.dynamicMultiSelect({
defaultValue: [],
maxValues: 100,
withDescription: true,
useOptions: () => {
const { data = [], isPending, isError } = clientApi.docker.getEndpoints.useQuery();
return {
data: data.map((endpoint) => ({ value: endpoint.id, label: endpoint.name })),
isPending,
isError,
};
},
}),
columns: factory.multiSelect({ columns: factory.multiSelect({
defaultValue: [...allColumnsList], defaultValue: [...allColumnsList],
options: allColumnsList.map((value) => ({ options: allColumnsList.map((value) => ({
+18 -18
View File
@@ -279,7 +279,7 @@ function SpeedCell({
const { Icon, color } = speedColumnConfig[direction]; const { Icon, color } = speedColumnConfig[direction];
return ( return (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<Icon size="var(--mantine-font-size-xs)" style={{ flexShrink: 0, opacity: 0.5 }} /> <Icon size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
<Text size={fontSize} c={color}> <Text size={fontSize} c={color}>
{formatByteRate(speed)} {formatByteRate(speed)}
</Text> </Text>
@@ -914,7 +914,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
<DetailPair <DetailPair
label={t("items.downSpeed.detailsTitle")} label={t("items.downSpeed.detailsTitle")}
value={formatByteRate(item.downSpeed)} value={formatByteRate(item.downSpeed)}
icon={<IconDownload size="var(--mantine-font-size-xs)" />} icon={<IconDownload size={10} />}
color="blue" color="blue"
/> />
)} )}
@@ -922,7 +922,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
<DetailPair <DetailPair
label={t("items.upSpeed.detailsTitle")} label={t("items.upSpeed.detailsTitle")}
value={formatByteRate(item.upSpeed)} value={formatByteRate(item.upSpeed)}
icon={<IconUpload size="var(--mantine-font-size-xs)" />} icon={<IconUpload size={10} />}
color="green" color="green"
/> />
)} )}
@@ -1005,7 +1005,7 @@ function GlobalStatsBar({
<Tooltip label={t("stats.totalSize")} withArrow> <Tooltip label={t("stats.totalSize")} withArrow>
<Group gap={4}> <Group gap={4}>
<IconDatabase size="var(--mantine-font-size-xs)" style={{ opacity: 0.6 }} /> <IconDatabase size={12} style={{ opacity: 0.6 }} />
<Text size="xs">{`${formatBytes(queueStats.completedSize)} / ${formatBytes(queueStats.totalSize)}`}</Text> <Text size="xs">{`${formatBytes(queueStats.completedSize)} / ${formatBytes(queueStats.totalSize)}`}</Text>
</Group> </Group>
</Tooltip> </Tooltip>
@@ -1013,14 +1013,14 @@ function GlobalStatsBar({
<Group gap="md"> <Group gap="md">
<Group gap={4}> <Group gap={4}>
<IconDownload size="var(--mantine-font-size-xs)" style={{ opacity: 0.6 }} /> <IconDownload size={12} style={{ opacity: 0.6 }} />
<Text size="xs" fw={600} c="blue"> <Text size="xs" fw={600} c="blue">
{formatByteRate(totalSpeed)} {formatByteRate(totalSpeed)}
</Text> </Text>
</Group> </Group>
{totalUpSpeed > 0 && ( {totalUpSpeed > 0 && (
<Group gap={4}> <Group gap={4}>
<IconUpload size="var(--mantine-font-size-xs)" style={{ opacity: 0.6 }} /> <IconUpload size={12} style={{ opacity: 0.6 }} />
<Text size="xs" fw={600} c="green"> <Text size="xs" fw={600} c="green">
{formatByteRate(totalUpSpeed)} {formatByteRate(totalUpSpeed)}
</Text> </Text>
@@ -1093,7 +1093,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
<Menu.Label>{truncateText(item.name, 40)}</Menu.Label> <Menu.Label>{truncateText(item.name, 40)}</Menu.Label>
<Menu.Item <Menu.Item
leftSection={<IconInfoCircle size="var(--mantine-font-size-sm)" />} leftSection={<IconInfoCircle size={14} />}
onClick={() => { onClick={() => {
void navigator.clipboard.writeText(item.name).catch(() => {}); void navigator.clipboard.writeText(item.name).catch(() => {});
onClose(); onClose();
@@ -1104,7 +1104,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
{item.id && ( {item.id && (
<Menu.Item <Menu.Item
leftSection={<IconCopy size="var(--mantine-font-size-sm)" />} leftSection={<IconCopy size={14} />}
onClick={() => { onClick={() => {
void navigator.clipboard.writeText(item.id).catch(() => {}); void navigator.clipboard.writeText(item.id).catch(() => {});
onClose(); onClose();
@@ -1118,7 +1118,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
<> <>
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item
leftSection={<PauseResumeIcon size="var(--mantine-font-size-sm)" />} leftSection={<PauseResumeIcon size={14} />}
onClick={() => { onClick={() => {
pauseResumeInvoke?.(); pauseResumeInvoke?.();
onClose(); onClose();
@@ -1130,7 +1130,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
{!showDeleteConfirm && ( {!showDeleteConfirm && (
<Menu.Item <Menu.Item
color="red" color="red"
leftSection={<IconTrash size="var(--mantine-font-size-sm)" />} leftSection={<IconTrash size={14} />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
openDelete(); openDelete();
@@ -1144,7 +1144,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
<> <>
<Menu.Item <Menu.Item
color="red" color="red"
leftSection={<IconTrash size="var(--mantine-font-size-sm)" />} leftSection={<IconTrash size={14} />}
onClick={() => { onClick={() => {
item.actions?.delete({ fromDisk: false }); item.actions?.delete({ fromDisk: false });
onClose(); onClose();
@@ -1154,7 +1154,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
color="red" color="red"
leftSection={<IconTrashX size="var(--mantine-font-size-sm)" />} leftSection={<IconTrashX size={14} />}
onClick={() => { onClick={() => {
item.actions?.delete({ fromDisk: true }); item.actions?.delete({ fromDisk: true });
onClose(); onClose();
@@ -1321,14 +1321,14 @@ function WidgetFooter({
} }
}} }}
> >
<FilterIcon size="var(--mantine-font-size-sm)" /> <FilterIcon size={14} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
{toggleStats ? ( {toggleStats ? (
<Tooltip label={statsTooltip}> <Tooltip label={statsTooltip}>
<ActionIcon size="xs" variant={statsIconVariant} aria-label={statsTooltip} onClick={toggleStats}> <ActionIcon size="xs" variant={statsIconVariant} aria-label={statsTooltip} onClick={toggleStats}>
<StatsIcon size="var(--mantine-font-size-sm)" /> <StatsIcon size={14} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
) : null} ) : null}
@@ -1356,7 +1356,7 @@ function WidgetFooter({
disabled={integrationsStatuses.paused.length === 0} disabled={integrationsStatuses.paused.length === 0}
onClick={() => resumeQueue({ integrationIds: integrationsStatuses.paused })} onClick={() => resumeQueue({ integrationIds: integrationsStatuses.paused })}
> >
<IconPlayerPlay size="var(--mantine-font-size-sm)" /> <IconPlayerPlay size={14} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
@@ -1364,12 +1364,12 @@ function WidgetFooter({
{!showStats && ( {!showStats && (
<Group gap={2}> <Group gap={2}>
<Text size="xs" fw={600} c="blue"> <Text size="xs" fw={600} c="blue">
<IconDownload size="var(--mantine-font-size-xs)" style={{ verticalAlign: "middle", marginRight: 2 }} /> <IconDownload size={10} style={{ verticalAlign: "middle", marginRight: 2 }} />
{formatByteRate(totalSpeed)} {formatByteRate(totalSpeed)}
</Text> </Text>
{totalUpSpeed > 0 && ( {totalUpSpeed > 0 && (
<Text size="xs" fw={600} c="green"> <Text size="xs" fw={600} c="green">
<IconUpload size="var(--mantine-font-size-xs)" style={{ verticalAlign: "middle", marginRight: 2 }} /> <IconUpload size={10} style={{ verticalAlign: "middle", marginRight: 2 }} />
{formatByteRate(totalUpSpeed)} {formatByteRate(totalUpSpeed)}
</Text> </Text>
)} )}
@@ -1386,7 +1386,7 @@ function WidgetFooter({
disabled={integrationsStatuses.active.length === 0} disabled={integrationsStatuses.active.length === 0}
onClick={() => pauseQueue({ integrationIds: integrationsStatuses.active })} onClick={() => pauseQueue({ integrationIds: integrationsStatuses.active })}
> >
<IconPlayerPause size="var(--mantine-font-size-sm)" /> <IconPlayerPause size={14} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
+3 -3
View File
@@ -386,7 +386,7 @@ const InterfacesPanel = ({
variant={isAdvanced ? "contained" : "default"} variant={isAdvanced ? "contained" : "default"}
> >
<Accordion.Item value="interfaces"> <Accordion.Item value="interfaces">
<Accordion.Control icon={<IconTopologyBus size="var(--mantine-font-size-md)" />}> <Accordion.Control icon={<IconTopologyBus size={16} />}>
<Group justify="space-between" wrap="nowrap" gap="xs"> <Group justify="space-between" wrap="nowrap" gap="xs">
<Text size="xs">{label}</Text> <Text size="xs">{label}</Text>
{hasError && ( {hasError && (
@@ -410,7 +410,7 @@ const InterfacesPanel = ({
</Text> </Text>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<IconArrowBarUp <IconArrowBarUp
size="var(--mantine-font-size-xs)" size={12}
color="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))" color="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))"
/> />
<Text size="xs" c="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))"> <Text size="xs" c="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))">
@@ -419,7 +419,7 @@ const InterfacesPanel = ({
</Group> </Group>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<IconArrowBarDown <IconArrowBarDown
size="var(--mantine-font-size-xs)" size={12}
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))" color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
/> />
<Text size="xs" c="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"> <Text size="xs" c="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))">
@@ -26,7 +26,7 @@ export const ResourceAccordionItem = ({
}: PropsWithChildren<ResourceAccordionItemProps>) => { }: PropsWithChildren<ResourceAccordionItemProps>) => {
return ( return (
<Accordion.Item value={value}> <Accordion.Item value={value}>
<Accordion.Control icon={isTiny ? null : <Icon size="var(--mantine-font-size-md)" />}> <Accordion.Control icon={isTiny ? null : <Icon size={16} />}>
<Group style={{ rowGap: "0" }} gap="xs"> <Group style={{ rowGap: "0" }} gap="xs">
<Text size="xs">{title}</Text> <Text size="xs">{title}</Text>
<Badge variant="dot" color={badge.color} size="xs"> <Badge variant="dot" color={badge.color} size="xs">
@@ -97,20 +97,20 @@ const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
const storage = formatBytesPair(item.storage.used, item.storage.total); const storage = formatBytesPair(item.storage.used, item.storage.total);
return ( return (
<List> <List>
<List.Item icon={<IconCpu size="var(--mantine-font-size-md)" />}> <List.Item icon={<IconCpu size={16} />}>
{t("cpu")} - {item.cpu.cores} {t("cpu")} - {item.cpu.cores}
</List.Item> </List.Item>
<List.Item icon={<IconBrain size="var(--mantine-font-size-md)" />}> <List.Item icon={<IconBrain size={16} />}>
{t("memory")} - {memory.used} / {memory.available} {t("memory")} - {memory.used} / {memory.available}
</List.Item> </List.Item>
<List.Item icon={<IconDatabase size="var(--mantine-font-size-md)" />}> <List.Item icon={<IconDatabase size={16} />}>
{t("storage")} - {storage.used} / {storage.available} {t("storage")} - {storage.used} / {storage.available}
</List.Item> </List.Item>
<List.Item icon={<IconClockHour3 size="var(--mantine-font-size-md)" />}> <List.Item icon={<IconClockHour3 size={16} />}>
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)} {t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
</List.Item> </List.Item>
{item.haState && ( {item.haState && (
<List.Item icon={<IconHeartBolt size="var(--mantine-font-size-md)" />}> <List.Item icon={<IconHeartBolt size={16} />}>
{t("haState")} - {capitalize(item.haState)} {t("haState")} - {capitalize(item.haState)}
</List.Item> </List.Item>
)} )}
@@ -152,15 +152,15 @@ const DiskStats = ({ item }: { item: ComputeResource }) => {
return null; return null;
} }
return ( return (
<List.Item icon={<IconDatabase size="var(--mantine-font-size-md)" />}> <List.Item icon={<IconDatabase size={16} />}>
<Group gap="sm"> <Group gap="sm">
<Group gap={0}> <Group gap={0}>
<Text>{formatBytes(item.storage.write)}</Text> <Text>{formatBytes(item.storage.write)}</Text>
<IconArrowNarrowDown size="var(--mantine-font-size-sm)" /> <IconArrowNarrowDown size={14} />
</Group> </Group>
<Group gap={0}> <Group gap={0}>
<Text>{formatBytes(item.storage.read)}</Text> <Text>{formatBytes(item.storage.read)}</Text>
<IconArrowNarrowUp size="var(--mantine-font-size-sm)" /> <IconArrowNarrowUp size={14} />
</Group> </Group>
</Group> </Group>
</List.Item> </List.Item>
@@ -172,15 +172,15 @@ const NetStats = ({ item }: { item: ComputeResource }) => {
return null; return null;
} }
return ( return (
<List.Item icon={<IconNetwork size="var(--mantine-font-size-md)" />}> <List.Item icon={<IconNetwork size={16} />}>
<Group gap="sm"> <Group gap="sm">
<Group gap={0}> <Group gap={0}>
<Text>{formatBytes(item.network.in)}</Text> <Text>{formatBytes(item.network.in)}</Text>
<IconArrowNarrowDown size="var(--mantine-font-size-sm)" /> <IconArrowNarrowDown size={14} />
</Group> </Group>
<Group gap={0}> <Group gap={0}>
<Text>{formatBytes(item.network.out)}</Text> <Text>{formatBytes(item.network.out)}</Text>
<IconArrowNarrowUp size="var(--mantine-font-size-sm)" /> <IconArrowNarrowUp size={14} />
</Group> </Group>
</Group> </Group>
</List.Item> </List.Item>
@@ -178,7 +178,7 @@ function Carousel({
size={40} size={40}
onClick={() => move(-1)} onClick={() => move(-1)}
> >
<IconChevronLeft size="var(--mantine-font-size-lg)" /> <IconChevronLeft size={18} />
</ActionIcon> </ActionIcon>
)} )}
<ActionIcon <ActionIcon
@@ -189,7 +189,7 @@ function Carousel({
size={advanced ? 40 : 32} size={advanced ? 40 : 32}
onClick={() => setPaused((value) => !value)} onClick={() => setPaused((value) => !value)}
> >
{paused ? <IconPlayerPlay size="var(--mantine-font-size-lg)" /> : <IconPlayerPause size="var(--mantine-font-size-lg)" />} {paused ? <IconPlayerPlay size={18} /> : <IconPlayerPause size={18} />}
</ActionIcon> </ActionIcon>
{advanced && ( {advanced && (
<ActionIcon <ActionIcon
@@ -200,7 +200,7 @@ function Carousel({
size={40} size={40}
onClick={() => move(1)} onClick={() => move(1)}
> >
<IconChevronRight size="var(--mantine-font-size-lg)" /> <IconChevronRight size={18} />
</ActionIcon> </ActionIcon>
)} )}
</Group> </Group>
@@ -214,7 +214,7 @@ function Carousel({
</Text> </Text>
)} )}
<Group gap="xs"> <Group gap="xs">
<IconCalendar size="var(--mantine-font-size-md)" /> <IconCalendar size={16} />
<Text size="xs">{new Date(currentAsset.fileModifiedAt).toLocaleDateString(locale)}</Text> <Text size="xs">{new Date(currentAsset.fileModifiedAt).toLocaleDateString(locale)}</Text>
</Group> </Group>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
@@ -59,7 +59,7 @@ export default function ImmichServerStatsWidget({
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}> <SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
{statVisibility.showUsers && ( {statVisibility.showUsers && (
<StatItem <StatItem
icon={<IconUsers size="var(--mantine-font-size-xl)" />} icon={<IconUsers size={20} />}
label={t("widget.immich-serverStats.users")} label={t("widget.immich-serverStats.users")}
value={stats.userCount.toLocaleString(locale)} value={stats.userCount.toLocaleString(locale)}
dense={statsLayout.dense} dense={statsLayout.dense}
@@ -67,7 +67,7 @@ export default function ImmichServerStatsWidget({
)} )}
{statVisibility.showPhotos && ( {statVisibility.showPhotos && (
<StatItem <StatItem
icon={<IconPhoto size="var(--mantine-font-size-xl)" />} icon={<IconPhoto size={20} />}
label={t("widget.immich-serverStats.photos")} label={t("widget.immich-serverStats.photos")}
value={stats.photoCount.toLocaleString(locale)} value={stats.photoCount.toLocaleString(locale)}
dense={statsLayout.dense} dense={statsLayout.dense}
@@ -75,7 +75,7 @@ export default function ImmichServerStatsWidget({
)} )}
{statVisibility.showVideos && ( {statVisibility.showVideos && (
<StatItem <StatItem
icon={<IconVideo size="var(--mantine-font-size-xl)" />} icon={<IconVideo size={20} />}
label={t("widget.immich-serverStats.videos")} label={t("widget.immich-serverStats.videos")}
value={stats.videoCount.toLocaleString(locale)} value={stats.videoCount.toLocaleString(locale)}
dense={statsLayout.dense} dense={statsLayout.dense}
@@ -83,7 +83,7 @@ export default function ImmichServerStatsWidget({
)} )}
{statVisibility.showStorage && ( {statVisibility.showStorage && (
<StatItem <StatItem
icon={<IconDatabase size="var(--mantine-font-size-xl)" />} icon={<IconDatabase size={20} />}
label={t("widget.immich-serverStats.storage")} label={t("widget.immich-serverStats.storage")}
value={formatBytes(stats.totalLibraryUsageInBytes)} value={formatBytes(stats.totalLibraryUsageInBytes)}
dense={statsLayout.dense} dense={statsLayout.dense}
@@ -133,7 +133,7 @@ export default function IndexerManagerWidget({
}} }}
aria-label={t("widget.indexerManager.testAll")} aria-label={t("widget.indexerManager.testAll")}
> >
<IconTestPipe size="var(--mantine-font-size-sm)" /> <IconTestPipe size={14} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Group> </Group>
@@ -141,7 +141,7 @@ export default function MediaMissingWidget({
{showMissing && ( {showMissing && (
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}> <Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
<Group p="xs" gap="xs"> <Group p="xs" gap="xs">
<IconQuestionMark size="var(--mantine-font-size-md)" /> <IconQuestionMark size={16} />
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{tabLabel(t("tab.missing"), missing.length, missingCount)} {tabLabel(t("tab.missing"), missing.length, missingCount)}
</Text> </Text>
@@ -152,7 +152,7 @@ export default function MediaMissingWidget({
{showQueued && ( {showQueued && (
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}> <Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
<Group p="xs" gap="xs"> <Group p="xs" gap="xs">
<IconDownload size="var(--mantine-font-size-md)" /> <IconDownload size={16} />
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{tabLabel(t("tab.queued"), queued.length, queuedCount)} {tabLabel(t("tab.queued"), queued.length, queuedCount)}
</Text> </Text>
@@ -181,12 +181,12 @@ export default function MediaMissingWidget({
</Group> </Group>
<Tabs.List grow> <Tabs.List grow>
{showMissing && ( {showMissing && (
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size="var(--mantine-font-size-sm)" />}> <Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
{tabLabel(t("tab.missing"), missing.length, missingCount)} {tabLabel(t("tab.missing"), missing.length, missingCount)}
</Tabs.Tab> </Tabs.Tab>
)} )}
{showQueued && ( {showQueued && (
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size="var(--mantine-font-size-sm)" />}> <Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size={14} />}>
{tabLabel(t("tab.queued"), queued.length, queuedCount)} {tabLabel(t("tab.queued"), queued.length, queuedCount)}
</Tabs.Tab> </Tabs.Tab>
)} )}
@@ -273,7 +273,7 @@ const InfoDivider = () => (
const Info = ({ icon: Icon, label }: IconAndLabelProps) => { const Info = ({ icon: Icon, label }: IconAndLabelProps) => {
return ( return (
<Group gap={4} wrap="nowrap" miw={0}> <Group gap={4} wrap="nowrap" miw={0}>
{Icon && <Icon size="var(--mantine-font-size-xs)" color={getMantineColor("gray", 5)} style={{ flexShrink: 0 }} />} {Icon && <Icon size={12} color={getMantineColor("gray", 5)} style={{ flexShrink: 0 }} />}
<Text size="xs" c="dimmed" truncate="end"> <Text size="xs" c="dimmed" truncate="end">
{label} {label}
</Text> </Text>
@@ -94,7 +94,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
aria-label={t("action.search.label")} aria-label={t("action.search.label")}
onClick={() => openMediaRequestSearch({ integrationIds })} onClick={() => openMediaRequestSearch({ integrationIds })}
> >
<IconSearch size="var(--mantine-font-size-md)" /> <IconSearch size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
); );
@@ -281,7 +281,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
handleDecision("approve"); handleDecision("approve");
}} }}
> >
<IconThumbUp size="var(--mantine-font-size-md)" /> <IconThumbUp size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={t("pending.decline")}> <Tooltip label={t("pending.decline")}>
@@ -296,7 +296,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
handleDecision("decline"); handleDecision("decline");
}} }}
> >
<IconThumbDown size="var(--mantine-font-size-md)" /> <IconThumbDown size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Group> </Group>
@@ -132,7 +132,7 @@ export default function MediaServerWidget({
<Tooltip label={t(`titles.stats.${stat.name}`)}> <Tooltip label={t(`titles.stats.${stat.name}`)}>
<Card p={0} radius={board.itemRadius} className={classes.card}> <Card p={0} radius={board.itemRadius} className={classes.card}>
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%"> <Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
<stat.icon className="mediaRequests-stats-stat-icon" size="var(--mantine-font-size-md)" /> <stat.icon className="mediaRequests-stats-stat-icon" size={16} />
<Text className="mediaRequests-stats-stat-value" size="md"> <Text className="mediaRequests-stats-stat-value" size="md">
{stat.number} {stat.number}
</Text> </Text>
@@ -226,7 +226,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
aria-label={t("action.search.label")} aria-label={t("action.search.label")}
onClick={() => openMediaRequestSearch({ integrationIds })} onClick={() => openMediaRequestSearch({ integrationIds })}
> >
<IconSearch size="var(--mantine-font-size-md)" /> <IconSearch size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
); );
@@ -23,15 +23,12 @@
.tableViewport :global(th), .tableViewport :global(th),
.tableViewport :global(td) { .tableViewport :global(td) {
padding-block: 8px; padding-block: 4px;
padding-inline: 8px;
overflow: hidden; overflow: hidden;
vertical-align: top;
} }
.tableViewport :global(th) { .tableViewport :global(th) {
background-color: var(--background-color); background-color: var(--background-color);
border-bottom: 1px solid var(--border-color);
} }
.sortButton { .sortButton {
@@ -44,20 +41,7 @@
.cellContent { .cellContent {
min-width: 0; min-width: 0;
}
.userIdentity {
display: grid;
flex: 1 1 0;
grid-template-rows: auto auto;
min-width: 0;
overflow: hidden; overflow: hidden;
row-gap: 1px;
}
.userIdentityLine {
min-width: 0;
line-height: 1.2;
} }
.sessionRow { .sessionRow {
@@ -9,12 +9,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { StreamSession } from "@homarr/integrations"; import type { StreamSession } from "@homarr/integrations";
import { import { getMediaServerColumnVisibility, SessionDetailsPopover } from "./component";
getMediaServerColumnVisibility,
getResolutionLabel,
getSeasonEpisodeParams,
SessionDetailsPopover,
} from "./component";
vi.mock("@homarr/translation/client", () => ({ vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key, useScopedI18n: () => (key: string) => key,
@@ -109,51 +104,7 @@ describe("getMediaServerColumnVisibility", () => {
test("keeps a stable responsive column model from the zero-sized first render", () => { test("keeps a stable responsive column model from the zero-sized first render", () => {
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false }); expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false });
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false }); expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false });
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: false }); expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true }); expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
}); });
}); });
describe("getSeasonEpisodeParams", () => {
test("zero-pads season and episode numbers", () => {
expect(getSeasonEpisodeParams(4, 12)).toEqual({ season: "04", episode: "12" });
});
test("does not pad numbers already two digits or longer", () => {
expect(getSeasonEpisodeParams(12, 104)).toEqual({ season: "12", episode: "104" });
});
test("returns null when either number is missing", () => {
expect(getSeasonEpisodeParams(undefined, 12)).toBeNull();
expect(getSeasonEpisodeParams(4, null)).toBeNull();
expect(getSeasonEpisodeParams(null, undefined)).toBeNull();
});
});
describe("getResolutionLabel", () => {
test("labels a standard 16:9 resolution by its exact height", () => {
expect(getResolutionLabel(1920, 1080)).toBe("1080p");
expect(getResolutionLabel(1280, 720)).toBe("720p");
});
test("classifies a cinemascope source by its width, not its cropped height", () => {
// A 1920x804 encode (2.35:1, cropped top and bottom) is still a 1080p source.
expect(getResolutionLabel(1920, 804)).toBe("1080p");
expect(getResolutionLabel(1280, 640)).toBe("720p");
});
test("rounds a near-standard resolution up to its nearest tier", () => {
expect(getResolutionLabel(1912, 796)).toBe("1080p");
});
test("falls back to the raw height when it doesn't clear any known tier", () => {
expect(getResolutionLabel(100, 100)).toBe("100p");
});
test("classifies a portrait video by its width, not its long-axis height", () => {
// A 1080x1920 portrait phone recording is still a 1080p source, not a 1440p one.
expect(getResolutionLabel(1080, 1920)).toBe("1080p");
expect(getResolutionLabel(720, 1280)).toBe("720p");
});
});
+42 -124
View File
@@ -51,9 +51,7 @@ type SortState = { column: SortColumn; descending: boolean } | null;
export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({ export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({
user: isAdvanced || width >= 300, user: isAdvanced || width >= 300,
// The user (200px) and status (190px) columns together need a floor here, otherwise the status: isAdvanced || width >= 420,
// currentlyPlaying column - the primary content - gets squeezed to almost nothing.
status: isAdvanced || width >= 540,
}); });
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus { function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
@@ -79,31 +77,6 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | null {
return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`; return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`;
} }
const RESOLUTION_TIER_HEIGHTS = [4320, 2160, 1440, 1080, 720, 480, 360, 240] as const;
export function getResolutionLabel(width: number, height: number): string {
// Classify off the short axis (height for landscape, width for portrait) so orientation
// doesn't flip which dimension drives the label. Letterboxed/cinemascope sources are cropped
// shorter than their nominal resolution class (a 1920x804 encode is still "1080p"), so derive
// a floor for the short axis from the long axis assuming 16:9 - matches how Plex/Jellyfin label these themselves.
const isPortrait = height > width;
const shortAxis = isPortrait ? width : height;
const longAxis = isPortrait ? height : width;
const effectiveShortAxis = Math.max(shortAxis, Math.round((longAxis * 9) / 16));
const tier = RESOLUTION_TIER_HEIGHTS.find((candidate) => effectiveShortAxis >= candidate * 0.9);
return `${tier ?? effectiveShortAxis}p`;
}
export function getSeasonEpisodeParams(
seasonNumber: number | null | undefined,
episodeNumber: number | null | undefined,
): { season: string; episode: string } | null {
if (seasonNumber === null || seasonNumber === undefined || episodeNumber === null || episodeNumber === undefined) {
return null;
}
return { season: String(seasonNumber).padStart(2, "0"), episode: String(episodeNumber).padStart(2, "0") };
}
const getSessionSortValue = (session: StreamSession, column: SortColumn) => { const getSessionSortValue = (session: StreamSession, column: SortColumn) => {
if (column === "user") return session.user?.username ?? session.sessionName; if (column === "user") return session.user?.username ?? session.sessionName;
if (column === "currentlyPlaying") return session.currentlyPlaying?.name ?? session.sessionName; if (column === "currentlyPlaying") return session.currentlyPlaying?.name ?? session.sessionName;
@@ -134,33 +107,27 @@ function StreamTableHeader({
sortable, sortable,
sort, sort,
onSort, onSort,
width,
}: { }: {
column: SortColumn; column: SortColumn;
label: string; label: string;
sortable: boolean; sortable: boolean;
sort: SortState; sort: SortState;
onSort: (column: SortColumn) => void; onSort: (column: SortColumn) => void;
width?: number;
}) { }) {
const active = sort?.column === column; const active = sort?.column === column;
const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp; const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp;
return ( return (
<Table.Th w={width} aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}> <Table.Th aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
{sortable ? ( {sortable ? (
<UnstyledButton className={classes.sortButton} onClick={() => onSort(column)}> <UnstyledButton className={classes.sortButton} onClick={() => onSort(column)}>
<Text component="span" size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate> <Text component="span" size="xs" fw={600} truncate>
{label} {label}
</Text> </Text>
<SortIcon <SortIcon size={13} aria-hidden />
size="var(--mantine-font-size-xs)"
aria-hidden
color={active ? undefined : "var(--mantine-color-dimmed)"}
/>
</UnstyledButton> </UnstyledButton>
) : ( ) : (
<Text size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate> <Text size="xs" fw={600} truncate>
{label} {label}
</Text> </Text>
)} )}
@@ -218,14 +185,12 @@ export default function MediaServerWidget({
current?.column === column ? { column, descending: !current.descending } : { column, descending: false }, current?.column === column ? { column, descending: !current.descending } : { column, descending: false },
); );
const uniqueIntegrations = currentStreams const uniqueIntegrations = currentStreams.map((stream) => ({
.filter((stream) => stream.sessions.length > 0) integrationId: stream.integrationId,
.map((stream) => ({ integrationKind: stream.integrationKind,
integrationId: stream.integrationId, integrationIcon: getIconUrl(stream.integrationKind),
integrationKind: stream.integrationKind, integrationName: stream.integrationName,
integrationIcon: getIconUrl(stream.integrationKind), }));
integrationName: stream.integrationName,
}));
const playingCount = flatSessions.filter( const playingCount = flatSessions.filter(
(session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused", (session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused",
@@ -260,7 +225,7 @@ export default function MediaServerWidget({
onChange={(event) => setSearch(event.currentTarget.value)} onChange={(event) => setSearch(event.currentTarget.value)}
placeholder={tGlobal("search.placeholder")} placeholder={tGlobal("search.placeholder")}
aria-label={tGlobal("search.placeholder")} aria-label={tGlobal("search.placeholder")}
leftSection={<IconSearch size="var(--mantine-font-size-xs)" aria-hidden />} leftSection={<IconSearch size={14} aria-hidden />}
/> />
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} /> <WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} /> <IntegrationErrorIndicator results={currentStreams} />
@@ -288,7 +253,6 @@ export default function MediaServerWidget({
sortable={isAdvanced} sortable={isAdvanced}
sort={sort} sort={sort}
onSort={toggleSort} onSort={toggleSort}
width={200}
/> />
)} )}
<StreamTableHeader <StreamTableHeader
@@ -305,7 +269,6 @@ export default function MediaServerWidget({
sortable={isAdvanced} sortable={isAdvanced}
sort={sort} sort={sort}
onSort={toggleSort} onSort={toggleSort}
width={190}
/> />
)} )}
</Table.Tr> </Table.Tr>
@@ -317,12 +280,6 @@ export default function MediaServerWidget({
const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding); const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding);
const location = showLocation ? currentlyPlaying?.location : null; const location = showLocation ? currentlyPlaying?.location : null;
const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying?.metadata?.bitrateKbps) : null; const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying?.metadata?.bitrateKbps) : null;
// The transcoding resolution reflects what's actually being streamed - fall back to the
// source's own resolution when it isn't set (direct play, or a transcode that only
// touches audio/container and leaves the video resolution untouched).
const resolution =
currentlyPlaying?.metadata?.transcoding.resolution ?? currentlyPlaying?.metadata?.video.resolution;
const resolutionLabel = resolution ? getResolutionLabel(resolution.width, resolution.height) : null;
const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId)); const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId));
return ( return (
@@ -347,13 +304,13 @@ export default function MediaServerWidget({
> >
{columnVisibility.user && ( {columnVisibility.user && (
<Table.Td> <Table.Td>
<Group gap="xs" wrap="nowrap" w="100%"> <Group gap="xs" wrap="nowrap">
<Avatar size={28} src={session.user?.profilePictureUrl} style={{ flexShrink: 0 }} /> <Avatar size={28} src={session.user?.profilePictureUrl} />
<Stack gap={2} className={classes.cellContent}> <Stack gap={0} className={classes.cellContent}>
<Text size="xs" truncate> <Text size="xs" truncate>
{session.user?.username ?? t("items.unknownUser")} {session.user?.username ?? t("items.unknownUser")}
</Text> </Text>
<Text size="xs" c="dimmed" truncate> <Text size="10px" c="dimmed" truncate>
{session.sessionName} {session.sessionName}
</Text> </Text>
</Stack> </Stack>
@@ -372,33 +329,22 @@ export default function MediaServerWidget({
{columnVisibility.status && ( {columnVisibility.status && (
<Table.Td> <Table.Td>
{currentlyPlaying && ( {currentlyPlaying && (
<Stack gap={4} align="flex-start" w="100%" className={classes.cellContent}> <Stack gap={4} align="flex-start" className={classes.cellContent}>
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%"> <Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}> {t(`items.${status}` as never)}
{t(`items.${status}` as never)} </Badge>
</Badge> {(location ?? bitrateLabel) && (
{location && (
<Group gap={4} align="center" wrap="nowrap">
{location === "lan" ? (
<IconWifi size="var(--mantine-font-size-xs)" />
) : (
<IconWorld size="var(--mantine-font-size-xs)" />
)}
<Text size="xs" c="dimmed" tt="uppercase">
{location}
</Text>
</Group>
)}
</Group>
{(resolutionLabel ?? bitrateLabel) && (
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%"> <Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
{resolutionLabel && ( <Group gap={4} align="center" wrap="nowrap">
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}> {location && (location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
{resolutionLabel} {location && (
</Text> <Text size="10px" c="dimmed" tt="uppercase">
)} {location}
</Text>
)}
</Group>
{bitrateLabel && ( {bitrateLabel && (
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}> <Text size="10px" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{bitrateLabel} {bitrateLabel}
</Text> </Text>
)} )}
@@ -425,42 +371,25 @@ export default function MediaServerWidget({
borderTop: "1px solid var(--border-color)", borderTop: "1px solid var(--border-color)",
}} }}
> >
<Group gap={6} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} /> <IconVideo size={16} style={{ flexShrink: 0 }} />
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}> <Text size="sm" style={{ whiteSpace: "nowrap" }}>
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
count: flatSessions.length, count: flatSessions.length,
})} })}
</Text> </Text>
{totalBitrateLabel && (isAdvanced || width >= 300) && ( {totalBitrateLabel && (isAdvanced || width >= 300) && (
<> <Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}> {t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
• </Text>
</Text>
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
</Text>
</>
)} )}
</Group> </Group>
<Group gap={6}> <Group gap="xs">
{uniqueIntegrations.map((integration) => ( {uniqueIntegrations.map((integration) => (
<Group <Group key={integration.integrationId} gap="xs" align="center">
key={integration.integrationId} <Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
gap={6}
align="center"
wrap="nowrap"
pl={2}
pr={8}
py={2}
style={{
backgroundColor: "var(--mantine-color-default-hover)",
borderRadius: 999,
}}
>
<Avatar className="media-server-icon" src={integration.integrationIcon} radius="xl" size={18} />
{(isAdvanced || width >= 480) && ( {(isAdvanced || width >= 480) && (
<Text className="media-server-name" size="xs" fw={500} truncate="end"> <Text className="media-server-name" size="sm" truncate="end">
{integration.integrationName} {integration.integrationName}
</Text> </Text>
)} )}
@@ -495,25 +424,14 @@ function CurrentlyPlaying({ item }: { item: StreamSession }) {
: null; : null;
const remainingMinutes = const remainingMinutes =
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null; positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
const seasonEpisodeParams = getSeasonEpisodeParams(currentlyPlaying.seasonNumber, currentlyPlaying.episodeNumber);
const seasonEpisodeLabel = seasonEpisodeParams ? t("items.seasonEpisode", seasonEpisodeParams) : null;
return ( return (
<Stack gap={6} style={{ minWidth: 0 }}> <Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}> <Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
<Icon <Icon size={16} color={isPaused ? "var(--mantine-color-yellow-6)" : undefined} style={{ flexShrink: 0 }} />
size="var(--mantine-font-size-xs)"
color={isPaused ? "var(--mantine-color-yellow-6)" : undefined}
style={{ flexShrink: 0 }}
/>
<Text size="xs" lineClamp={1} style={{ minWidth: 0 }}> <Text size="xs" lineClamp={1} style={{ minWidth: 0 }}>
{currentlyPlaying.name} {currentlyPlaying.name}
</Text> </Text>
{seasonEpisodeLabel && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{seasonEpisodeLabel}
</Text>
)}
{isPaused ? ( {isPaused ? (
<Text size="xs" c="yellow" style={{ flexShrink: 0 }}> <Text size="xs" c="yellow" style={{ flexShrink: 0 }}>
{t("items.paused")} {t("items.paused")}
@@ -153,7 +153,7 @@ export default function MediaTranscodingWidget({
return { return {
label: ( label: (
<Center style={{ gap: 4 }}> <Center style={{ gap: 4 }}>
<Icon size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} /> <Icon size={12} />
{footerLayout.showTabLabels ? ( {footerLayout.showTabLabels ? (
<Text span size="xs"> <Text span size="xs">
{t(`tab.${value}`)} {t(`tab.${value}`)}
@@ -259,7 +259,7 @@ export const resolveQueuePagination = (
const AdvancedPanel = ({ title, icon: Icon, children }: { title: string; icon: TablerIcon; children: ReactNode }) => ( const AdvancedPanel = ({ title, icon: Icon, children }: { title: string; icon: TablerIcon; children: ReactNode }) => (
<Paper withBorder radius="sm" p="xs" mih={280} style={{ display: "flex", flexDirection: "column" }}> <Paper withBorder radius="sm" p="xs" mih={280} style={{ display: "flex", flexDirection: "column" }}>
<Group gap="xs" mb="xs"> <Group gap="xs" mb="xs">
<Icon size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} /> <Icon size={16} />
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{title} {title}
</Text> </Text>
@@ -27,7 +27,7 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
<Popover.Target> <Popover.Target>
<ActionIcon className={actionTargetClasses.root} variant="subtle" size="sm" aria-label={t("title")}> <ActionIcon className={actionTargetClasses.root} variant="subtle" size="sm" aria-label={t("title")}>
<Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex"> <Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex">
<IconHeartbeat size="var(--mantine-font-size-md)" /> <IconHeartbeat size={16} />
</Indicator> </Indicator>
</ActionIcon> </ActionIcon>
</Popover.Target> </Popover.Target>
@@ -38,7 +38,7 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
> >
<Stack gap="sm" align="center"> <Stack gap="sm" align="center">
<Group gap="xs"> <Group gap="xs">
<IconHeartbeat size="var(--mantine-font-size-lg)" /> <IconHeartbeat size={18} />
<Text size="sm">{t("title")}</Text> <Text size="sm">{t("title")}</Text>
</Group> </Group>
<Divider <Divider
@@ -59,11 +59,11 @@ export function QueuePanel(props: QueuePanelProps) {
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
{item.type === "transcode" ? ( {item.type === "transcode" ? (
<Tooltip label={t("table.transcode")}> <Tooltip label={t("table.transcode")}>
<IconTransform size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} /> <IconTransform size={12} />
</Tooltip> </Tooltip>
) : ( ) : (
<Tooltip label={t("table.healthCheck")}> <Tooltip label={t("table.healthCheck")}>
<IconHeartbeat size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} /> <IconHeartbeat size={12} />
</Tooltip> </Tooltip>
)} )}
<Text lineClamp={1} size="xs"> <Text lineClamp={1} size="xs">
@@ -94,7 +94,7 @@ function StatisticItem(props: StatisticItemProps) {
<Tooltip label={props.label}> <Tooltip label={props.label}>
<Card p={0} radius={board.itemRadius} miw={48} flex={1}> <Card p={0} radius={board.itemRadius} miw={48} flex={1}>
<Group justify="center" align="center" gap="xs" w="100%" wrap="nowrap"> <Group justify="center" align="center" gap="xs" w="100%" wrap="nowrap">
<props.icon size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} /> <props.icon size={16} style={{ minWidth: 16 }} />
<Text size="md">{props.value}</Text> <Text size="md">{props.value}</Text>
</Group> </Group>
</Card> </Card>
@@ -66,11 +66,11 @@ export function WorkersPanel(props: WorkersPanelProps) {
<div> <div>
{worker.jobType === "transcode" ? ( {worker.jobType === "transcode" ? (
<Tooltip label={t("table.transcode")}> <Tooltip label={t("table.transcode")}>
<IconTransform size="var(--mantine-font-size-sm)" style={{ flexShrink: 0 }} /> <IconTransform size={14} />
</Tooltip> </Tooltip>
) : ( ) : (
<Tooltip label={t("table.healthCheck")}> <Tooltip label={t("table.healthCheck")}>
<IconHeartbeat size="var(--mantine-font-size-sm)" style={{ flexShrink: 0 }} /> <IconHeartbeat size={14} />
</Tooltip> </Tooltip>
)} )}
</div> </div>
@@ -76,7 +76,7 @@ export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormPro
return ( return (
<Stack> <Stack>
<Alert icon={<IconInfoCircle size="var(--mantine-font-size-md)" />} color="blue" variant="light"> <Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.app.propagationNotice")} {t("item.edit.app.propagationNotice")}
</Alert> </Alert>
<AppForm <AppForm
+49 -131
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { ComponentType, FormEvent } from "react"; import type { FormEvent } from "react";
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core"; import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
import { schemaResolver } from "@mantine/form"; import { schemaResolver } from "@mantine/form";
import { IconArrowLeft, IconPencil } from "@tabler/icons-react"; import { IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
@@ -35,16 +35,6 @@ export interface WidgetEditModalState {
advancedOptions: BoardItemAdvancedOptions; advancedOptions: BoardItemAdvancedOptions;
} }
export interface EmbeddedIntegrationEditFormHandle {
submitIfDirty: () => Promise<boolean>;
}
export interface EmbeddedIntegrationEditFormProps {
integrationId: string;
handleRef: React.Ref<EmbeddedIntegrationEditFormHandle>;
onSuccess?: () => void;
}
export interface WidgetEditModalProps<TSort extends WidgetKind> { export interface WidgetEditModalProps<TSort extends WidgetKind> {
kind: TSort; kind: TSort;
definition: WidgetDefinition; definition: WidgetDefinition;
@@ -56,7 +46,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
itemId?: string; itemId?: string;
boardId?: string; boardId?: string;
appId?: string; appId?: string;
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>; onEditIntegration?: (integrationId: string) => void;
onIntegrationSaved?: () => void; onIntegrationSaved?: () => void;
} }
@@ -71,12 +61,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const { data: session } = useSession(); const { data: session } = useSession();
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions); const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null); const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
const integrationEditHandles = useRef(new Map<string, EmbeddedIntegrationEditFormHandle>());
const integrationEditRefCallbacks = useRef(
new Map<string, (handle: EmbeddedIntegrationEditFormHandle | null) => void>(),
);
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
z.config({ z.config({
@@ -134,24 +118,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId); const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false; const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false;
const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds); const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds);
const editableIntegrationIds = selectedIntegrations const showIntegrationTab = Boolean(innerProps.onEditIntegration) && selectedIntegrations.length > 0;
.filter((integration) => canModifyAllIntegrations || integration.permissions?.hasFullAccess === true)
.map((integration) => integration.id);
let soleEditableIntegrationId: string | null = null;
if (selectedIntegrations.length === 1) {
soleEditableIntegrationId = editableIntegrationIds[0] ?? null;
}
let displayedIntegrationId = soleEditableIntegrationId;
if (activeIntegrationId && editableIntegrationIds.includes(activeIntegrationId)) {
displayedIntegrationId = activeIntegrationId;
}
const integrationIdsToRender = mountedIntegrationIds.filter((id) => editableIntegrationIds.includes(id));
if (displayedIntegrationId && !integrationIdsToRender.includes(displayedIntegrationId)) {
integrationIdsToRender.push(displayedIntegrationId);
}
const IntegrationEditForm = innerProps.integrationEditForm;
const showIntegrationTab = Boolean(IntegrationEditForm) && selectedIntegrations.length > 0;
const showResourceTabs = showAppTab || showIntegrationTab; const showResourceTabs = showAppTab || showIntegrationTab;
const [activeTab, setActiveTab] = useState<string | null>("widget"); const [activeTab, setActiveTab] = useState<string | null>("widget");
@@ -160,31 +127,15 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
if (activeTab === "app" && !showAppTab) setActiveTab("widget"); if (activeTab === "app" && !showAppTab) setActiveTab("widget");
}, [activeTab, showAppTab, showIntegrationTab]); }, [activeTab, showAppTab, showIntegrationTab]);
const getIntegrationEditRef = (integrationId: string) => { const handleTabChange = (value: string | null) => {
const existingCallback = integrationEditRefCallbacks.current.get(integrationId); if (value === "integration" && selectedIntegrations.length === 1) {
if (existingCallback) { const integration = selectedIntegrations[0];
return existingCallback; if (integration) {
innerProps.onEditIntegration?.(integration.id);
return;
}
} }
setActiveTab(value);
const callback = (handle: EmbeddedIntegrationEditFormHandle | null) => {
if (handle) {
integrationEditHandles.current.set(integrationId, handle);
} else {
integrationEditHandles.current.delete(integrationId);
}
};
integrationEditRefCallbacks.current.set(integrationId, callback);
return callback;
};
const beginEditingIntegration = (integrationId: string) => {
setMountedIntegrationIds((current) => {
if (current.includes(integrationId)) {
return current;
}
return [...current, integrationId];
});
setActiveIntegrationId(integrationId);
}; };
const handleSubmit = form.onSubmit(async (values) => { const handleSubmit = form.onSubmit(async (values) => {
@@ -196,13 +147,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
return; return;
} }
for (const integrationEditHandle of integrationEditHandles.current.values()) {
const integrationSaved = await integrationEditHandle.submitIfDirty();
if (!integrationSaved) {
return;
}
}
innerProps.onSuccessfulEdit({ innerProps.onSuccessfulEdit({
...values, ...values,
advancedOptions, advancedOptions,
@@ -296,7 +240,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
<FormProvider form={form}> <FormProvider form={form}>
{showResourceTabs ? ( {showResourceTabs ? (
<Stack> <Stack>
<Tabs value={activeTab} onChange={setActiveTab}> <Tabs value={activeTab} onChange={handleTabChange}>
<Tabs.List grow> <Tabs.List grow>
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab> <Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>} {showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
@@ -313,68 +257,42 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
{showIntegrationTab && ( {showIntegrationTab && (
<Tabs.Panel value="integration" pt="md"> <Tabs.Panel value="integration" pt="md">
<Stack> <Stack>
{displayedIntegrationId === null && ( <Text size="sm" c="dimmed">
<> {t("item.edit.integration.description")}
<Text size="sm" c="dimmed"> </Text>
{t("item.edit.integration.description")} {selectedIntegrations.map((integration) => {
</Text> const canEdit = canModifyAllIntegrations || integration.permissions?.hasFullAccess === true;
{selectedIntegrations.map((integration) => { return (
const canEdit = editableIntegrationIds.includes(integration.id); <Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
return ( <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm"> <IntegrationAvatar kind={integration.kind} size="sm" />
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}> <Stack gap={0} style={{ minWidth: 0 }}>
<IntegrationAvatar kind={integration.kind} size="sm" /> <Text fw={600} size="sm" truncate>
<Stack gap={0} style={{ minWidth: 0 }}> {integration.name}
<Text fw={600} size="sm" truncate> </Text>
{integration.name} <Text size="xs" c="dimmed" truncate>
</Text> {integration.url}
<Text size="xs" c="dimmed" truncate> </Text>
{integration.url} </Stack>
</Text> </Group>
</Stack> {canEdit ? (
</Group> <Button
{canEdit ? ( type="button"
<Button variant="light"
type="button" leftSection={<IconPencil size={16} />}
variant="light" onClick={() => innerProps.onEditIntegration?.(integration.id)}
leftSection={<IconPencil size={16} />} aria-label={t("item.edit.integration.editLabel", { name: integration.name })}
onClick={() => beginEditingIntegration(integration.id)} >
aria-label={t("item.edit.integration.editLabel", { name: integration.name })} {t("item.edit.integration.action")}
> </Button>
{t("item.edit.integration.action")} ) : (
</Button> <Text size="xs" c="dimmed" ta="end">
) : ( {t("item.edit.integration.fullAccessRequired")}
<Text size="xs" c="dimmed" ta="end"> </Text>
{t("item.edit.integration.fullAccessRequired")} )}
</Text> </Group>
)} );
</Group> })}
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{t("common.action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack> </Stack>
</Tabs.Panel> </Tabs.Panel>
)} )}
@@ -111,7 +111,7 @@ export default function NetworkControllerSummaryWidget({
<StatusIcon <StatusIcon
status={section.status} status={section.status}
label={statusLabels[getBinaryStatusKey(section.status)]} label={statusLabels[getBinaryStatusKey(section.status)]}
size="var(--mantine-font-size-md)" size={16}
/> />
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{getMatrixSectionLabel(section.key, t)} {getMatrixSectionLabel(section.key, t)}
@@ -210,7 +210,7 @@ export default function NetworkControllerSummaryWidget({
<StatusIcon <StatusIcon
status={summary.lan.status} status={summary.lan.status}
label={statusLabels[getBinaryStatusKey(summary.lan.status)]} label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
size="var(--mantine-font-size-xl)" size={20}
/> />
} }
> >
@@ -256,15 +256,7 @@ const getMatrixSectionLabel = (key: NetworkControllerMatrixSectionKey, t: Transl
return t(`widget.networkControllerSummary.card.${key}`); return t(`widget.networkControllerSummary.card.${key}`);
}; };
const StatusIcon = ({ const StatusIcon = ({ status, label, size }: { status?: "enabled" | "disabled"; label: string; size: number }) => {
status,
label,
size,
}: {
status?: "enabled" | "disabled";
label: string;
size: number | string;
}) => {
const mantineTheme = useMantineTheme(); const mantineTheme = useMantineTheme();
if (status === "enabled") { if (status === "enabled") {
return <IconCircleCheckFilled aria-label={label} size={size} color={mantineTheme.colors.green[6]} />; return <IconCircleCheckFilled aria-label={label} size={size} color={mantineTheme.colors.green[6]} />;
-16
View File
@@ -69,15 +69,6 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
}; };
} }
interface DynamicMultiSelectInput extends CommonInput<string[]> {
maxValues?: number;
useOptions: () => {
data: DynamicSelectOption[];
isPending: boolean;
isError: boolean;
};
}
interface IntegrationSelectInput extends CommonInput<string> { interface IntegrationSelectInput extends CommonInput<string> {
clearable?: boolean; clearable?: boolean;
searchable?: boolean; searchable?: boolean;
@@ -150,13 +141,6 @@ const optionsFactory = {
useOptions: input.useOptions, useOptions: input.useOptions,
withDescription: input.withDescription ?? false, withDescription: input.withDescription ?? false,
}), }),
dynamicMultiSelect: (input: DynamicMultiSelectInput) => ({
type: "dynamicMultiSelect" as const,
defaultValue: input.defaultValue ?? [],
maxValues: input.maxValues,
useOptions: input.useOptions,
withDescription: input.withDescription ?? false,
}),
select: <const TOptions extends SelectOption[]>(input: SelectInput<TOptions>) => ({ select: <const TOptions extends SelectOption[]>(input: SelectInput<TOptions>) => ({
type: "select" as const, type: "select" as const,
defaultValue: (input.defaultValue ?? input.options[0]) as inferSelectOptionValue<TOptions[number]>, defaultValue: (input.defaultValue ?? input.options[0]) as inferSelectOptionValue<TOptions[number]>,
+1 -1
View File
@@ -445,7 +445,7 @@ function CompactStatGrid({
style={{ "--stat-bg": severityToMantineBgColor(severity) } as CSSProperties} style={{ "--stat-bg": severityToMantineBgColor(severity) } as CSSProperties}
> >
{showIcon && ( {showIcon && (
<Icon className={classes.statIcon} size="var(--mantine-font-size-lg)" stroke={1.5} color={severityToIconColor(severity)} /> <Icon className={classes.statIcon} size={18} stroke={1.5} color={severityToIconColor(severity)} />
)} )}
<span className={classes.compactStatValue}> <span className={classes.compactStatValue}>
<Text component="span" fw={700} size="inherit" c={severityToMantineColor(severity)}> <Text component="span" fw={700} size="inherit" c={severityToMantineColor(severity)}>
+9 -9
View File
@@ -303,20 +303,20 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
{hasError ? ( {hasError ? (
<IconTriangleFilled <IconTriangleFilled
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-error" className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-error"
size="var(--mantine-font-size-xs)" size={10}
color="var(--mantine-color-red-filled)" color="var(--mantine-color-red-filled)"
/> />
) : repository.viewed ? ( ) : repository.viewed ? (
<IconCheck <IconCheck
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-confirmed" className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-confirmed"
size="var(--mantine-font-size-xs)" size={10}
color="green" color="green"
/> />
) : ( ) : (
(repository.isNewRelease || repository.isStaleRelease) && ( (repository.isNewRelease || repository.isStaleRelease) && (
<IconCircleFilled <IconCircleFilled
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-marker" className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-marker"
size="var(--mantine-font-size-xs)" size={10}
color={ color={
repository.isNewRelease repository.isNewRelease
? "var(--mantine-color-primaryColor-filled)" ? "var(--mantine-color-primaryColor-filled)"
@@ -390,7 +390,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
"releases-repository-details-icon", "releases-repository-details-icon",
"releases-repository-details-icon-preRelease", "releases-repository-details-icon-preRelease",
)} )}
size="var(--mantine-font-size-sm)" size={13}
color={ color={
repository.isPreRelease ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)" repository.isPreRelease ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
} }
@@ -411,7 +411,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
"releases-repository-details-icon", "releases-repository-details-icon",
"releases-repository-details-icon-archived", "releases-repository-details-icon-archived",
)} )}
size="var(--mantine-font-size-sm)" size={13}
color={ color={
repository.isArchived ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)" repository.isArchived ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
} }
@@ -432,7 +432,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
"releases-repository-details-icon", "releases-repository-details-icon",
"releases-repository-details-icon-forked", "releases-repository-details-icon-forked",
)} )}
size="var(--mantine-font-size-sm)" size={13}
color={repository.isFork ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"} color={repository.isFork ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
/> />
</Tooltip> </Tooltip>
@@ -459,7 +459,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
"releases-repository-details-stats-icon", "releases-repository-details-stats-icon",
"releases-repository-details-stats-stars-icon", "releases-repository-details-stats-stars-icon",
)} )}
size="var(--mantine-font-size-xs)" size={12}
color={!repository.starsCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"} color={!repository.starsCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
/> />
<Text <Text
@@ -501,7 +501,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
"releases-repository-details-stats-icon", "releases-repository-details-stats-icon",
"releases-repository-details-stats-forks-icon", "releases-repository-details-stats-forks-icon",
)} )}
size="var(--mantine-font-size-xs)" size={12}
color={!repository.forksCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"} color={!repository.forksCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
/> />
<Text <Text
@@ -543,7 +543,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
"releases-repository-details-stats-icon", "releases-repository-details-stats-icon",
"releases-repository-details-stats-issues-icon", "releases-repository-details-stats-issues-icon",
)} )}
size="var(--mantine-font-size-xs)" size={12}
color={!repository.openIssues ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"} color={!repository.openIssues ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
/> />
<Text <Text
+2 -2
View File
@@ -73,7 +73,7 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
return ( return (
<Flex direction="column" h="100%" p="xs"> <Flex direction="column" h="100%" p="xs">
{warning && ( {warning && (
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size="var(--mantine-font-size-md)" />} p="xs"> <Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs">
<output>{warning}</output> <output>{warning}</output>
</Alert> </Alert>
)} )}
@@ -103,7 +103,7 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
return ( return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isTiny ? 4 : "xs"}> <ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isTiny ? 4 : "xs"}>
{warning && ( {warning && (
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size="var(--mantine-font-size-md)" />} p="xs" mb="xs"> <Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs" mb="xs">
<output>{warning}</output> <output>{warning}</output>
</Alert> </Alert>
)} )}
+2 -7
View File
@@ -78,19 +78,14 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
)} )}
{compactStaleWarning && displayMode === "compact" && ( {compactStaleWarning && displayMode === "compact" && (
<Group gap={2} wrap="nowrap"> <Group gap={2} wrap="nowrap">
<IconAlertTriangle <IconAlertTriangle aria-hidden size={12} color="var(--mantine-color-orange-light-color)" />
aria-hidden
size="var(--mantine-font-size-xs)"
color="var(--mantine-color-orange-light-color)"
style={{ flexShrink: 0 }}
/>
<Text component="output" size="xs" c="var(--mantine-color-text)" style={{ whiteSpace: "nowrap" }}> <Text component="output" size="xs" c="var(--mantine-color-text)" style={{ whiteSpace: "nowrap" }}>
{compactStaleWarning} {compactStaleWarning}
</Text> </Text>
</Group> </Group>
)} )}
{staleWarning && displayMode === "advanced" && ( {staleWarning && displayMode === "advanced" && (
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size="var(--mantine-font-size-md)" />} p="xs"> <Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs">
<output>{staleWarning}</output> <output>{staleWarning}</output>
</Alert> </Alert>
)} )}
@@ -18,14 +18,14 @@ export function StatsBar({
return ( return (
<SimpleGrid cols={cols} spacing="xs"> <SimpleGrid cols={cols} spacing="xs">
<StatCard icon={<IconVideo size="var(--mantine-font-size-md)" />} label={t("stats.activeStreams")} value={stats.activeStreams} /> <StatCard icon={<IconVideo size={16} />} label={t("stats.activeStreams")} value={stats.activeStreams} />
<StatCard icon={<IconUsers size="var(--mantine-font-size-md)" />} label={t("stats.totalUsers")} value={stats.totalUsers} /> <StatCard icon={<IconUsers size={16} />} label={t("stats.totalUsers")} value={stats.totalUsers} />
<StatCard <StatCard
icon={<IconDevices size="var(--mantine-font-size-md)" />} icon={<IconDevices size={16} />}
label={t("stats.transcodes")} label={t("stats.transcodes")}
value={`${summary.transcodes}/${summary.total}`} value={`${summary.transcodes}/${summary.total}`}
/> />
<StatCard icon={<IconNetwork size="var(--mantine-font-size-md)" />} label={t("stats.bandwidth")} value={summary.totalBitrate} /> <StatCard icon={<IconNetwork size={16} />} label={t("stats.bandwidth")} value={summary.totalBitrate} />
</SimpleGrid> </SimpleGrid>
); );
} }
@@ -88,9 +88,9 @@ function StreamCard({
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}> <Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
{stream.state === "playing" ? ( {stream.state === "playing" ? (
<IconPlayerPlay size="var(--mantine-font-size-sm)" color="var(--mantine-color-green-6)" /> <IconPlayerPlay size={14} color="var(--mantine-color-green-6)" />
) : ( ) : (
<IconPlayerPause size="var(--mantine-font-size-sm)" color="var(--mantine-color-yellow-6)" /> <IconPlayerPause size={14} color="var(--mantine-color-yellow-6)" />
)} )}
<Text size="sm" fw={600} lineClamp={1}> <Text size="sm" fw={600} lineClamp={1}>
{stream.username} {stream.username}
@@ -34,7 +34,7 @@ export function ViolationsList({
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}> <Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
<IconAlertTriangle <IconAlertTriangle
size="var(--mantine-font-size-sm)" size={14}
color={ color={
violation.severity === "high" violation.severity === "high"
? "var(--mantine-color-red-6)" ? "var(--mantine-color-red-6)"
+4 -4
View File
@@ -308,7 +308,7 @@ function ProtocolCard({ protocol, data }: { protocol: ProtocolKey; data: Traefik
{rows.map(({ key, icon: Icon, summary }) => ( {rows.map(({ key, icon: Icon, summary }) => (
<div key={key} className={classes.protocolRow}> <div key={key} className={classes.protocolRow}>
<Group gap={4} wrap="nowrap" className={classes.protocolLabel}> <Group gap={4} wrap="nowrap" className={classes.protocolLabel}>
<Icon size="var(--mantine-font-size-sm)" /> <Icon size={14} />
<Text>{t(`resource.${key}`)}</Text> <Text>{t(`resource.${key}`)}</Text>
</Group> </Group>
<StatusCount summary={summary} /> <StatusCount summary={summary} />
@@ -354,7 +354,7 @@ function HealthBadge({ errors, warnings }: { errors: number; warnings: number })
if (errors > 0) { if (errors > 0) {
return ( return (
<Badge color="red" variant="light" leftSection={<IconAlertTriangle size="var(--mantine-font-size-xs)" />} radius="sm"> <Badge color="red" variant="light" leftSection={<IconAlertTriangle size={12} />} radius="sm">
{t("status.errors", { count: errors })} {t("status.errors", { count: errors })}
</Badge> </Badge>
); );
@@ -362,14 +362,14 @@ function HealthBadge({ errors, warnings }: { errors: number; warnings: number })
if (warnings > 0) { if (warnings > 0) {
return ( return (
<Badge color="yellow" variant="light" leftSection={<IconAlertTriangle size="var(--mantine-font-size-xs)" />} radius="sm"> <Badge color="yellow" variant="light" leftSection={<IconAlertTriangle size={12} />} radius="sm">
{t("status.warnings", { count: warnings })} {t("status.warnings", { count: warnings })}
</Badge> </Badge>
); );
} }
return ( return (
<Badge color="green" variant="light" leftSection={<IconCircleCheck size="var(--mantine-font-size-xs)" />} radius="sm"> <Badge color="green" variant="light" leftSection={<IconCircleCheck size={12} />} radius="sm">
{t("status.healthy")} {t("status.healthy")}
</Badge> </Badge>
); );
+3 -3
View File
@@ -159,7 +159,7 @@ function VpnInfoColumn({
</Text> </Text>
{!dense && (city || country) && ( {!dense && (city || country) && (
<Group gap={4} justify="flex-start" wrap="nowrap"> <Group gap={4} justify="flex-start" wrap="nowrap">
<IconMapPin aria-hidden size="var(--mantine-font-size-xs)" /> <IconMapPin aria-hidden size={10} />
<Text fs="italic" fw={500} size="sm" lh={1.2} lineClamp={1}> <Text fs="italic" fw={500} size="sm" lh={1.2} lineClamp={1}>
{[city, country].filter(Boolean).join(", ")} {[city, country].filter(Boolean).join(", ")}
</Text> </Text>
@@ -176,7 +176,7 @@ function VpnProviderDetails({ provider, protocol }: { provider: string; protocol
<Text fz="xs" lh={1.2} tt="capitalize" lineClamp={1}> <Text fz="xs" lh={1.2} tt="capitalize" lineClamp={1}>
{provider} {provider}
</Text> </Text>
<IconArrowsExchange stroke={1} size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} /> <IconArrowsExchange stroke={1} size={12} style={{ flexShrink: 0 }} />
<Text fz="xs" lh={1.2} tt="capitalize" lineClamp={1}> <Text fz="xs" lh={1.2} tt="capitalize" lineClamp={1}>
{protocol} {protocol}
</Text> </Text>
@@ -196,7 +196,7 @@ function DnsStatusBadge({ status }: { status: string }) {
color={getStatusColor(status)} color={getStatusColor(status)}
size="xs" size="xs"
radius="xl" radius="xl"
leftSection={<StatusIcon aria-hidden size="var(--mantine-font-size-xs)" />} leftSection={<StatusIcon aria-hidden size={9} />}
> >
DNS DNS
<VisuallyHidden> {statusLabel}</VisuallyHidden> <VisuallyHidden> {statusLabel}</VisuallyHidden>
+14 -14
View File
@@ -116,11 +116,11 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
<Group gap="lg" wrap="wrap"> <Group gap="lg" wrap="wrap">
<Group gap={5} wrap="nowrap"> <Group gap={5} wrap="nowrap">
<IconMapPin size="var(--mantine-font-size-md)" aria-hidden /> <IconMapPin size={16} aria-hidden />
<Text size="sm">{options.location.name}</Text> <Text size="sm">{options.location.name}</Text>
</Group> </Group>
<Group gap={5} wrap="nowrap"> <Group gap={5} wrap="nowrap">
<IconWind size="var(--mantine-font-size-md)" aria-hidden /> <IconWind size={16} aria-hidden />
<Text size="sm"> <Text size="sm">
{t("currentWindSpeed", { {t("currentWindSpeed", {
currentWindSpeed: getPreferredWindSpeed(weather.current.windspeed, options.useImperialSpeed), currentWindSpeed: getPreferredWindSpeed(weather.current.windspeed, options.useImperialSpeed),
@@ -196,7 +196,7 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<IconArrowUpRight size="var(--mantine-font-size-sm)" aria-hidden /> <IconArrowUpRight size={15} aria-hidden />
<Text size="sm"> <Text size="sm">
{getPreferredUnit( {getPreferredUnit(
dayWeather.maxTemp, dayWeather.maxTemp,
@@ -207,7 +207,7 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
/ /
</Text> </Text>
<IconArrowDownRight size="var(--mantine-font-size-sm)" aria-hidden /> <IconArrowDownRight size={15} aria-hidden />
<Text size="sm"> <Text size="sm">
{getPreferredUnit( {getPreferredUnit(
dayWeather.minTemp, dayWeather.minTemp,
@@ -219,13 +219,13 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={5} wrap="nowrap"> <Group gap={5} wrap="nowrap">
<IconDroplets size="var(--mantine-font-size-sm)" aria-hidden /> <IconDroplets size={15} aria-hidden />
<Text size="sm">{dayWeather.humidity === undefined ? "?" : `${dayWeather.humidity}%`}</Text> <Text size="sm">{dayWeather.humidity === undefined ? "?" : `${dayWeather.humidity}%`}</Text>
</Group> </Group>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={5} wrap="nowrap"> <Group gap={5} wrap="nowrap">
<IconWind size="var(--mantine-font-size-sm)" aria-hidden /> <IconWind size={15} aria-hidden />
<Text size="sm"> <Text size="sm">
{getPreferredWindSpeed(dayWeather.maxWindSpeed, options.useImperialSpeed)} /{" "} {getPreferredWindSpeed(dayWeather.maxWindSpeed, options.useImperialSpeed)} /{" "}
{getPreferredWindSpeed(dayWeather.maxWindGusts, options.useImperialSpeed)} {getPreferredWindSpeed(dayWeather.maxWindGusts, options.useImperialSpeed)}
@@ -235,14 +235,14 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
<Table.Td> <Table.Td>
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<Group gap={3} wrap="nowrap"> <Group gap={3} wrap="nowrap">
<IconSunrise size="var(--mantine-font-size-sm)" aria-hidden /> <IconSunrise size={15} aria-hidden />
<Text size="sm">{getPreferredTime(dayWeather.sunrise, locale)}</Text> <Text size="sm">{getPreferredTime(dayWeather.sunrise, locale)}</Text>
</Group> </Group>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
/ /
</Text> </Text>
<Group gap={3} wrap="nowrap"> <Group gap={3} wrap="nowrap">
<IconSunset size="var(--mantine-font-size-sm)" aria-hidden /> <IconSunset size={15} aria-hidden />
<Text size="sm">{getPreferredTime(dayWeather.sunset, locale)}</Text> <Text size="sm">{getPreferredTime(dayWeather.sunset, locale)}</Text>
</Group> </Group>
</Group> </Group>
@@ -285,7 +285,7 @@ const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) =>
<Stack gap="xs" align="center"> <Stack gap="xs" align="center">
{options.showCurrentWindSpeed && ( {options.showCurrentWindSpeed && (
<Group className="weather-current-wind-speed-group" wrap="nowrap" gap="xs"> <Group className="weather-current-wind-speed-group" wrap="nowrap" gap="xs">
<IconWind size="var(--mantine-font-size-md)" /> <IconWind size={16} />
<Text fz={16}> <Text fz={16}>
{t("currentWindSpeed", { {t("currentWindSpeed", {
currentWindSpeed: (options.useImperialSpeed currentWindSpeed: (options.useImperialSpeed
@@ -301,13 +301,13 @@ const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) =>
)} )}
{weather.daily[0]?.humidity !== undefined && ( {weather.daily[0]?.humidity !== undefined && (
<Group className="weather-humidity-group" wrap="nowrap" gap="xs"> <Group className="weather-humidity-group" wrap="nowrap" gap="xs">
<IconDroplets size="var(--mantine-font-size-md)" /> <IconDroplets size={16} />
<Text fz={16}>{t("dailyForecast.humidity", { humidity: weather.daily[0].humidity })}</Text> <Text fz={16}>{t("dailyForecast.humidity", { humidity: weather.daily[0].humidity })}</Text>
</Group> </Group>
)} )}
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm"> <Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm">
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<IconArrowUpRight size="var(--mantine-font-size-md)" /> <IconArrowUpRight size={16} />
<Text fz={16}> <Text fz={16}>
{getPreferredUnit( {getPreferredUnit(
weather.daily[0]?.maxTemp, weather.daily[0]?.maxTemp,
@@ -317,7 +317,7 @@ const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) =>
</Text> </Text>
</Group> </Group>
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<IconArrowDownRight size="var(--mantine-font-size-md)" /> <IconArrowDownRight size={16} />
<Text fz={16}> <Text fz={16}>
{getPreferredUnit( {getPreferredUnit(
weather.daily[0]?.minTemp, weather.daily[0]?.minTemp,
@@ -331,7 +331,7 @@ const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) =>
{options.showCity && ( {options.showCity && (
<> <>
<Group className="weather-city-group" wrap="nowrap" gap="xs"> <Group className="weather-city-group" wrap="nowrap" gap="xs">
<IconMapPin size="var(--mantine-font-size-md)" /> <IconMapPin size={16} />
<Text fz={16} style={{ whiteSpace: "nowrap" }}> <Text fz={16} style={{ whiteSpace: "nowrap" }}>
{options.location.name} {options.location.name}
</Text> </Text>
@@ -350,7 +350,7 @@ const WeeklyForecast = ({ options, weather, isEditMode, maxDays }: CompactWeathe
<Group className="weather-forecast-city-temp-group" wrap="nowrap" gap="md"> <Group className="weather-forecast-city-temp-group" wrap="nowrap" gap="md">
{options.showCity && ( {options.showCity && (
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<IconMapPin size="var(--mantine-font-size-md)" /> <IconMapPin size={16} />
<Text fz={16} style={{ whiteSpace: "nowrap" }}> <Text fz={16} style={{ whiteSpace: "nowrap" }}>
{options.location.name} {options.location.name}
</Text> </Text>
+7 -7
View File
@@ -95,15 +95,15 @@ export const WeatherDescription = ({
<Text fz="24px">{formatWeatherDate(time, locale, dateFormat)}</Text> <Text fz="24px">{formatWeatherDate(time, locale, dateFormat)}</Text>
<Text fz="16px">{t(`kind.${name}`)}</Text> <Text fz="16px">{t(`kind.${name}`)}</Text>
<List> <List>
<List.Item icon={<IconTemperaturePlus size="var(--mantine-font-size-sm)" />}>{`${tCommon("information.max")}: ${maxTemp}`}</List.Item> <List.Item icon={<IconTemperaturePlus size={15} />}>{`${tCommon("information.max")}: ${maxTemp}`}</List.Item>
<List.Item icon={<IconTemperatureMinus size="var(--mantine-font-size-sm)" />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item> <List.Item icon={<IconTemperatureMinus size={15} />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
<List.Item icon={<IconSun size="var(--mantine-font-size-sm)" />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item> <List.Item icon={<IconSun size={15} />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
<List.Item icon={<IconMoon size="var(--mantine-font-size-sm)" />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item> <List.Item icon={<IconMoon size={15} />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
{humidity !== undefined && ( {humidity !== undefined && (
<List.Item icon={<IconDroplets size="var(--mantine-font-size-sm)" />}>{t("dailyForecast.humidity", { humidity })}</List.Item> <List.Item icon={<IconDroplets size={15} />}>{t("dailyForecast.humidity", { humidity })}</List.Item>
)} )}
{maxWindSpeed !== undefined && ( {maxWindSpeed !== undefined && (
<List.Item icon={<IconWind size="var(--mantine-font-size-sm)" />}> <List.Item icon={<IconWind size={15} />}>
{t("dailyForecast.maxWindSpeed", { {t("dailyForecast.maxWindSpeed", {
maxWindSpeed: (useImperialSpeed ? metricToImperial(maxWindSpeed) : maxWindSpeed).toFixed(1), maxWindSpeed: (useImperialSpeed ? metricToImperial(maxWindSpeed) : maxWindSpeed).toFixed(1),
unit: useImperialSpeed ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour"), unit: useImperialSpeed ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour"),
@@ -111,7 +111,7 @@ export const WeatherDescription = ({
</List.Item> </List.Item>
)} )}
{maxWindGusts !== undefined && ( {maxWindGusts !== undefined && (
<List.Item icon={<IconWind size="var(--mantine-font-size-sm)" />}> <List.Item icon={<IconWind size={15} />}>
{t("dailyForecast.maxWindGusts", { {t("dailyForecast.maxWindGusts", {
maxWindGusts: (useImperialSpeed ? metricToImperial(maxWindGusts) : maxWindGusts).toFixed(1), maxWindGusts: (useImperialSpeed ? metricToImperial(maxWindGusts) : maxWindGusts).toFixed(1),
unit: useImperialSpeed ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour"), unit: useImperialSpeed ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour"),
@@ -88,7 +88,7 @@ export const WidgetIntegrationSelect = ({
return ( return (
<Combobox.Option value={item.id} key={item.id} active={multiSelectValues.includes(item.id)}> <Combobox.Option value={item.id} key={item.id} active={multiSelectValues.includes(item.id)}>
<Group gap="sm" align="center"> <Group gap="sm" align="center">
{multiSelectValues.includes(item.id) ? <CheckIcon size="var(--mantine-font-size-xs)" /> : null} {multiSelectValues.includes(item.id) ? <CheckIcon size={12} /> : null}
<Group gap={7} align="center"> <Group gap={7} align="center">
<Avatar src={getIconUrl(item.kind)} size="sm" /> <Avatar src={getIconUrl(item.kind)} size="sm" />
<Stack gap={0}> <Stack gap={0}>